Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e4cd18574 | ||
|
|
c1548c40ef | ||
|
|
f1c6da2da2 | ||
|
|
7ac63e1615 | ||
|
|
14d1d0b99c | ||
|
|
80e6e432b4 | ||
|
|
5d0bae1b22 | ||
|
|
b17928075a | ||
|
|
b6dbff1cb7 | ||
|
|
9f8faf70a1 | ||
|
|
440651d90d | ||
|
|
da8653305f | ||
|
|
88a7c5ec84 | ||
|
|
d72e89adab | ||
|
|
ce38fb2a49 | ||
|
|
103a7ad933 | ||
|
|
fff9992fe9 | ||
|
|
c253e418c3 | ||
|
|
285597740e | ||
|
|
7c7f5293af | ||
|
|
494c78675d | ||
|
|
f75c5efeec | ||
|
|
65785b892e | ||
|
|
6dde2bf9e5 | ||
|
|
48203364a9 | ||
|
|
805c308841 | ||
|
|
898654a174 | ||
|
|
499d54570c | ||
|
|
6c815dffc3 | ||
|
|
3a1b8e49cd | ||
|
|
f3e5d7132f | ||
|
|
88f606994c | ||
|
|
a155e1dfd6 | ||
|
|
f648a5dfd7 | ||
|
|
e309bd7149 | ||
|
|
0cfd678857 | ||
|
|
3d8825fc67 | ||
|
|
3e52e7feda | ||
|
|
98e322695d | ||
|
|
700c922baf | ||
|
|
69293f5198 | ||
|
|
46589f93d5 | ||
|
|
57546d29e6 | ||
|
|
50b0c7170a | ||
|
|
f719c3dadf | ||
|
|
105090a6ba | ||
|
|
edfadcc6a8 | ||
|
|
13184e2e16 | ||
|
|
1b8642331f | ||
|
|
6af129f414 | ||
|
|
02e82a6050 | ||
|
|
9a05db8b20 | ||
|
|
e06830e068 | ||
|
|
465baea6f8 | ||
|
|
2bd6e9eaf1 | ||
|
|
e6fe39a0dd | ||
|
|
67f68c00fe | ||
|
|
6156e51f8f | ||
|
|
461c45772a | ||
|
|
a221bffb52 | ||
|
|
26cfd818af | ||
|
|
e868e23dcb | ||
|
|
0bb6342472 | ||
|
|
d76bfe6e5b |
@@ -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"
|
||||
@@ -3,6 +3,9 @@ name: CI
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
code-quality:
|
||||
name: Code quality checks
|
||||
@@ -33,6 +36,10 @@ jobs:
|
||||
run: |
|
||||
ruff format --check .
|
||||
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
ruff check .
|
||||
|
||||
- name: Check types with mypy
|
||||
run: |
|
||||
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
|
||||
@@ -42,7 +49,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
@@ -68,3 +75,34 @@ jobs:
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
pytest --verbose --timeout=30
|
||||
|
||||
django5-integration:
|
||||
name: Django 5 integration tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'integration_tests/django5/pyproject.toml'
|
||||
|
||||
- name: Install Django 5 test project dependencies
|
||||
shell: bash
|
||||
working-directory: integration_tests/django5
|
||||
run: |
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync
|
||||
|
||||
- name: Run Django 5 middleware integration tests
|
||||
working-directory: integration_tests/django5
|
||||
run: |
|
||||
uv run pytest test_middleware.py test_exception_capture.py --verbose
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: "Generate References"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docs-generation:
|
||||
name: Generate references
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.11.11
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Generate references
|
||||
run: |
|
||||
uv run bin/docs generate-references
|
||||
|
||||
- name: Check for changes in references
|
||||
id: changes
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain references/)" ]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "New references generated in references directory:"
|
||||
git status --porcelain references/
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
echo "No new references generated in references directory"
|
||||
fi
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
with:
|
||||
commit_message: "Update generated references"
|
||||
file_pattern: references/
|
||||
@@ -12,15 +12,14 @@ jobs:
|
||||
release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
@@ -40,12 +39,20 @@ jobs:
|
||||
run: uv sync --extra dev
|
||||
|
||||
- name: Push releases to PyPI
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
run: uv run make release && uv run make release_analytics
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ env.REPO_VERSION }}
|
||||
release_name: ${{ env.REPO_VERSION }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "v${{ env.REPO_VERSION }}" \
|
||||
--title "${{ env.REPO_VERSION }}" \
|
||||
--generate-notes
|
||||
|
||||
- name: Dispatch generate-references for posthog-python
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh workflow run generate-references.yml --ref master
|
||||
@@ -19,3 +19,4 @@ pyrightconfig.json
|
||||
.env
|
||||
.DS_Store
|
||||
posthog-python-references.json
|
||||
.claude/settings.local.json
|
||||
|
||||
+137
@@ -1,3 +1,140 @@
|
||||
# 7.5.0 - 2026-01-06
|
||||
|
||||
feat: Capture Langchain, OpenAI and Anthropic errors as exceptions (if exception autocapture is enabled)
|
||||
feat: Add reference to exception in LLMA trace and span events
|
||||
|
||||
# 7.4.3 - 2026-01-02
|
||||
|
||||
Fixes cache creation cost for Langchain with Anthropic
|
||||
|
||||
# 7.4.2 - 2025-12-22
|
||||
|
||||
feat: add `in_app_modules` option to control code variables capturing
|
||||
|
||||
# 7.4.1 - 2025-12-19
|
||||
|
||||
fix: extract model from response for OpenAI stored prompts
|
||||
|
||||
When using OpenAI stored prompts, the model is defined in the OpenAI dashboard rather than passed in the API request. This fix adds a fallback to extract the model from the response object when not provided in kwargs, ensuring generations show up with the correct model and enabling cost calculations.
|
||||
|
||||
# 7.4.0 - 2025-12-16
|
||||
|
||||
feat: Add automatic retries for feature flag requests
|
||||
|
||||
Feature flag API requests now automatically retry on transient failures:
|
||||
|
||||
- Network errors (connection refused, DNS failures, timeouts)
|
||||
- Server errors (500, 502, 503, 504)
|
||||
- Up to 2 retries with exponential backoff (0.5s, 1s delays)
|
||||
|
||||
Rate limit (429) and quota (402) errors are not retried.
|
||||
|
||||
# 7.3.1 - 2025-12-06
|
||||
|
||||
fix: remove unused $exception_message and $exception_type
|
||||
|
||||
# 7.3.0 - 2025-12-05
|
||||
|
||||
feat: improve code variables capture masking
|
||||
|
||||
# 7.2.0 - 2025-12-01
|
||||
|
||||
feat: add $feature_flag_evaluated_at properties to $feature_flag_called events
|
||||
|
||||
# 7.1.0 - 2025-11-26
|
||||
|
||||
Add support for the async version of Gemini.
|
||||
|
||||
# 7.0.2 - 2025-11-18
|
||||
|
||||
Add support for Python 3.14.
|
||||
Projects upgrading to Python 3.14 should ensure any Pydantic models passed into the SDK use Pydantic v2, as Pydantic v1 is not compatible with Python 3.14.
|
||||
|
||||
# 7.0.1 - 2025-11-15
|
||||
|
||||
Try to use repr() when formatting code variables
|
||||
|
||||
# 7.0.0 - 2025-11-11
|
||||
|
||||
NB Python 3.9 is no longer supported
|
||||
|
||||
- chore(llma): update LLM provider SDKs to latest major versions
|
||||
- openai: 1.102.0 → 2.7.1
|
||||
- anthropic: 0.64.0 → 0.72.0
|
||||
- google-genai: 1.32.0 → 1.49.0
|
||||
- langchain-core: 0.3.75 → 1.0.3
|
||||
- langchain-openai: 0.3.32 → 1.0.2
|
||||
- langchain-anthropic: 0.3.19 → 1.0.1
|
||||
- langchain-community: 0.3.29 → 0.4.1
|
||||
- langgraph: 0.6.6 → 1.0.2
|
||||
|
||||
# 6.9.3 - 2025-11-10
|
||||
|
||||
- feat(ph-ai): PostHog properties dict in GenerationMetadata
|
||||
|
||||
# 6.9.2 - 2025-11-10
|
||||
|
||||
- fix(llma): fix cache token double subtraction in Langchain for non-Anthropic providers causing negative costs
|
||||
|
||||
# 6.9.1 - 2025-11-07
|
||||
|
||||
- fix(error-tracking): pass code variables config from init to client
|
||||
|
||||
# 6.9.0 - 2025-11-06
|
||||
|
||||
- feat(error-tracking): add local variables capture
|
||||
|
||||
# 6.8.0 - 2025-11-03
|
||||
|
||||
- feat(llma): send web search calls to be used for LLM cost calculations
|
||||
|
||||
# 6.7.14 - 2025-11-03
|
||||
|
||||
- fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355)
|
||||
- test(django): Add Django 5 integration test suite with real ASGI application testing async middleware behavior
|
||||
|
||||
# 6.7.13 - 2025-11-02
|
||||
|
||||
- fix(llma): cache cost calculation in the LangChain callback
|
||||
|
||||
# 6.7.12 - 2025-11-02
|
||||
|
||||
- fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329)
|
||||
- fix(ai/langchain): Add LangChain 1.0+ compatibility for CallbackHandler imports (fixes #362)
|
||||
|
||||
# 6.7.11 - 2025-10-28
|
||||
|
||||
- feat(ai): Add `$ai_framework` property for framework integrations (e.g. LangChain)
|
||||
|
||||
# 6.7.10 - 2025-10-24
|
||||
|
||||
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
|
||||
|
||||
# 6.7.9 - 2025-10-22
|
||||
|
||||
- fix(flags): multi-condition flags with static cohorts returning wrong variants
|
||||
|
||||
# 6.7.8 - 2025-10-16
|
||||
|
||||
- fix(llma): missing async for OpenAI's streaming implementation
|
||||
|
||||
# 6.7.7 - 2025-10-14
|
||||
|
||||
- fix: remove deprecated attribute $exception_personURL from exception events
|
||||
|
||||
# 6.7.6 - 2025-09-16
|
||||
|
||||
- fix: don't sort condition sets with variant overrides to the top
|
||||
- fix: Prevent core Client methods from raising exceptions
|
||||
|
||||
# 6.7.5 - 2025-09-16
|
||||
|
||||
- feat: Django middleware now supports async request handling.
|
||||
|
||||
# 6.7.4 - 2025-09-05
|
||||
|
||||
- fix: Missing system prompts for some providers
|
||||
|
||||
# 6.7.3 - 2025-09-04
|
||||
|
||||
- fix: missing usage tokens in Gemini
|
||||
|
||||
@@ -30,8 +30,8 @@ We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
|
||||
## PostHog recommends `uv` so...
|
||||
|
||||
```bash
|
||||
uv python install 3.9.19
|
||||
uv python pin 3.9.19
|
||||
uv python install 3.12
|
||||
uv python pin 3.12
|
||||
uv venv
|
||||
source env/bin/activate
|
||||
uv sync --extra dev --extra test
|
||||
|
||||
@@ -3,6 +3,7 @@ Constants for PostHog Python SDK documentation generation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Union
|
||||
from posthog.version import VERSION
|
||||
|
||||
# Documentation generation metadata
|
||||
DOCUMENTATION_METADATA = {
|
||||
@@ -27,8 +28,9 @@ DOCSTRING_PATTERNS = {
|
||||
|
||||
# Output file configuration
|
||||
OUTPUT_CONFIG: Dict[str, Union[str, int]] = {
|
||||
"output_dir": ".",
|
||||
"filename": "posthog-python-references.json",
|
||||
"output_dir": "./references",
|
||||
"filename": f"posthog-python-references-{VERSION}.json",
|
||||
"filename_latest": "posthog-python-references-latest.json",
|
||||
"indent": 2,
|
||||
}
|
||||
|
||||
|
||||
@@ -460,12 +460,23 @@ if __name__ == "__main__":
|
||||
try:
|
||||
documentation = generate_sdk_documentation()
|
||||
|
||||
# Write to file
|
||||
# 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}")
|
||||
|
||||
|
||||
+66
-60
@@ -35,54 +35,40 @@ 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"
|
||||
)
|
||||
# Check if project key is provided (required)
|
||||
if not project_key:
|
||||
print("❌ Missing PostHog project API key!")
|
||||
print(" Please set POSTHOG_PROJECT_API_KEY environment variable")
|
||||
print(" or copy .env.example to .env and fill in your values")
|
||||
exit(1)
|
||||
|
||||
# Test authentication before proceeding
|
||||
print("🔑 Testing PostHog authentication...")
|
||||
# Configure PostHog with credentials
|
||||
posthog.debug = False
|
||||
posthog.api_key = project_key
|
||||
posthog.project_api_key = project_key
|
||||
posthog.host = host
|
||||
posthog.poll_interval = 10
|
||||
|
||||
try:
|
||||
# Configure PostHog with credentials
|
||||
posthog.debug = False # Keep quiet during auth test
|
||||
posthog.api_key = project_key
|
||||
posthog.project_api_key = project_key
|
||||
# Check if personal API key is available for local evaluation
|
||||
local_eval_available = bool(personal_api_key)
|
||||
if personal_api_key:
|
||||
posthog.personal_api_key = personal_api_key
|
||||
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)
|
||||
print("🔑 PostHog Configuration:")
|
||||
print(f" Project API Key: {project_key[:9]}...")
|
||||
if local_eval_available:
|
||||
print(" Personal API Key: [SET]")
|
||||
else:
|
||||
print(" Personal API Key: [NOT SET] - Local evaluation examples will be skipped")
|
||||
print(f" Host: {host}\n")
|
||||
|
||||
# Display menu and get user choice
|
||||
print("🚀 PostHog Python SDK Demo - Choose an example to run:\n")
|
||||
print("1. Identify and capture examples")
|
||||
print("2. Feature flag local evaluation examples")
|
||||
local_eval_note = "" if local_eval_available else " [requires personal API key]"
|
||||
print(f"2. Feature flag local evaluation examples{local_eval_note}")
|
||||
print("3. Feature flag payload examples")
|
||||
print("4. Flag dependencies examples")
|
||||
print(f"4. Flag dependencies examples{local_eval_note}")
|
||||
print("5. Context management and tagging examples")
|
||||
print("6. Run all examples")
|
||||
print("7. Exit")
|
||||
@@ -148,6 +134,14 @@ if choice == "1":
|
||||
)
|
||||
|
||||
elif choice == "2":
|
||||
if not local_eval_available:
|
||||
print("\n❌ This example requires a personal API key for local evaluation.")
|
||||
print(
|
||||
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
|
||||
)
|
||||
posthog.shutdown()
|
||||
exit(1)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("FEATURE FLAG LOCAL EVALUATION EXAMPLES")
|
||||
print("=" * 60)
|
||||
@@ -215,6 +209,14 @@ elif choice == "3":
|
||||
print(f"Value (variant or enabled): {result.get_value()}")
|
||||
|
||||
elif choice == "4":
|
||||
if not local_eval_available:
|
||||
print("\n❌ This example requires a personal API key for local evaluation.")
|
||||
print(
|
||||
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
|
||||
)
|
||||
posthog.shutdown()
|
||||
exit(1)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("FLAG DEPENDENCIES EXAMPLES")
|
||||
print("=" * 60)
|
||||
@@ -429,6 +431,8 @@ elif choice == "5":
|
||||
|
||||
elif choice == "6":
|
||||
print("\n🔄 Running all examples...")
|
||||
if not local_eval_available:
|
||||
print(" (Skipping local evaluation examples - no personal API key set)\n")
|
||||
|
||||
# Run example 1
|
||||
print(f"\n{'🔸' * 20} IDENTIFY AND CAPTURE {'🔸' * 20}")
|
||||
@@ -447,35 +451,37 @@ elif choice == "6":
|
||||
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 2 (requires local evaluation)
|
||||
if local_eval_available:
|
||||
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
|
||||
print("🏁 Testing basic feature flags...")
|
||||
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
|
||||
print(
|
||||
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
|
||||
)
|
||||
|
||||
# Run example 3
|
||||
print(f"\n{'🔸' * 20} PAYLOADS {'🔸' * 20}")
|
||||
print("📦 Testing payloads...")
|
||||
print(f"Payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}")
|
||||
|
||||
# Run example 4
|
||||
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 4 (requires local evaluation)
|
||||
if local_eval_available:
|
||||
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
|
||||
print("🔗 Testing flag dependencies...")
|
||||
result1 = posthog.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"demo_user",
|
||||
person_properties={"email": "user@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
result2 = posthog.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"demo_user2",
|
||||
person_properties={"email": "user@other.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
print(f"✅ @example.com user: {result1}, regular user: {result2}")
|
||||
|
||||
# Run example 5
|
||||
print(f"\n{'🔸' * 20} CONTEXT MANAGEMENT {'🔸' * 20}")
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Redis-based distributed cache for PostHog feature flag definitions.
|
||||
|
||||
This example demonstrates how to implement a FlagDefinitionCacheProvider
|
||||
using Redis for multi-instance deployments (leader election pattern).
|
||||
|
||||
Usage:
|
||||
import redis
|
||||
from posthog import Posthog
|
||||
|
||||
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
|
||||
cache = RedisFlagCache(redis_client, service_key="my-service")
|
||||
|
||||
posthog = Posthog(
|
||||
"<project_api_key>",
|
||||
personal_api_key="<personal_api_key>",
|
||||
flag_definition_cache_provider=cache,
|
||||
)
|
||||
|
||||
Requirements:
|
||||
pip install redis
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from posthog import FlagDefinitionCacheData, FlagDefinitionCacheProvider
|
||||
from redis import Redis
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class RedisFlagCache(FlagDefinitionCacheProvider):
|
||||
"""
|
||||
A distributed cache for PostHog feature flag definitions using Redis.
|
||||
|
||||
In a multi-instance deployment (e.g., multiple serverless functions or containers),
|
||||
we want only ONE instance to poll PostHog for flag updates, while all instances
|
||||
share the cached results. This prevents N instances from making N redundant API calls.
|
||||
|
||||
The implementation uses leader election:
|
||||
- One instance "wins" and becomes responsible for fetching
|
||||
- Other instances read from the shared cache
|
||||
- If the leader dies, the lock expires (TTL) and another instance takes over
|
||||
|
||||
Uses Lua scripts for atomic operations, following Redis distributed lock best practices:
|
||||
https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
|
||||
"""
|
||||
|
||||
LOCK_TTL_MS = 60 * 1000 # 60 seconds, should be longer than the flags poll interval
|
||||
CACHE_TTL_SECONDS = 60 * 60 * 24 # 24 hours
|
||||
|
||||
# Lua script: acquire lock if free, or extend if we own it
|
||||
_LUA_TRY_LEAD = """
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if current == false then
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
||||
return 1
|
||||
elseif current == ARGV[1] then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
# Lua script: release lock only if we own it
|
||||
_LUA_STOP_LEAD = """
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis[str], service_key: str):
|
||||
"""
|
||||
Initialize the Redis flag cache.
|
||||
|
||||
Args:
|
||||
redis: A redis-py client instance. Must be configured with
|
||||
decode_responses=True for correct string handling.
|
||||
service_key: A unique identifier for this service/environment.
|
||||
Used to scope Redis keys, allowing multiple services
|
||||
or environments to share the same Redis instance.
|
||||
Examples: "my-api-prod", "checkout-service", "staging".
|
||||
|
||||
Redis Keys Created:
|
||||
- posthog:flags:{service_key} - Cached flag definitions (JSON)
|
||||
- posthog:flags:{service_key}:lock - Leader election lock
|
||||
|
||||
Example:
|
||||
redis_client = redis.Redis(
|
||||
host='localhost',
|
||||
port=6379,
|
||||
decode_responses=True
|
||||
)
|
||||
cache = RedisFlagCache(redis_client, service_key="my-api-prod")
|
||||
"""
|
||||
self._redis = redis
|
||||
self._cache_key = f"posthog:flags:{service_key}"
|
||||
self._lock_key = f"posthog:flags:{service_key}:lock"
|
||||
self._instance_id = str(uuid.uuid4())
|
||||
self._try_lead = self._redis.register_script(self._LUA_TRY_LEAD)
|
||||
self._stop_lead = self._redis.register_script(self._LUA_STOP_LEAD)
|
||||
|
||||
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
|
||||
"""
|
||||
Retrieve cached flag definitions from Redis.
|
||||
|
||||
Returns:
|
||||
Cached flag definitions if available, None otherwise.
|
||||
"""
|
||||
cached = self._redis.get(self._cache_key)
|
||||
return json.loads(cached) if cached else None
|
||||
|
||||
def should_fetch_flag_definitions(self) -> bool:
|
||||
"""
|
||||
Determines if this instance should fetch flag definitions from PostHog.
|
||||
|
||||
Atomically either:
|
||||
- Acquires the lock if no one holds it, OR
|
||||
- Extends the lock TTL if we already hold it
|
||||
|
||||
Returns:
|
||||
True if this instance is the leader and should fetch, False otherwise.
|
||||
"""
|
||||
result = self._try_lead(
|
||||
keys=[self._lock_key],
|
||||
args=[self._instance_id, self.LOCK_TTL_MS],
|
||||
)
|
||||
return result == 1
|
||||
|
||||
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
|
||||
"""
|
||||
Store fetched flag definitions in Redis.
|
||||
|
||||
Args:
|
||||
data: The flag definitions to cache.
|
||||
"""
|
||||
self._redis.set(self._cache_key, json.dumps(data), ex=self.CACHE_TTL_SECONDS)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""
|
||||
Release leadership if we hold it. Safe to call even if not the leader.
|
||||
"""
|
||||
self._stop_lead(keys=[self._lock_key], args=[self._instance_id])
|
||||
@@ -0,0 +1,4 @@
|
||||
db.sqlite3
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/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", "testdjango.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
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/5.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
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
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
URL configuration for testdjango project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from testdjango import views
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
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")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
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/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
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" },
|
||||
]
|
||||
@@ -26,14 +26,9 @@ posthog/client.py:0: error: Incompatible types in assignment (expression has typ
|
||||
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]
|
||||
|
||||
+102
-9
@@ -1,17 +1,64 @@
|
||||
import datetime # noqa: F401
|
||||
from typing import Callable, Dict, Optional, Any # noqa: F401
|
||||
from typing import Any, Callable, Dict, Optional # noqa: F401
|
||||
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from posthog.args import OptionalCaptureArgs, OptionalSetArgs, ExceptionArg
|
||||
from posthog.args import ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
|
||||
from posthog.client import Client
|
||||
from posthog.contexts import (
|
||||
new_context as inner_new_context,
|
||||
scoped as inner_scoped,
|
||||
tag as inner_tag,
|
||||
set_context_session as inner_set_context_session,
|
||||
identify_context as inner_identify_context,
|
||||
)
|
||||
from posthog.types import FeatureFlag, FlagsAndPayloads, FeatureFlagResult
|
||||
from posthog.contexts import (
|
||||
new_context as inner_new_context,
|
||||
)
|
||||
from posthog.contexts import (
|
||||
scoped as inner_scoped,
|
||||
)
|
||||
from posthog.contexts import (
|
||||
set_capture_exception_code_variables_context as inner_set_capture_exception_code_variables_context,
|
||||
)
|
||||
from posthog.contexts import (
|
||||
set_code_variables_ignore_patterns_context as inner_set_code_variables_ignore_patterns_context,
|
||||
)
|
||||
from posthog.contexts import (
|
||||
set_code_variables_mask_patterns_context as inner_set_code_variables_mask_patterns_context,
|
||||
)
|
||||
from posthog.contexts import (
|
||||
set_context_session as inner_set_context_session,
|
||||
)
|
||||
from posthog.contexts import (
|
||||
tag as inner_tag,
|
||||
)
|
||||
from posthog.contexts import (
|
||||
get_tags as inner_get_tags,
|
||||
)
|
||||
from posthog.exception_utils import (
|
||||
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
|
||||
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
|
||||
)
|
||||
from posthog.feature_flags import (
|
||||
InconclusiveMatchError as InconclusiveMatchError,
|
||||
)
|
||||
from posthog.feature_flags import (
|
||||
RequiresServerEvaluation as RequiresServerEvaluation,
|
||||
)
|
||||
from posthog.flag_definition_cache import (
|
||||
FlagDefinitionCacheData as FlagDefinitionCacheData,
|
||||
FlagDefinitionCacheProvider as FlagDefinitionCacheProvider,
|
||||
)
|
||||
from posthog.request import (
|
||||
disable_connection_reuse as disable_connection_reuse,
|
||||
enable_keep_alive as enable_keep_alive,
|
||||
set_socket_options as set_socket_options,
|
||||
SocketOptions as SocketOptions,
|
||||
)
|
||||
from posthog.types import (
|
||||
FeatureFlag,
|
||||
FlagsAndPayloads,
|
||||
)
|
||||
from posthog.types import (
|
||||
FeatureFlagResult as FeatureFlagResult,
|
||||
)
|
||||
from posthog.version import VERSION
|
||||
|
||||
__version__ = VERSION
|
||||
@@ -19,13 +66,14 @@ __version__ = VERSION
|
||||
"""Context management."""
|
||||
|
||||
|
||||
def new_context(fresh=False, capture_exceptions=True):
|
||||
def new_context(fresh=False, capture_exceptions=True, client=None):
|
||||
"""
|
||||
Create a new context scope that will be active for the duration of the with block.
|
||||
|
||||
Args:
|
||||
fresh: Whether to start with a fresh context (default: False)
|
||||
capture_exceptions: Whether to capture exceptions raised within the context (default: True)
|
||||
client: Optional Posthog client instance to use for this context (default: None)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@@ -38,7 +86,9 @@ def new_context(fresh=False, capture_exceptions=True):
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_new_context(fresh=fresh, capture_exceptions=capture_exceptions)
|
||||
return inner_new_context(
|
||||
fresh=fresh, capture_exceptions=capture_exceptions, client=client
|
||||
)
|
||||
|
||||
|
||||
def scoped(fresh=False, capture_exceptions=True):
|
||||
@@ -102,6 +152,27 @@ def identify_context(distinct_id: str):
|
||||
return inner_identify_context(distinct_id)
|
||||
|
||||
|
||||
def set_capture_exception_code_variables_context(enabled: bool):
|
||||
"""
|
||||
Set whether code variables are captured for the current context.
|
||||
"""
|
||||
return inner_set_capture_exception_code_variables_context(enabled)
|
||||
|
||||
|
||||
def set_code_variables_mask_patterns_context(mask_patterns: list):
|
||||
"""
|
||||
Variable names matching these patterns will be masked with *** when capturing code variables.
|
||||
"""
|
||||
return inner_set_code_variables_mask_patterns_context(mask_patterns)
|
||||
|
||||
|
||||
def set_code_variables_ignore_patterns_context(ignore_patterns: list):
|
||||
"""
|
||||
Variable names matching these patterns will be ignored completely when capturing code variables.
|
||||
"""
|
||||
return inner_set_code_variables_ignore_patterns_context(ignore_patterns)
|
||||
|
||||
|
||||
def tag(name: str, value: Any):
|
||||
"""
|
||||
Add a tag to the current context.
|
||||
@@ -122,6 +193,19 @@ def tag(name: str, value: Any):
|
||||
return inner_tag(name, value)
|
||||
|
||||
|
||||
def get_tags() -> Dict[str, Any]:
|
||||
"""
|
||||
Get all tags from the current context.
|
||||
|
||||
Returns:
|
||||
Dict of all tags in the current context
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_get_tags()
|
||||
|
||||
|
||||
"""Settings."""
|
||||
api_key = None # type: Optional[str]
|
||||
host = None # type: Optional[str]
|
||||
@@ -149,6 +233,11 @@ enable_local_evaluation = True # type: bool
|
||||
|
||||
default_client = None # type: Optional[Client]
|
||||
|
||||
capture_exception_code_variables = False
|
||||
code_variables_mask_patterns = DEFAULT_CODE_VARIABLES_MASK_PATTERNS
|
||||
code_variables_ignore_patterns = DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS
|
||||
in_app_modules = None # type: Optional[list[str]]
|
||||
|
||||
|
||||
# NOTE - this and following functions take unpacked kwargs because we needed to make
|
||||
# it impossible to write `posthog.capture(distinct-id, event-name)` - basically, to enforce
|
||||
@@ -743,6 +832,10 @@ def setup() -> Client:
|
||||
enable_exception_autocapture=enable_exception_autocapture,
|
||||
log_captured_exceptions=log_captured_exceptions,
|
||||
enable_local_evaluation=enable_local_evaluation,
|
||||
capture_exception_code_variables=capture_exception_code_variables,
|
||||
code_variables_mask_patterns=code_variables_mask_patterns,
|
||||
code_variables_ignore_patterns=code_variables_ignore_patterns,
|
||||
in_app_modules=in_app_modules,
|
||||
)
|
||||
|
||||
# always set incase user changes it
|
||||
|
||||
@@ -14,14 +14,9 @@ from posthog import setup
|
||||
from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage_async,
|
||||
extract_available_tool_calls,
|
||||
get_model_params,
|
||||
merge_system_prompt,
|
||||
merge_usage_stats,
|
||||
with_privacy_mode,
|
||||
)
|
||||
from posthog.ai.anthropic.anthropic_converter import (
|
||||
format_anthropic_streaming_content,
|
||||
extract_anthropic_usage_from_event,
|
||||
handle_anthropic_content_block_start,
|
||||
handle_anthropic_text_delta,
|
||||
@@ -220,66 +215,34 @@ class AsyncWrappedMessages(AsyncMessages):
|
||||
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
|
||||
|
||||
# Format output using converter
|
||||
formatted_content = format_anthropic_streaming_content(content_blocks)
|
||||
formatted_output = []
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_anthropic_streaming_input(kwargs)
|
||||
sanitized_input = sanitize_anthropic(formatted_input)
|
||||
|
||||
if formatted_content:
|
||||
formatted_output = [{"role": "assistant", "content": formatted_content}]
|
||||
else:
|
||||
# Fallback to accumulated content if no blocks
|
||||
formatted_output = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": accumulated_content}],
|
||||
}
|
||||
]
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "anthropic",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
sanitize_anthropic(merge_system_prompt(kwargs, "anthropic")),
|
||||
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,
|
||||
formatted_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_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,
|
||||
)
|
||||
|
||||
# Add tools if available
|
||||
available_tools = extract_available_tool_calls("anthropic", kwargs)
|
||||
|
||||
if available_tools:
|
||||
event_properties["$ai_tools"] = available_tools
|
||||
|
||||
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)
|
||||
|
||||
@@ -163,6 +163,32 @@ def format_anthropic_streaming_content(
|
||||
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).
|
||||
@@ -191,6 +217,10 @@ def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
|
||||
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
|
||||
|
||||
|
||||
@@ -222,6 +252,16 @@ def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .gemini import Client
|
||||
from .gemini_async import AsyncClient
|
||||
from .gemini_converter import (
|
||||
format_gemini_input,
|
||||
format_gemini_response,
|
||||
@@ -9,12 +10,14 @@ from .gemini_converter import (
|
||||
# Create a genai-like module for perfect drop-in replacement
|
||||
class _GenAI:
|
||||
Client = Client
|
||||
AsyncClient = AsyncClient
|
||||
|
||||
|
||||
genai = _GenAI()
|
||||
|
||||
__all__ = [
|
||||
"Client",
|
||||
"AsyncClient",
|
||||
"genai",
|
||||
"format_gemini_input",
|
||||
"format_gemini_response",
|
||||
|
||||
@@ -3,7 +3,8 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from posthog.ai.types import TokenUsage
|
||||
from posthog.ai.types import TokenUsage, StreamingEventData
|
||||
from posthog.ai.utils import merge_system_prompt
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
@@ -19,7 +20,6 @@ from posthog.ai.utils import (
|
||||
merge_usage_stats,
|
||||
)
|
||||
from posthog.ai.gemini.gemini_converter import (
|
||||
format_gemini_input,
|
||||
extract_gemini_usage_from_chunk,
|
||||
extract_gemini_content_from_chunk,
|
||||
format_gemini_streaming_output,
|
||||
@@ -304,7 +304,7 @@ class Models:
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
nonlocal accumulated_content
|
||||
try:
|
||||
for chunk in response:
|
||||
# Extract usage stats from chunk
|
||||
@@ -356,10 +356,8 @@ class Models:
|
||||
latency: float,
|
||||
output: Any,
|
||||
):
|
||||
from posthog.ai.types import StreamingEventData
|
||||
|
||||
# Prepare standardized event data
|
||||
formatted_input = self._format_input(contents)
|
||||
formatted_input = self._format_input(contents, **kwargs)
|
||||
sanitized_input = sanitize_gemini(formatted_input)
|
||||
|
||||
event_data = StreamingEventData(
|
||||
@@ -381,10 +379,12 @@ class Models:
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._ph_client, event_data)
|
||||
|
||||
def _format_input(self, contents):
|
||||
def _format_input(self, contents, **kwargs):
|
||||
"""Format input contents for PostHog tracking"""
|
||||
|
||||
return format_gemini_input(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,
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from posthog.ai.types import TokenUsage, StreamingEventData
|
||||
from posthog.ai.utils import merge_system_prompt
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Google Gemini SDK to use this feature: 'pip install google-genai'"
|
||||
)
|
||||
|
||||
from posthog import setup
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage_async,
|
||||
capture_streaming_event,
|
||||
merge_usage_stats,
|
||||
)
|
||||
from posthog.ai.gemini.gemini_converter import (
|
||||
extract_gemini_usage_from_chunk,
|
||||
extract_gemini_content_from_chunk,
|
||||
format_gemini_streaming_output,
|
||||
)
|
||||
from posthog.ai.sanitization import sanitize_gemini
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
class AsyncClient:
|
||||
"""
|
||||
An async drop-in replacement for genai.Client that automatically sends LLM usage events to PostHog.
|
||||
|
||||
Usage:
|
||||
client = AsyncClient(
|
||||
api_key="your_api_key",
|
||||
posthog_client=posthog_client,
|
||||
posthog_distinct_id="default_user", # Optional defaults
|
||||
posthog_properties={"team": "ai"} # Optional defaults
|
||||
)
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello world"],
|
||||
posthog_distinct_id="specific_user" # Override default
|
||||
)
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
vertexai: Optional[bool] = None,
|
||||
credentials: Optional[Any] = None,
|
||||
project: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
debug_config: Optional[Any] = None,
|
||||
http_options: Optional[Any] = None,
|
||||
posthog_client: Optional[PostHogClient] = None,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable (not required for Vertex AI)
|
||||
vertexai: Whether to use Vertex AI authentication
|
||||
credentials: Vertex AI credentials object
|
||||
project: GCP project ID for Vertex AI
|
||||
location: GCP location for Vertex AI
|
||||
debug_config: Debug configuration for the client
|
||||
http_options: HTTP options for the client
|
||||
posthog_client: PostHog client for tracking usage
|
||||
posthog_distinct_id: Default distinct ID for all calls (can be overridden per call)
|
||||
posthog_properties: Default properties for all calls (can be overridden per call)
|
||||
posthog_privacy_mode: Default privacy mode for all calls (can be overridden per call)
|
||||
posthog_groups: Default groups for all calls (can be overridden per call)
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
if self._ph_client is None:
|
||||
raise ValueError("posthog_client is required for PostHog tracking")
|
||||
|
||||
self.models = AsyncModels(
|
||||
api_key=api_key,
|
||||
vertexai=vertexai,
|
||||
credentials=credentials,
|
||||
project=project,
|
||||
location=location,
|
||||
debug_config=debug_config,
|
||||
http_options=http_options,
|
||||
posthog_client=self._ph_client,
|
||||
posthog_distinct_id=posthog_distinct_id,
|
||||
posthog_properties=posthog_properties,
|
||||
posthog_privacy_mode=posthog_privacy_mode,
|
||||
posthog_groups=posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class AsyncModels:
|
||||
"""
|
||||
Async Models interface that mimics genai.Client().aio.models with PostHog tracking.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient # Not None after __init__ validation
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
vertexai: Optional[bool] = None,
|
||||
credentials: Optional[Any] = None,
|
||||
project: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
debug_config: Optional[Any] = None,
|
||||
http_options: Optional[Any] = None,
|
||||
posthog_client: Optional[PostHogClient] = None,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable (not required for Vertex AI)
|
||||
vertexai: Whether to use Vertex AI authentication
|
||||
credentials: Vertex AI credentials object
|
||||
project: GCP project ID for Vertex AI
|
||||
location: GCP location for Vertex AI
|
||||
debug_config: Debug configuration for the client
|
||||
http_options: HTTP options for the client
|
||||
posthog_client: PostHog client for tracking usage
|
||||
posthog_distinct_id: Default distinct ID for all calls
|
||||
posthog_properties: Default properties for all calls
|
||||
posthog_privacy_mode: Default privacy mode for all calls
|
||||
posthog_groups: Default groups for all calls
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
if self._ph_client is None:
|
||||
raise ValueError("posthog_client is required for PostHog tracking")
|
||||
|
||||
# Store default PostHog settings
|
||||
self._default_distinct_id = posthog_distinct_id
|
||||
self._default_properties = posthog_properties or {}
|
||||
self._default_privacy_mode = posthog_privacy_mode
|
||||
self._default_groups = posthog_groups
|
||||
|
||||
# Build genai.Client arguments
|
||||
client_args: Dict[str, Any] = {}
|
||||
|
||||
# Add Vertex AI parameters if provided
|
||||
if vertexai is not None:
|
||||
client_args["vertexai"] = vertexai
|
||||
|
||||
if credentials is not None:
|
||||
client_args["credentials"] = credentials
|
||||
|
||||
if project is not None:
|
||||
client_args["project"] = project
|
||||
|
||||
if location is not None:
|
||||
client_args["location"] = location
|
||||
|
||||
if debug_config is not None:
|
||||
client_args["debug_config"] = debug_config
|
||||
|
||||
if http_options is not None:
|
||||
client_args["http_options"] = http_options
|
||||
|
||||
# Handle API key authentication
|
||||
if vertexai:
|
||||
# For Vertex AI, api_key is optional
|
||||
if api_key is not None:
|
||||
client_args["api_key"] = api_key
|
||||
else:
|
||||
# For non-Vertex AI mode, api_key is required (backwards compatibility)
|
||||
if api_key is None:
|
||||
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("API_KEY")
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError(
|
||||
"API key must be provided either as parameter or via GOOGLE_API_KEY/API_KEY environment variable"
|
||||
)
|
||||
|
||||
client_args["api_key"] = api_key
|
||||
|
||||
self._client = genai.Client(**client_args)
|
||||
self._base_url = "https://generativelanguage.googleapis.com"
|
||||
|
||||
def _merge_posthog_params(
|
||||
self,
|
||||
call_distinct_id: Optional[str],
|
||||
call_trace_id: Optional[str],
|
||||
call_properties: Optional[Dict[str, Any]],
|
||||
call_privacy_mode: Optional[bool],
|
||||
call_groups: Optional[Dict[str, Any]],
|
||||
):
|
||||
"""Merge call-level PostHog parameters with client defaults."""
|
||||
|
||||
# Use call-level values if provided, otherwise fall back to defaults
|
||||
distinct_id = (
|
||||
call_distinct_id
|
||||
if call_distinct_id is not None
|
||||
else self._default_distinct_id
|
||||
)
|
||||
privacy_mode = (
|
||||
call_privacy_mode
|
||||
if call_privacy_mode is not None
|
||||
else self._default_privacy_mode
|
||||
)
|
||||
groups = call_groups if call_groups is not None else self._default_groups
|
||||
|
||||
# Merge properties: default properties + call properties (call properties override)
|
||||
properties = dict(self._default_properties)
|
||||
|
||||
if call_properties:
|
||||
properties.update(call_properties)
|
||||
|
||||
if call_trace_id is None:
|
||||
call_trace_id = str(uuid.uuid4())
|
||||
|
||||
return distinct_id, call_trace_id, properties, privacy_mode, groups
|
||||
|
||||
async def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: Optional[bool] = None,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Generate content using Gemini's API while tracking usage in PostHog.
|
||||
|
||||
This method signature exactly matches genai.Client().aio.models.generate_content()
|
||||
with additional PostHog tracking parameters.
|
||||
|
||||
Args:
|
||||
model: The model to use (e.g., 'gemini-2.0-flash')
|
||||
contents: The input content for generation
|
||||
posthog_distinct_id: ID to associate with the usage event (overrides client default)
|
||||
posthog_trace_id: Trace UUID for linking events (auto-generated if not provided)
|
||||
posthog_properties: Extra properties to include in the event (merged with client defaults)
|
||||
posthog_privacy_mode: Whether to redact sensitive information (overrides client default)
|
||||
posthog_groups: Group analytics properties (overrides client default)
|
||||
**kwargs: Arguments passed to Gemini's generate_content
|
||||
"""
|
||||
|
||||
# Merge PostHog parameters
|
||||
distinct_id, trace_id, properties, privacy_mode, groups = (
|
||||
self._merge_posthog_params(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
)
|
||||
)
|
||||
|
||||
kwargs_with_contents = {"model": model, "contents": contents, **kwargs}
|
||||
|
||||
return await call_llm_and_track_usage_async(
|
||||
distinct_id,
|
||||
self._ph_client,
|
||||
"gemini",
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
self._base_url,
|
||||
self._client.aio.models.generate_content,
|
||||
**kwargs_with_contents,
|
||||
)
|
||||
|
||||
async def _generate_content_streaming(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
distinct_id: Optional[str],
|
||||
trace_id: Optional[str],
|
||||
properties: Optional[Dict[str, Any]],
|
||||
privacy_mode: bool,
|
||||
groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
|
||||
accumulated_content = []
|
||||
|
||||
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
|
||||
response = await self._client.aio.models.generate_content_stream(
|
||||
**kwargs_without_stream
|
||||
)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_gemini_usage_from_chunk(chunk)
|
||||
|
||||
if chunk_usage:
|
||||
# Gemini reports cumulative totals, not incremental values
|
||||
merge_usage_stats(usage_stats, chunk_usage, mode="cumulative")
|
||||
|
||||
# Extract content from chunk (now returns content blocks)
|
||||
content_block = extract_gemini_content_from_chunk(chunk)
|
||||
|
||||
if content_block is not None:
|
||||
accumulated_content.append(content_block)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
self._capture_streaming_event(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
return async_generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
distinct_id: Optional[str],
|
||||
trace_id: Optional[str],
|
||||
properties: Optional[Dict[str, Any]],
|
||||
privacy_mode: bool,
|
||||
groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
):
|
||||
# Prepare standardized event data
|
||||
formatted_input = self._format_input(contents, **kwargs)
|
||||
sanitized_input = sanitize_gemini(formatted_input)
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="gemini",
|
||||
model=model,
|
||||
base_url=self._base_url,
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_gemini_streaming_output(output),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=distinct_id,
|
||||
trace_id=trace_id,
|
||||
properties=properties,
|
||||
privacy_mode=privacy_mode,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._ph_client, event_data)
|
||||
|
||||
def _format_input(self, contents, **kwargs):
|
||||
"""Format input contents for PostHog tracking"""
|
||||
|
||||
# Create kwargs dict with contents for merge_system_prompt
|
||||
input_kwargs = {"contents": contents, **kwargs}
|
||||
return merge_system_prompt(input_kwargs, "gemini")
|
||||
|
||||
async def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: Optional[bool] = None,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
# Merge PostHog parameters
|
||||
distinct_id, trace_id, properties, privacy_mode, groups = (
|
||||
self._merge_posthog_params(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
)
|
||||
)
|
||||
|
||||
return await self._generate_content_streaming(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -29,35 +29,76 @@ class GeminiMessage(TypedDict, total=False):
|
||||
text: str
|
||||
|
||||
|
||||
def _extract_text_from_parts(parts: List[Any]) -> str:
|
||||
def _format_parts_as_content_blocks(parts: List[Any]) -> List[FormattedContentItem]:
|
||||
"""
|
||||
Extract and concatenate text from a parts array.
|
||||
Format Gemini parts array into structured content blocks.
|
||||
|
||||
Preserves structure for multimodal content (text + images) instead of
|
||||
concatenating everything into a string.
|
||||
|
||||
Args:
|
||||
parts: List of parts that may contain text content
|
||||
parts: List of parts that may contain text, inline_data, etc.
|
||||
|
||||
Returns:
|
||||
Concatenated text from all parts
|
||||
List of formatted content blocks
|
||||
"""
|
||||
|
||||
content_parts = []
|
||||
content_blocks: List[FormattedContentItem] = []
|
||||
|
||||
for part in parts:
|
||||
# Handle dict with text field
|
||||
if isinstance(part, dict) and "text" in part:
|
||||
content_parts.append(part["text"])
|
||||
content_blocks.append({"type": "text", "text": part["text"]})
|
||||
|
||||
# Handle string parts
|
||||
elif isinstance(part, str):
|
||||
content_parts.append(part)
|
||||
content_blocks.append({"type": "text", "text": part})
|
||||
|
||||
# Handle dict with inline_data (images, documents, etc.)
|
||||
elif isinstance(part, dict) and "inline_data" in part:
|
||||
inline_data = part["inline_data"]
|
||||
mime_type = inline_data.get("mime_type", "")
|
||||
content_type = "image" if mime_type.startswith("image/") else "document"
|
||||
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": content_type,
|
||||
"inline_data": inline_data,
|
||||
}
|
||||
)
|
||||
|
||||
# Handle object with text attribute
|
||||
elif hasattr(part, "text"):
|
||||
# Get the text attribute value
|
||||
text_value = getattr(part, "text", "")
|
||||
content_parts.append(text_value if text_value else str(part))
|
||||
if text_value:
|
||||
content_blocks.append({"type": "text", "text": text_value})
|
||||
|
||||
else:
|
||||
content_parts.append(str(part))
|
||||
# Handle object with inline_data attribute
|
||||
elif hasattr(part, "inline_data"):
|
||||
inline_data = part.inline_data
|
||||
# Convert to dict if needed
|
||||
if hasattr(inline_data, "mime_type") and hasattr(inline_data, "data"):
|
||||
# Determine type based on mime_type
|
||||
mime_type = inline_data.mime_type
|
||||
content_type = "image" if mime_type.startswith("image/") else "document"
|
||||
|
||||
return "".join(content_parts)
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": content_type,
|
||||
"inline_data": {
|
||||
"mime_type": mime_type,
|
||||
"data": inline_data.data,
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "image",
|
||||
"inline_data": inline_data,
|
||||
}
|
||||
)
|
||||
|
||||
return content_blocks
|
||||
|
||||
|
||||
def _format_dict_message(item: Dict[str, Any]) -> FormattedMessage:
|
||||
@@ -73,16 +114,17 @@ def _format_dict_message(item: Dict[str, Any]) -> FormattedMessage:
|
||||
|
||||
# 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}
|
||||
content_blocks = _format_parts_as_content_blocks(item["parts"])
|
||||
return {"role": item.get("role", "user"), "content": content_blocks}
|
||||
|
||||
# Handle dict with content field
|
||||
if "content" in item:
|
||||
content = item["content"]
|
||||
|
||||
if isinstance(content, list):
|
||||
# If content is a list, extract text from it
|
||||
content = _extract_text_from_parts(content)
|
||||
# If content is a list, format it as content blocks
|
||||
content_blocks = _format_parts_as_content_blocks(content)
|
||||
return {"role": item.get("role", "user"), "content": content_blocks}
|
||||
|
||||
elif not isinstance(content, str):
|
||||
content = str(content)
|
||||
@@ -110,14 +152,14 @@ def _format_object_message(item: Any) -> FormattedMessage:
|
||||
|
||||
# Handle object with parts attribute
|
||||
if hasattr(item, "parts") and hasattr(item.parts, "__iter__"):
|
||||
content = _extract_text_from_parts(item.parts)
|
||||
content_blocks = _format_parts_as_content_blocks(list(item.parts))
|
||||
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
|
||||
|
||||
# Ensure role is a string
|
||||
if not isinstance(role, str):
|
||||
role = "user"
|
||||
|
||||
return {"role": role, "content": content}
|
||||
return {"role": role, "content": content_blocks}
|
||||
|
||||
# Handle object with text attribute
|
||||
if hasattr(item, "text"):
|
||||
@@ -140,7 +182,8 @@ def _format_object_message(item: Any) -> FormattedMessage:
|
||||
content = item.content
|
||||
|
||||
if isinstance(content, list):
|
||||
content = _extract_text_from_parts(content)
|
||||
content_blocks = _format_parts_as_content_blocks(content)
|
||||
return {"role": role, "content": content_blocks}
|
||||
|
||||
elif not isinstance(content, str):
|
||||
content = str(content)
|
||||
@@ -193,6 +236,29 @@ def format_gemini_response(response: Any) -> List[FormattedMessage]:
|
||||
}
|
||||
)
|
||||
|
||||
elif hasattr(part, "inline_data") and part.inline_data:
|
||||
# Handle audio/media inline data
|
||||
import base64
|
||||
|
||||
inline_data = part.inline_data
|
||||
mime_type = getattr(inline_data, "mime_type", "audio/pcm")
|
||||
raw_data = getattr(inline_data, "data", b"")
|
||||
|
||||
# Encode binary data as base64 string for JSON serialization
|
||||
if isinstance(raw_data, bytes):
|
||||
data = base64.b64encode(raw_data).decode("utf-8")
|
||||
else:
|
||||
# Already a string (base64)
|
||||
data = raw_data
|
||||
|
||||
content.append(
|
||||
{
|
||||
"type": "audio",
|
||||
"mime_type": mime_type,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
if content:
|
||||
output.append(
|
||||
{
|
||||
@@ -220,6 +286,30 @@ def format_gemini_response(response: Any) -> List[FormattedMessage]:
|
||||
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.
|
||||
@@ -237,6 +327,38 @@ def extract_gemini_tools(kwargs: Dict[str, Any]) -> Optional[Any]:
|
||||
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.
|
||||
@@ -282,6 +404,61 @@ def format_gemini_input(contents: Any) -> List[FormattedMessage]:
|
||||
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.
|
||||
@@ -326,7 +503,14 @@ def extract_gemini_usage_from_response(response: Any) -> TokenUsage:
|
||||
if not hasattr(response, "usage_metadata") or not response.usage_metadata:
|
||||
return TokenUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
return _extract_usage_from_metadata(response.usage_metadata)
|
||||
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:
|
||||
@@ -342,11 +526,19 @@ def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
|
||||
|
||||
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
|
||||
|
||||
# Use the shared helper to extract usage
|
||||
usage = _extract_usage_from_metadata(chunk.usage_metadata)
|
||||
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
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
try:
|
||||
import langchain # noqa: F401
|
||||
import langchain_core # noqa: F401
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install LangChain to use this feature: 'pip install langchain'"
|
||||
"Please install LangChain to use this feature: 'pip install langchain-core'"
|
||||
)
|
||||
|
||||
import json
|
||||
@@ -20,8 +20,14 @@ from typing import (
|
||||
)
|
||||
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.agents import AgentAction, AgentFinish
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||
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,
|
||||
@@ -29,15 +35,15 @@ from langchain_core.messages import (
|
||||
FunctionMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
ToolCall,
|
||||
ToolMessage,
|
||||
)
|
||||
from langchain_core.outputs import ChatGeneration, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from posthog import setup
|
||||
from posthog.ai.utils import get_model_params, with_privacy_mode
|
||||
from posthog.ai.sanitization import sanitize_langchain
|
||||
from posthog.ai.utils import get_model_params, with_privacy_mode
|
||||
from posthog.client import Client
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
@@ -73,6 +79,8 @@ class GenerationMetadata(SpanMetadata):
|
||||
"""Base URL of the provider's API used in the run."""
|
||||
tools: Optional[List[Dict[str, Any]]] = None
|
||||
"""Tools provided to the model."""
|
||||
posthog_properties: Optional[Dict[str, Any]] = None
|
||||
"""PostHog properties of the run."""
|
||||
|
||||
|
||||
RunMetadata = Union[SpanMetadata, GenerationMetadata]
|
||||
@@ -414,6 +422,8 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
generation.model = model
|
||||
if provider := metadata.get("ls_provider"):
|
||||
generation.provider = provider
|
||||
|
||||
generation.posthog_properties = metadata.get("posthog_properties")
|
||||
try:
|
||||
base_url = serialized["kwargs"]["openai_api_base"]
|
||||
if base_url is not None:
|
||||
@@ -486,6 +496,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
"$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
|
||||
@@ -495,6 +506,14 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
if isinstance(outputs, BaseException):
|
||||
event_properties["$ai_error"] = _stringify_exception(outputs)
|
||||
event_properties["$ai_is_error"] = True
|
||||
event_properties = _capture_exception_and_update_properties(
|
||||
self._ph_client,
|
||||
outputs,
|
||||
self._distinct_id,
|
||||
self._groups,
|
||||
event_properties,
|
||||
)
|
||||
|
||||
elif outputs is not None:
|
||||
event_properties["$ai_output_state"] = with_privacy_mode(
|
||||
self._ph_client, self._privacy_mode, outputs
|
||||
@@ -556,18 +575,36 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
"$ai_http_status": 200,
|
||||
"$ai_latency": run.latency,
|
||||
"$ai_base_url": run.base_url,
|
||||
"$ai_framework": "langchain",
|
||||
}
|
||||
|
||||
if isinstance(run.posthog_properties, dict):
|
||||
event_properties.update(run.posthog_properties)
|
||||
|
||||
if run.tools:
|
||||
event_properties["$ai_tools"] = run.tools
|
||||
|
||||
if self._properties:
|
||||
event_properties.update(self._properties)
|
||||
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if isinstance(output, BaseException):
|
||||
event_properties["$ai_http_status"] = _get_http_status(output)
|
||||
event_properties["$ai_error"] = _stringify_exception(output)
|
||||
event_properties["$ai_is_error"] = True
|
||||
|
||||
event_properties = _capture_exception_and_update_properties(
|
||||
self._ph_client,
|
||||
output,
|
||||
self._distinct_id,
|
||||
self._groups,
|
||||
event_properties,
|
||||
)
|
||||
else:
|
||||
# Add usage
|
||||
usage = _parse_usage(output)
|
||||
usage = _parse_usage(output, run.provider, run.model)
|
||||
event_properties["$ai_input_tokens"] = usage.input_tokens
|
||||
event_properties["$ai_output_tokens"] = usage.output_tokens
|
||||
event_properties["$ai_cache_creation_input_tokens"] = (
|
||||
@@ -592,12 +629,6 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
self._ph_client, self._privacy_mode, completions
|
||||
)
|
||||
|
||||
if self._properties:
|
||||
event_properties.update(self._properties)
|
||||
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._ph_client.capture(
|
||||
distinct_id=self._distinct_id or trace_id,
|
||||
event="$ai_generation",
|
||||
@@ -688,6 +719,8 @@ class ModelUsage:
|
||||
|
||||
def _parse_usage_model(
|
||||
usage: Union[BaseModel, dict],
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> ModelUsage:
|
||||
if isinstance(usage, BaseModel):
|
||||
usage = usage.__dict__
|
||||
@@ -750,15 +783,38 @@ def _parse_usage_model(
|
||||
"cache_read": "cache_read_tokens",
|
||||
"reasoning": "reasoning_tokens",
|
||||
}
|
||||
return ModelUsage(
|
||||
normalized_usage = ModelUsage(
|
||||
**{
|
||||
dataclass_key: parsed_usage.get(mapped_key) or 0
|
||||
for mapped_key, dataclass_key in field_mapping.items()
|
||||
},
|
||||
)
|
||||
# For Anthropic providers, LangChain reports input_tokens as the sum of all input tokens.
|
||||
# Our cost calculation expects them to be separate for Anthropic, so we subtract cache tokens.
|
||||
# Both cache_read and cache_write tokens should be subtracted since Anthropic's raw API
|
||||
# reports input_tokens as tokens NOT read from or used to create a cache.
|
||||
# For other providers (OpenAI, etc.), input_tokens already excludes cache tokens as expected.
|
||||
# Match logic consistent with plugin-server: exact match on provider OR substring match on model
|
||||
is_anthropic = False
|
||||
if provider and provider.lower() == "anthropic":
|
||||
is_anthropic = True
|
||||
elif model and "anthropic" in model.lower():
|
||||
is_anthropic = True
|
||||
|
||||
if is_anthropic and normalized_usage.input_tokens:
|
||||
cache_tokens = (normalized_usage.cache_read_tokens or 0) + (
|
||||
normalized_usage.cache_write_tokens or 0
|
||||
)
|
||||
if cache_tokens > 0:
|
||||
normalized_usage.input_tokens = max(
|
||||
normalized_usage.input_tokens - cache_tokens, 0
|
||||
)
|
||||
return normalized_usage
|
||||
|
||||
|
||||
def _parse_usage(response: LLMResult) -> ModelUsage:
|
||||
def _parse_usage(
|
||||
response: LLMResult, provider: Optional[str] = None, model: Optional[str] = None
|
||||
) -> ModelUsage:
|
||||
# langchain-anthropic uses the usage field
|
||||
llm_usage_keys = ["token_usage", "usage"]
|
||||
llm_usage: ModelUsage = ModelUsage(
|
||||
@@ -772,13 +828,15 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
|
||||
if response.llm_output is not None:
|
||||
for key in llm_usage_keys:
|
||||
if response.llm_output.get(key):
|
||||
llm_usage = _parse_usage_model(response.llm_output[key])
|
||||
llm_usage = _parse_usage_model(
|
||||
response.llm_output[key], provider, model
|
||||
)
|
||||
break
|
||||
|
||||
if hasattr(response, "generations"):
|
||||
for generation in response.generations:
|
||||
if "usage" in generation:
|
||||
llm_usage = _parse_usage_model(generation["usage"])
|
||||
llm_usage = _parse_usage_model(generation["usage"], provider, model)
|
||||
break
|
||||
|
||||
for generation_chunk in generation:
|
||||
@@ -786,7 +844,9 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
|
||||
"usage_metadata" in generation_chunk.generation_info
|
||||
):
|
||||
llm_usage = _parse_usage_model(
|
||||
generation_chunk.generation_info["usage_metadata"]
|
||||
generation_chunk.generation_info["usage_metadata"],
|
||||
provider,
|
||||
model,
|
||||
)
|
||||
break
|
||||
|
||||
@@ -813,12 +873,33 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
|
||||
bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
|
||||
)
|
||||
if chunk_usage:
|
||||
llm_usage = _parse_usage_model(chunk_usage)
|
||||
llm_usage = _parse_usage_model(chunk_usage, provider, model)
|
||||
break
|
||||
|
||||
return llm_usage
|
||||
|
||||
|
||||
def _capture_exception_and_update_properties(
|
||||
client: Client,
|
||||
exception: BaseException,
|
||||
distinct_id: Optional[Union[str, int, UUID]],
|
||||
groups: Optional[Dict[str, Any]],
|
||||
event_properties: Dict[str, Any],
|
||||
):
|
||||
if client.enable_exception_autocapture:
|
||||
exception_id = client.capture_exception(
|
||||
exception,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups,
|
||||
properties=event_properties,
|
||||
)
|
||||
|
||||
if exception_id:
|
||||
event_properties["$exception_event_id"] = exception_id
|
||||
|
||||
return event_properties
|
||||
|
||||
|
||||
def _get_http_status(error: BaseException) -> int:
|
||||
# OpenAI: https://github.com/openai/openai-python/blob/main/src/openai/_exceptions.py
|
||||
# Anthropic: https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_exceptions.py
|
||||
|
||||
@@ -124,14 +124,23 @@ class WrappedResponses:
|
||||
start_time = time.time()
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
final_content = []
|
||||
model_from_response: Optional[str] = None
|
||||
response = self._original.create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal final_content # noqa: F824
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
# Extract model from response object in chunk (for stored prompts)
|
||||
if hasattr(chunk, "response") and chunk.response:
|
||||
if model_from_response is None and hasattr(
|
||||
chunk.response, "model"
|
||||
):
|
||||
model_from_response = chunk.response.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
|
||||
|
||||
@@ -161,6 +170,7 @@ class WrappedResponses:
|
||||
latency,
|
||||
output,
|
||||
None, # Responses API doesn't have tools
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
return generator()
|
||||
@@ -177,6 +187,7 @@ class WrappedResponses:
|
||||
latency: float,
|
||||
output: Any,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
from posthog.ai.types import StreamingEventData
|
||||
from posthog.ai.openai.openai_converter import (
|
||||
@@ -189,9 +200,12 @@ class WrappedResponses:
|
||||
formatted_input = format_openai_streaming_input(kwargs, "responses")
|
||||
sanitized_input = sanitize_openai_response(formatted_input)
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="openai",
|
||||
model=kwargs.get("model", "unknown"),
|
||||
model=model,
|
||||
base_url=str(self._client.base_url),
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
@@ -320,6 +334,7 @@ class WrappedCompletions:
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
accumulated_content = []
|
||||
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
|
||||
model_from_response: Optional[str] = None
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
@@ -329,9 +344,14 @@ class WrappedCompletions:
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
nonlocal accumulated_tool_calls
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
# Extract model from chunk (Chat Completions chunks have model field)
|
||||
if model_from_response is None and hasattr(chunk, "model"):
|
||||
model_from_response = chunk.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
|
||||
|
||||
@@ -376,6 +396,7 @@ class WrappedCompletions:
|
||||
accumulated_content,
|
||||
tool_calls_list,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
return generator()
|
||||
@@ -393,6 +414,7 @@ class WrappedCompletions:
|
||||
output: Any,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
from posthog.ai.types import StreamingEventData
|
||||
from posthog.ai.openai.openai_converter import (
|
||||
@@ -405,9 +427,12 @@ class WrappedCompletions:
|
||||
formatted_input = format_openai_streaming_input(kwargs, "chat")
|
||||
sanitized_input = sanitize_openai(formatted_input)
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="openai",
|
||||
model=kwargs.get("model", "unknown"),
|
||||
model=model,
|
||||
base_url=str(self._client.base_url),
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
|
||||
@@ -128,14 +128,23 @@ class WrappedResponses:
|
||||
start_time = time.time()
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
final_content = []
|
||||
response = self._original.create(**kwargs)
|
||||
model_from_response: Optional[str] = None
|
||||
response = await self._original.create(**kwargs)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal final_content # noqa: F824
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
# Extract model from response object in chunk (for stored prompts)
|
||||
if hasattr(chunk, "response") and chunk.response:
|
||||
if model_from_response is None and hasattr(
|
||||
chunk.response, "model"
|
||||
):
|
||||
model_from_response = chunk.response.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
|
||||
|
||||
@@ -166,6 +175,7 @@ class WrappedResponses:
|
||||
latency,
|
||||
output,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
return async_generator()
|
||||
@@ -182,13 +192,17 @@ class WrappedResponses:
|
||||
latency: float,
|
||||
output: Any,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model": model,
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
@@ -213,6 +227,15 @@ class WrappedResponses:
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
# Add web search count if present
|
||||
web_search_count = usage_stats.get("web_search_count")
|
||||
if (
|
||||
web_search_count is not None
|
||||
and isinstance(web_search_count, int)
|
||||
and web_search_count > 0
|
||||
):
|
||||
event_properties["$ai_web_search_count"] = web_search_count
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
@@ -341,19 +364,25 @@ class WrappedCompletions:
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
accumulated_content = []
|
||||
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
|
||||
model_from_response: Optional[str] = None
|
||||
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
response = self._original.create(**kwargs)
|
||||
response = await self._original.create(**kwargs)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
nonlocal accumulated_tool_calls
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
# Extract model from chunk (Chat Completions chunks have model field)
|
||||
if model_from_response is None and hasattr(chunk, "model"):
|
||||
model_from_response = chunk.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
|
||||
if chunk_usage:
|
||||
@@ -396,6 +425,7 @@ class WrappedCompletions:
|
||||
accumulated_content,
|
||||
tool_calls_list,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
return async_generator()
|
||||
@@ -413,13 +443,17 @@ class WrappedCompletions:
|
||||
output: Any,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model": model,
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
@@ -444,6 +478,16 @@ class WrappedCompletions:
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
# Add web search count if present
|
||||
web_search_count = usage_stats.get("web_search_count")
|
||||
|
||||
if (
|
||||
web_search_count is not None
|
||||
and isinstance(web_search_count, int)
|
||||
and web_search_count > 0
|
||||
):
|
||||
event_properties["$ai_web_search_count"] = web_search_count
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
@@ -499,7 +543,7 @@ class WrappedEmbeddings:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
start_time = time.time()
|
||||
response = self._original.create(**kwargs)
|
||||
response = await self._original.create(**kwargs)
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
|
||||
@@ -67,6 +67,12 @@ def format_openai_response(response: Any) -> List[FormattedMessage]:
|
||||
}
|
||||
)
|
||||
|
||||
# Handle audio output (gpt-4o-audio-preview)
|
||||
if hasattr(choice.message, "audio") and choice.message.audio:
|
||||
# Convert Pydantic model to dict to capture all fields from OpenAI
|
||||
audio_dict = choice.message.audio.model_dump()
|
||||
content.append({"type": "audio", **audio_dict})
|
||||
|
||||
if content:
|
||||
output.append(
|
||||
{
|
||||
@@ -255,6 +261,113 @@ def format_openai_streaming_content(
|
||||
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).
|
||||
@@ -312,6 +425,10 @@ def extract_openai_usage_from_response(response: Any) -> TokenUsage:
|
||||
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
|
||||
|
||||
|
||||
@@ -334,6 +451,13 @@ def extract_openai_usage_from_chunk(
|
||||
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
|
||||
|
||||
@@ -386,6 +510,12 @@ def extract_openai_usage_from_chunk(
|
||||
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
|
||||
|
||||
|
||||
@@ -606,7 +736,6 @@ def format_openai_streaming_input(
|
||||
Returns:
|
||||
Formatted input ready for PostHog tracking
|
||||
"""
|
||||
if api_type == "chat":
|
||||
return kwargs.get("messages")
|
||||
else: # responses API
|
||||
return kwargs.get("input")
|
||||
from posthog.ai.utils import merge_system_prompt
|
||||
|
||||
return merge_system_prompt(kwargs, "openai")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
@@ -5,6 +6,15 @@ from urllib.parse import urlparse
|
||||
REDACTED_IMAGE_PLACEHOLDER = "[base64 image redacted]"
|
||||
|
||||
|
||||
def _is_multimodal_enabled() -> bool:
|
||||
"""Check if multimodal capture is enabled via environment variable."""
|
||||
return os.environ.get("_INTERNAL_LLMA_MULTIMODAL", "").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def is_base64_data_url(text: str) -> bool:
|
||||
return re.match(r"^data:([^;]+);base64,", text) is not None
|
||||
|
||||
@@ -27,6 +37,9 @@ def is_raw_base64(text: str) -> bool:
|
||||
|
||||
|
||||
def redact_base64_data_url(value: Any) -> Any:
|
||||
if _is_multimodal_enabled():
|
||||
return value
|
||||
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
@@ -83,6 +96,11 @@ def sanitize_openai_image(item: Any) -> Any:
|
||||
},
|
||||
}
|
||||
|
||||
if item.get("type") == "audio" and "data" in item:
|
||||
if _is_multimodal_enabled():
|
||||
return item
|
||||
return {**item, "data": REDACTED_IMAGE_PLACEHOLDER}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
@@ -100,6 +118,9 @@ def sanitize_openai_response_image(item: Any) -> Any:
|
||||
|
||||
|
||||
def sanitize_anthropic_image(item: Any) -> Any:
|
||||
if _is_multimodal_enabled():
|
||||
return item
|
||||
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
@@ -109,8 +130,6 @@ def sanitize_anthropic_image(item: Any) -> Any:
|
||||
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": {
|
||||
@@ -123,6 +142,9 @@ def sanitize_anthropic_image(item: Any) -> Any:
|
||||
|
||||
|
||||
def sanitize_gemini_part(part: Any) -> Any:
|
||||
if _is_multimodal_enabled():
|
||||
return part
|
||||
|
||||
if not isinstance(part, dict):
|
||||
return part
|
||||
|
||||
@@ -131,8 +153,6 @@ def sanitize_gemini_part(part: Any) -> Any:
|
||||
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": {
|
||||
@@ -185,7 +205,9 @@ def sanitize_langchain_image(item: Any) -> Any:
|
||||
and isinstance(item.get("source"), dict)
|
||||
and "data" in item["source"]
|
||||
):
|
||||
# Anthropic style - raw base64 in structured format, always redact
|
||||
if _is_multimodal_enabled():
|
||||
return item
|
||||
|
||||
return {
|
||||
**item,
|
||||
"source": {
|
||||
|
||||
@@ -63,6 +63,7 @@ class TokenUsage(TypedDict, total=False):
|
||||
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):
|
||||
|
||||
+227
-161
@@ -1,15 +1,16 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional, cast
|
||||
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog.ai.types import StreamingEventData, TokenUsage
|
||||
from posthog import get_tags, identify_context, new_context, tag
|
||||
from posthog.ai.sanitization import (
|
||||
sanitize_openai,
|
||||
sanitize_anthropic,
|
||||
sanitize_gemini,
|
||||
sanitize_langchain,
|
||||
sanitize_openai,
|
||||
)
|
||||
from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
def merge_usage_stats(
|
||||
@@ -53,6 +54,12 @@ def merge_usage_stats(
|
||||
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:
|
||||
@@ -67,6 +74,9 @@ def merge_usage_stats(
|
||||
]
|
||||
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'")
|
||||
|
||||
@@ -158,7 +168,9 @@ def extract_available_tool_calls(provider: str, kwargs: Dict[str, Any]):
|
||||
return None
|
||||
|
||||
|
||||
def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
|
||||
def merge_system_prompt(
|
||||
kwargs: Dict[str, Any], provider: str
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Merge system prompts and format messages for the given provider.
|
||||
"""
|
||||
@@ -169,10 +181,11 @@ def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
|
||||
system = kwargs.get("system")
|
||||
return format_anthropic_input(messages, system)
|
||||
elif provider == "gemini":
|
||||
from posthog.ai.gemini.gemini_converter import format_gemini_input
|
||||
from posthog.ai.gemini.gemini_converter import format_gemini_input_with_system
|
||||
|
||||
contents = kwargs.get("contents", [])
|
||||
return format_gemini_input(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
|
||||
|
||||
@@ -187,9 +200,11 @@ def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
|
||||
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
|
||||
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:
|
||||
@@ -207,9 +222,11 @@ def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
|
||||
)
|
||||
else:
|
||||
# Create a new system message with instructions
|
||||
messages = [
|
||||
{"role": "system", "content": kwargs.get("instructions")}
|
||||
] + messages
|
||||
instruction_msg = cast(
|
||||
FormattedMessage,
|
||||
{"role": "system", "content": kwargs.get("instructions")},
|
||||
)
|
||||
messages = [instruction_msg] + messages
|
||||
|
||||
return messages
|
||||
|
||||
@@ -240,90 +257,108 @@ def call_llm_and_track_usage(
|
||||
usage: TokenUsage = TokenUsage()
|
||||
error_params: Dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
response = call_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(
|
||||
exc, "status_code", 0
|
||||
) # default to 0 becuase its likely an SDK error
|
||||
error_params = {
|
||||
"$ai_is_error": True,
|
||||
"$ai_error": exc.__str__(),
|
||||
}
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
with new_context(client=ph_client, capture_exceptions=False):
|
||||
if posthog_distinct_id:
|
||||
identify_context(posthog_distinct_id)
|
||||
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
try:
|
||||
response = call_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(
|
||||
exc, "status_code", 0
|
||||
) # default to 0 becuase its likely an SDK error
|
||||
error_params = {
|
||||
"$ai_is_error": True,
|
||||
"$ai_error": exc.__str__(),
|
||||
}
|
||||
# TODO: Add exception capture for OpenAI/Anthropic/Gemini wrappers when
|
||||
# enable_exception_autocapture is True, similar to LangChain callbacks.
|
||||
# See _capture_exception_and_update_properties in langchain/callbacks.py
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
if response and (
|
||||
hasattr(response, "usage")
|
||||
or (provider == "gemini" and hasattr(response, "usage_metadata"))
|
||||
):
|
||||
usage = get_usage(response, provider)
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
messages = merge_system_prompt(kwargs, provider)
|
||||
sanitized_messages = sanitize_messages(messages, provider)
|
||||
if response and (
|
||||
hasattr(response, "usage")
|
||||
or (provider == "gemini" and hasattr(response, "usage_metadata"))
|
||||
):
|
||||
usage = get_usage(response, 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, sanitized_messages
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, format_response(response, provider)
|
||||
),
|
||||
"$ai_http_status": http_status,
|
||||
"$ai_input_tokens": usage.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage.get("output_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(base_url),
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
}
|
||||
messages = merge_system_prompt(kwargs, provider)
|
||||
sanitized_messages = sanitize_messages(messages, provider)
|
||||
|
||||
available_tool_calls = extract_available_tool_calls(provider, kwargs)
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
cache_read = usage.get("cache_read_input_tokens")
|
||||
if cache_read is not None and cache_read > 0:
|
||||
event_properties["$ai_cache_read_input_tokens"] = cache_read
|
||||
|
||||
cache_creation = usage.get("cache_creation_input_tokens")
|
||||
if cache_creation is not None and cache_creation > 0:
|
||||
event_properties["$ai_cache_creation_input_tokens"] = cache_creation
|
||||
|
||||
reasoning = usage.get("reasoning_tokens")
|
||||
if reasoning is not None and reasoning > 0:
|
||||
event_properties["$ai_reasoning_tokens"] = reasoning
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Process instructions for Responses API
|
||||
if provider == "openai" and kwargs.get("instructions") is not None:
|
||||
event_properties["$ai_instructions"] = with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, kwargs.get("instructions")
|
||||
tag("$ai_provider", provider)
|
||||
tag("$ai_model", kwargs.get("model") or getattr(response, "model", None))
|
||||
tag("$ai_model_parameters", get_model_params(kwargs))
|
||||
tag(
|
||||
"$ai_input",
|
||||
with_privacy_mode(ph_client, posthog_privacy_mode, sanitized_messages),
|
||||
)
|
||||
|
||||
# send the event to posthog
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
tag(
|
||||
"$ai_output_choices",
|
||||
with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, format_response(response, provider)
|
||||
),
|
||||
)
|
||||
tag("$ai_http_status", http_status)
|
||||
tag("$ai_input_tokens", usage.get("input_tokens", 0))
|
||||
tag("$ai_output_tokens", usage.get("output_tokens", 0))
|
||||
tag("$ai_latency", latency)
|
||||
tag("$ai_trace_id", posthog_trace_id)
|
||||
tag("$ai_base_url", str(base_url))
|
||||
|
||||
if error:
|
||||
raise error
|
||||
available_tool_calls = extract_available_tool_calls(provider, kwargs)
|
||||
|
||||
if available_tool_calls:
|
||||
tag("$ai_tools", available_tool_calls)
|
||||
|
||||
cache_read = usage.get("cache_read_input_tokens")
|
||||
if cache_read is not None and cache_read > 0:
|
||||
tag("$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:
|
||||
tag("$ai_cache_creation_input_tokens", cache_creation)
|
||||
|
||||
reasoning = usage.get("reasoning_tokens")
|
||||
if reasoning is not None and reasoning > 0:
|
||||
tag("$ai_reasoning_tokens", reasoning)
|
||||
|
||||
web_search_count = usage.get("web_search_count")
|
||||
if web_search_count is not None and web_search_count > 0:
|
||||
tag("$ai_web_search_count", web_search_count)
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
tag("$process_person_profile", False)
|
||||
|
||||
# Process instructions for Responses API
|
||||
if provider == "openai" and kwargs.get("instructions") is not None:
|
||||
tag(
|
||||
"$ai_instructions",
|
||||
with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, kwargs.get("instructions")
|
||||
),
|
||||
)
|
||||
|
||||
# send the event to posthog
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties={
|
||||
**get_tags(),
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
},
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise error
|
||||
|
||||
return response
|
||||
|
||||
@@ -347,86 +382,108 @@ async def call_llm_and_track_usage_async(
|
||||
usage: TokenUsage = TokenUsage()
|
||||
error_params: Dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
response = await call_async_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(
|
||||
exc, "status_code", 0
|
||||
) # default to 0 because its likely an SDK error
|
||||
error_params = {
|
||||
"$ai_is_error": True,
|
||||
"$ai_error": exc.__str__(),
|
||||
}
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
with new_context(client=ph_client, capture_exceptions=False):
|
||||
if posthog_distinct_id:
|
||||
identify_context(posthog_distinct_id)
|
||||
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
try:
|
||||
response = await call_async_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(
|
||||
exc, "status_code", 0
|
||||
) # default to 0 because its likely an SDK error
|
||||
error_params = {
|
||||
"$ai_is_error": True,
|
||||
"$ai_error": exc.__str__(),
|
||||
}
|
||||
# TODO: Add exception capture for OpenAI/Anthropic/Gemini wrappers when
|
||||
# enable_exception_autocapture is True, similar to LangChain callbacks.
|
||||
# See _capture_exception_and_update_properties in langchain/callbacks.py
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
if response and (
|
||||
hasattr(response, "usage")
|
||||
or (provider == "gemini" and hasattr(response, "usage_metadata"))
|
||||
):
|
||||
usage = get_usage(response, provider)
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
messages = merge_system_prompt(kwargs, provider)
|
||||
sanitized_messages = sanitize_messages(messages, provider)
|
||||
if response and (
|
||||
hasattr(response, "usage")
|
||||
or (provider == "gemini" and hasattr(response, "usage_metadata"))
|
||||
):
|
||||
usage = get_usage(response, 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, sanitized_messages
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, format_response(response, provider)
|
||||
),
|
||||
"$ai_http_status": http_status,
|
||||
"$ai_input_tokens": usage.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage.get("output_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(base_url),
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
}
|
||||
messages = merge_system_prompt(kwargs, provider)
|
||||
sanitized_messages = sanitize_messages(messages, provider)
|
||||
|
||||
available_tool_calls = extract_available_tool_calls(provider, kwargs)
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
cache_read = usage.get("cache_read_input_tokens")
|
||||
if cache_read is not None and cache_read > 0:
|
||||
event_properties["$ai_cache_read_input_tokens"] = cache_read
|
||||
|
||||
cache_creation = usage.get("cache_creation_input_tokens")
|
||||
if cache_creation is not None and cache_creation > 0:
|
||||
event_properties["$ai_cache_creation_input_tokens"] = cache_creation
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Process instructions for Responses API
|
||||
if provider == "openai" and kwargs.get("instructions") is not None:
|
||||
event_properties["$ai_instructions"] = with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, kwargs.get("instructions")
|
||||
tag("$ai_provider", provider)
|
||||
tag("$ai_model", kwargs.get("model") or getattr(response, "model", None))
|
||||
tag("$ai_model_parameters", get_model_params(kwargs))
|
||||
tag(
|
||||
"$ai_input",
|
||||
with_privacy_mode(ph_client, posthog_privacy_mode, sanitized_messages),
|
||||
)
|
||||
|
||||
# send the event to posthog
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
tag(
|
||||
"$ai_output_choices",
|
||||
with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, format_response(response, provider)
|
||||
),
|
||||
)
|
||||
tag("$ai_http_status", http_status)
|
||||
tag("$ai_input_tokens", usage.get("input_tokens", 0))
|
||||
tag("$ai_output_tokens", usage.get("output_tokens", 0))
|
||||
tag("$ai_latency", latency)
|
||||
tag("$ai_trace_id", posthog_trace_id)
|
||||
tag("$ai_base_url", str(base_url))
|
||||
|
||||
if error:
|
||||
raise error
|
||||
available_tool_calls = extract_available_tool_calls(provider, kwargs)
|
||||
|
||||
if available_tool_calls:
|
||||
tag("$ai_tools", available_tool_calls)
|
||||
|
||||
cache_read = usage.get("cache_read_input_tokens")
|
||||
if cache_read is not None and cache_read > 0:
|
||||
tag("$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:
|
||||
tag("$ai_cache_creation_input_tokens", cache_creation)
|
||||
|
||||
reasoning = usage.get("reasoning_tokens")
|
||||
if reasoning is not None and reasoning > 0:
|
||||
tag("$ai_reasoning_tokens", reasoning)
|
||||
|
||||
web_search_count = usage.get("web_search_count")
|
||||
if web_search_count is not None and web_search_count > 0:
|
||||
tag("$ai_web_search_count", web_search_count)
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
tag("$process_person_profile", False)
|
||||
|
||||
# Process instructions for Responses API
|
||||
if provider == "openai" and kwargs.get("instructions") is not None:
|
||||
tag(
|
||||
"$ai_instructions",
|
||||
with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, kwargs.get("instructions")
|
||||
),
|
||||
)
|
||||
|
||||
# send the event to posthog
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties={
|
||||
**get_tags(),
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
},
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise error
|
||||
|
||||
return response
|
||||
|
||||
@@ -528,6 +585,15 @@ def capture_streaming_event(
|
||||
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"
|
||||
|
||||
+338
-90
@@ -2,43 +2,62 @@ import atexit
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from typing_extensions import Unpack
|
||||
from uuid import uuid4
|
||||
|
||||
from dateutil.tz import tzutc
|
||||
from six import string_types
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from posthog.args import OptionalCaptureArgs, OptionalSetArgs, ID_TYPES, ExceptionArg
|
||||
from posthog.args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
|
||||
from posthog.consumer import Consumer
|
||||
from posthog.contexts import (
|
||||
_get_current_context,
|
||||
get_capture_exception_code_variables_context,
|
||||
get_code_variables_ignore_patterns_context,
|
||||
get_code_variables_mask_patterns_context,
|
||||
get_context_distinct_id,
|
||||
get_context_session_id,
|
||||
new_context,
|
||||
)
|
||||
from posthog.exception_capture import ExceptionCapture
|
||||
from posthog.exception_utils import (
|
||||
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
|
||||
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
|
||||
exc_info_from_error,
|
||||
exception_is_already_captured,
|
||||
exceptions_from_error_tuple,
|
||||
handle_in_app,
|
||||
exception_is_already_captured,
|
||||
mark_exception_as_captured,
|
||||
try_attach_code_variables_to_frames,
|
||||
)
|
||||
from posthog.feature_flags import (
|
||||
InconclusiveMatchError,
|
||||
RequiresServerEvaluation,
|
||||
match_feature_flag_properties,
|
||||
)
|
||||
from posthog.flag_definition_cache import (
|
||||
FlagDefinitionCacheData,
|
||||
FlagDefinitionCacheProvider,
|
||||
)
|
||||
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
|
||||
from posthog.poller import Poller
|
||||
from posthog.request import (
|
||||
DEFAULT_HOST,
|
||||
APIError,
|
||||
QuotaLimitError,
|
||||
RequestsConnectionError,
|
||||
RequestsTimeout,
|
||||
batch_post,
|
||||
determine_server_host,
|
||||
flags,
|
||||
get,
|
||||
remote_config,
|
||||
)
|
||||
from posthog.contexts import (
|
||||
_get_current_context,
|
||||
get_context_distinct_id,
|
||||
get_context_session_id,
|
||||
new_context,
|
||||
)
|
||||
from posthog.types import (
|
||||
FeatureFlag,
|
||||
FeatureFlagError,
|
||||
FeatureFlagResult,
|
||||
FlagMetadata,
|
||||
FlagsAndPayloads,
|
||||
@@ -56,7 +75,6 @@ from posthog.utils import (
|
||||
SizeLimitedDict,
|
||||
clean,
|
||||
guess_timezone,
|
||||
remove_trailing_slash,
|
||||
system_context,
|
||||
)
|
||||
from posthog.version import VERSION
|
||||
@@ -99,6 +117,34 @@ def add_context_tags(properties):
|
||||
return properties
|
||||
|
||||
|
||||
def no_throw(default_return=None):
|
||||
"""
|
||||
Decorator to prevent raising exceptions from public API methods.
|
||||
Note that this doesn't prevent errors from propagating via `on_error`.
|
||||
Exceptions will still be raised if the debug flag is enabled.
|
||||
|
||||
Args:
|
||||
default_return: Value to return on exception (default: None)
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
from functools import wraps
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
try:
|
||||
return func(self, *args, **kwargs)
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
raise e
|
||||
self.log.exception(f"Error in {func.__name__}: {e}")
|
||||
return default_return
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class Client(object):
|
||||
"""
|
||||
This is the SDK reference for the PostHog Python SDK.
|
||||
@@ -147,6 +193,11 @@ class Client(object):
|
||||
before_send=None,
|
||||
flag_fallback_cache_url=None,
|
||||
enable_local_evaluation=True,
|
||||
flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None,
|
||||
capture_exception_code_variables=False,
|
||||
code_variables_mask_patterns=None,
|
||||
code_variables_ignore_patterns=None,
|
||||
in_app_modules: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize a new PostHog client instance.
|
||||
@@ -182,8 +233,8 @@ class Client(object):
|
||||
self.timeout = timeout
|
||||
self._feature_flags = None # private variable to store flags
|
||||
self.feature_flags_by_key = None
|
||||
self.group_type_mapping = None
|
||||
self.cohorts = None
|
||||
self.group_type_mapping: Optional[dict[str, str]] = None
|
||||
self.cohorts: Optional[dict[str, Any]] = None
|
||||
self.poll_interval = poll_interval
|
||||
self.feature_flags_request_timeout_seconds = (
|
||||
feature_flags_request_timeout_seconds
|
||||
@@ -192,6 +243,8 @@ class Client(object):
|
||||
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
|
||||
self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url)
|
||||
self.flag_definition_version = 0
|
||||
self._flags_etag: Optional[str] = None
|
||||
self._flag_definition_cache_provider = flag_definition_cache_provider
|
||||
self.disabled = disabled
|
||||
self.disable_geoip = disable_geoip
|
||||
self.historical_migration = historical_migration
|
||||
@@ -202,6 +255,19 @@ class Client(object):
|
||||
self.privacy_mode = privacy_mode
|
||||
self.enable_local_evaluation = enable_local_evaluation
|
||||
|
||||
self.capture_exception_code_variables = capture_exception_code_variables
|
||||
self.code_variables_mask_patterns = (
|
||||
code_variables_mask_patterns
|
||||
if code_variables_mask_patterns is not None
|
||||
else DEFAULT_CODE_VARIABLES_MASK_PATTERNS
|
||||
)
|
||||
self.code_variables_ignore_patterns = (
|
||||
code_variables_ignore_patterns
|
||||
if code_variables_ignore_patterns is not None
|
||||
else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS
|
||||
)
|
||||
self.in_app_modules = in_app_modules
|
||||
|
||||
if project_root is None:
|
||||
try:
|
||||
project_root = os.getcwd()
|
||||
@@ -243,8 +309,9 @@ class Client(object):
|
||||
# to call flush().
|
||||
if send:
|
||||
atexit.register(self.join)
|
||||
for n in range(thread):
|
||||
self.consumers = []
|
||||
|
||||
self.consumers = []
|
||||
for _ in range(thread):
|
||||
consumer = Consumer(
|
||||
self.queue,
|
||||
self.api_key,
|
||||
@@ -481,6 +548,7 @@ class Client(object):
|
||||
|
||||
return normalize_flags_response(resp_data)
|
||||
|
||||
@no_throw()
|
||||
def capture(
|
||||
self, event: str, **kwargs: Unpack[OptionalCaptureArgs]
|
||||
) -> Optional[str]:
|
||||
@@ -568,7 +636,28 @@ class Client(object):
|
||||
if flag_options["should_send"]:
|
||||
try:
|
||||
if flag_options["only_evaluate_locally"] is True:
|
||||
# Only use local evaluation
|
||||
# Local evaluation explicitly requested
|
||||
feature_variants = self.get_all_flags(
|
||||
distinct_id,
|
||||
groups=(groups or {}),
|
||||
person_properties=flag_options["person_properties"],
|
||||
group_properties=flag_options["group_properties"],
|
||||
disable_geoip=disable_geoip,
|
||||
only_evaluate_locally=True,
|
||||
flag_keys_to_evaluate=flag_options["flag_keys_filter"],
|
||||
)
|
||||
elif flag_options["only_evaluate_locally"] is False:
|
||||
# Remote evaluation explicitly requested
|
||||
feature_variants = self.get_feature_variants(
|
||||
distinct_id,
|
||||
groups,
|
||||
person_properties=flag_options["person_properties"],
|
||||
group_properties=flag_options["group_properties"],
|
||||
disable_geoip=disable_geoip,
|
||||
flag_keys_to_evaluate=flag_options["flag_keys_filter"],
|
||||
)
|
||||
elif self.feature_flags:
|
||||
# Local flags available, prefer local evaluation
|
||||
feature_variants = self.get_all_flags(
|
||||
distinct_id,
|
||||
groups=(groups or {}),
|
||||
@@ -579,7 +668,7 @@ class Client(object):
|
||||
flag_keys_to_evaluate=flag_options["flag_keys_filter"],
|
||||
)
|
||||
else:
|
||||
# Default behavior - use remote evaluation
|
||||
# Fall back to remote evaluation
|
||||
feature_variants = self.get_feature_variants(
|
||||
distinct_id,
|
||||
groups,
|
||||
@@ -593,15 +682,6 @@ class Client(object):
|
||||
f"[FEATURE FLAGS] Unable to get feature variants: {e}"
|
||||
)
|
||||
|
||||
elif self.feature_flags and event != "$feature_flag_called":
|
||||
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
|
||||
feature_variants = self.get_all_flags(
|
||||
distinct_id,
|
||||
groups=(groups or {}),
|
||||
disable_geoip=disable_geoip,
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
|
||||
for feature, variant in (feature_variants or {}).items():
|
||||
extra_properties[f"$feature/{feature}"] = variant
|
||||
|
||||
@@ -657,6 +737,7 @@ class Client(object):
|
||||
f"Expected bool or dict."
|
||||
)
|
||||
|
||||
@no_throw()
|
||||
def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
"""
|
||||
Set properties on a person profile.
|
||||
@@ -671,25 +752,13 @@ class Client(object):
|
||||
Examples:
|
||||
```python
|
||||
# Set with distinct id
|
||||
posthog.capture(
|
||||
'event_name',
|
||||
distinct_id='user-distinct-id',
|
||||
properties={
|
||||
'$set': {'name': 'Max Hedgehog'},
|
||||
'$set_once': {'initial_url': '/blog'}
|
||||
}
|
||||
)
|
||||
```
|
||||
```python
|
||||
# Set using context
|
||||
from posthog import new_context, identify_context
|
||||
with new_context():
|
||||
identify_context('user-distinct-id')
|
||||
posthog.capture('event_name')
|
||||
posthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})
|
||||
```
|
||||
|
||||
Category:
|
||||
Identification
|
||||
|
||||
Note: This method will not raise exceptions. Errors are logged.
|
||||
"""
|
||||
distinct_id = kwargs.get("distinct_id", None)
|
||||
properties = kwargs.get("properties", None)
|
||||
@@ -716,6 +785,7 @@ class Client(object):
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
@no_throw()
|
||||
def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
"""
|
||||
Set properties on a person profile only if they haven't been set before.
|
||||
@@ -734,6 +804,8 @@ class Client(object):
|
||||
|
||||
Category:
|
||||
Identification
|
||||
|
||||
Note: This method will not raise exceptions. Errors are logged.
|
||||
"""
|
||||
distinct_id = kwargs.get("distinct_id", None)
|
||||
properties = kwargs.get("properties", None)
|
||||
@@ -759,6 +831,7 @@ class Client(object):
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
@no_throw()
|
||||
def group_identify(
|
||||
self,
|
||||
group_type: str,
|
||||
@@ -791,6 +864,8 @@ class Client(object):
|
||||
|
||||
Category:
|
||||
Identification
|
||||
|
||||
Note: This method will not raise exceptions. Errors are logged.
|
||||
"""
|
||||
properties = properties or {}
|
||||
|
||||
@@ -815,6 +890,7 @@ class Client(object):
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
@no_throw()
|
||||
def alias(
|
||||
self,
|
||||
previous_id: str,
|
||||
@@ -840,6 +916,8 @@ class Client(object):
|
||||
|
||||
Category:
|
||||
Identification
|
||||
|
||||
Note: This method will not raise exceptions. Errors are logged.
|
||||
"""
|
||||
(distinct_id, personless) = get_identity_state(distinct_id)
|
||||
|
||||
@@ -922,20 +1000,44 @@ class Client(object):
|
||||
"values": all_exceptions_with_trace,
|
||||
},
|
||||
},
|
||||
in_app_include=self.in_app_modules,
|
||||
project_root=self.project_root,
|
||||
)
|
||||
all_exceptions_with_trace_and_in_app = event["exception"]["values"]
|
||||
|
||||
properties = {
|
||||
"$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"),
|
||||
"$exception_message": all_exceptions_with_trace_and_in_app[0].get(
|
||||
"value"
|
||||
),
|
||||
"$exception_list": all_exceptions_with_trace_and_in_app,
|
||||
"$exception_personURL": f"{remove_trailing_slash(self.raw_host)}/project/{self.api_key}/person/{distinct_id}",
|
||||
**properties,
|
||||
}
|
||||
|
||||
context_enabled = get_capture_exception_code_variables_context()
|
||||
context_mask = get_code_variables_mask_patterns_context()
|
||||
context_ignore = get_code_variables_ignore_patterns_context()
|
||||
|
||||
enabled = (
|
||||
context_enabled
|
||||
if context_enabled is not None
|
||||
else self.capture_exception_code_variables
|
||||
)
|
||||
mask_patterns = (
|
||||
context_mask
|
||||
if context_mask is not None
|
||||
else self.code_variables_mask_patterns
|
||||
)
|
||||
ignore_patterns = (
|
||||
context_ignore
|
||||
if context_ignore is not None
|
||||
else self.code_variables_ignore_patterns
|
||||
)
|
||||
|
||||
if enabled:
|
||||
try_attach_code_variables_to_frames(
|
||||
all_exceptions_with_trace_and_in_app,
|
||||
exc_info,
|
||||
mask_patterns=mask_patterns,
|
||||
ignore_patterns=ignore_patterns,
|
||||
)
|
||||
|
||||
if self.log_captured_exceptions:
|
||||
self.log.exception(exception, extra=kwargs)
|
||||
|
||||
@@ -1068,17 +1170,25 @@ class Client(object):
|
||||
posthog.join()
|
||||
```
|
||||
"""
|
||||
for consumer in self.consumers:
|
||||
consumer.pause()
|
||||
try:
|
||||
consumer.join()
|
||||
except RuntimeError:
|
||||
# consumer thread has not started
|
||||
pass
|
||||
if self.consumers:
|
||||
for consumer in self.consumers:
|
||||
consumer.pause()
|
||||
try:
|
||||
consumer.join()
|
||||
except RuntimeError:
|
||||
# consumer thread has not started
|
||||
pass
|
||||
|
||||
if self.poller:
|
||||
self.poller.stop()
|
||||
|
||||
# Shutdown the cache provider (release locks, cleanup)
|
||||
if self._flag_definition_cache_provider:
|
||||
try:
|
||||
self._flag_definition_cache_provider.shutdown()
|
||||
except Exception as e:
|
||||
self.log.error(f"[FEATURE FLAGS] Cache provider shutdown error: {e}")
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss.
|
||||
@@ -1094,7 +1204,71 @@ class Client(object):
|
||||
if self.exception_capture:
|
||||
self.exception_capture.close()
|
||||
|
||||
def _update_flag_state(
|
||||
self, data: FlagDefinitionCacheData, old_flags_by_key: Optional[dict] = None
|
||||
) -> None:
|
||||
"""Update internal flag state from cache data and invalidate evaluation cache if changed."""
|
||||
self.feature_flags = data["flags"]
|
||||
self.group_type_mapping = data["group_type_mapping"]
|
||||
self.cohorts = data["cohorts"]
|
||||
|
||||
# Invalidate evaluation cache if flag definitions changed
|
||||
if (
|
||||
self.flag_cache
|
||||
and old_flags_by_key is not None
|
||||
and old_flags_by_key != (self.feature_flags_by_key or {})
|
||||
):
|
||||
old_version = self.flag_definition_version
|
||||
self.flag_definition_version += 1
|
||||
self.flag_cache.invalidate_version(old_version)
|
||||
|
||||
def _load_feature_flags(self):
|
||||
should_fetch = True
|
||||
if self._flag_definition_cache_provider:
|
||||
try:
|
||||
should_fetch = (
|
||||
self._flag_definition_cache_provider.should_fetch_flag_definitions()
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error(
|
||||
f"[FEATURE FLAGS] Cache provider should_fetch error: {e}"
|
||||
)
|
||||
# Fail-safe: fetch from API if cache provider errors
|
||||
should_fetch = True
|
||||
|
||||
# If not fetching, try to get from cache
|
||||
if not should_fetch and self._flag_definition_cache_provider:
|
||||
try:
|
||||
cached_data = (
|
||||
self._flag_definition_cache_provider.get_flag_definitions()
|
||||
)
|
||||
if cached_data:
|
||||
self.log.debug(
|
||||
"[FEATURE FLAGS] Using cached flag definitions from external cache"
|
||||
)
|
||||
self._update_flag_state(
|
||||
cached_data, old_flags_by_key=self.feature_flags_by_key or {}
|
||||
)
|
||||
self._last_feature_flag_poll = datetime.now(tz=tzutc())
|
||||
return
|
||||
else:
|
||||
# Emergency fallback: if cache is empty and we have no flags, fetch anyway.
|
||||
# There's really no other way of recovering in this case.
|
||||
if not self.feature_flags:
|
||||
self.log.debug(
|
||||
"[FEATURE FLAGS] Cache empty and no flags loaded, falling back to API fetch"
|
||||
)
|
||||
should_fetch = True
|
||||
except Exception as e:
|
||||
self.log.error(f"[FEATURE FLAGS] Cache provider get error: {e}")
|
||||
# Fail-safe: fetch from API if cache provider errors
|
||||
should_fetch = True
|
||||
|
||||
if should_fetch:
|
||||
self._fetch_feature_flags_from_api()
|
||||
|
||||
def _fetch_feature_flags_from_api(self):
|
||||
"""Fetch feature flags from the PostHog API."""
|
||||
try:
|
||||
# Store old flags to detect changes
|
||||
old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
|
||||
@@ -1104,19 +1278,41 @@ class Client(object):
|
||||
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
|
||||
self.host,
|
||||
timeout=10,
|
||||
etag=self._flags_etag,
|
||||
)
|
||||
|
||||
self.feature_flags = response["flags"] or []
|
||||
self.group_type_mapping = response["group_type_mapping"] or {}
|
||||
self.cohorts = response["cohorts"] or {}
|
||||
# Update stored ETag (clear if server stops sending one)
|
||||
self._flags_etag = response.etag
|
||||
|
||||
# Check if flag definitions changed and update version
|
||||
if self.flag_cache and old_flags_by_key != (
|
||||
self.feature_flags_by_key or {}
|
||||
):
|
||||
old_version = self.flag_definition_version
|
||||
self.flag_definition_version += 1
|
||||
self.flag_cache.invalidate_version(old_version)
|
||||
# If 304 Not Modified, flags haven't changed - skip processing
|
||||
if response.not_modified:
|
||||
self.log.debug(
|
||||
"[FEATURE FLAGS] Flags not modified (304), using cached data"
|
||||
)
|
||||
self._last_feature_flag_poll = datetime.now(tz=tzutc())
|
||||
return
|
||||
|
||||
if response.data is None:
|
||||
self.log.error(
|
||||
"[FEATURE FLAGS] Unexpected empty response data in non-304 response"
|
||||
)
|
||||
return
|
||||
|
||||
self._update_flag_state(response.data, old_flags_by_key=old_flags_by_key)
|
||||
|
||||
# Store in external cache if provider is configured
|
||||
if self._flag_definition_cache_provider:
|
||||
try:
|
||||
self._flag_definition_cache_provider.on_flag_definitions_received(
|
||||
{
|
||||
"flags": self.feature_flags or [],
|
||||
"group_type_mapping": self.group_type_mapping or {},
|
||||
"cohorts": self.cohorts or {},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error(f"[FEATURE FLAGS] Cache provider store error: {e}")
|
||||
# Flags are already in memory, so continue normally
|
||||
|
||||
except APIError as e:
|
||||
if e.status == 401:
|
||||
@@ -1216,7 +1412,8 @@ class Client(object):
|
||||
flag_filters = feature_flag.get("filters") or {}
|
||||
aggregation_group_type_index = flag_filters.get("aggregation_group_type_index")
|
||||
if aggregation_group_type_index is not None:
|
||||
group_name = self.group_type_mapping.get(str(aggregation_group_type_index))
|
||||
group_type_mapping = self.group_type_mapping or {}
|
||||
group_name = group_type_mapping.get(str(aggregation_group_type_index))
|
||||
|
||||
if not group_name:
|
||||
self.log.warning(
|
||||
@@ -1308,6 +1505,19 @@ class Client(object):
|
||||
return None
|
||||
return bool(response)
|
||||
|
||||
def _get_stale_flag_fallback(
|
||||
self, distinct_id: ID_TYPES, key: str
|
||||
) -> Optional[FeatureFlagResult]:
|
||||
"""Returns a stale cached flag value if available, otherwise None."""
|
||||
if self.flag_cache:
|
||||
stale_result = self.flag_cache.get_stale_cached_flag(distinct_id, key)
|
||||
if stale_result:
|
||||
self.log.info(
|
||||
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
|
||||
)
|
||||
return stale_result
|
||||
return None
|
||||
|
||||
def _get_feature_flag_result(
|
||||
self,
|
||||
key: str,
|
||||
@@ -1340,6 +1550,8 @@ class Client(object):
|
||||
flag_result = None
|
||||
flag_details = None
|
||||
request_id = None
|
||||
evaluated_at = None
|
||||
feature_flag_error: Optional[str] = None
|
||||
|
||||
flag_value = self._locally_evaluate_flag(
|
||||
key, distinct_id, groups, person_properties, group_properties
|
||||
@@ -1364,14 +1576,24 @@ class Client(object):
|
||||
)
|
||||
elif not only_evaluate_locally:
|
||||
try:
|
||||
flag_details, request_id = self._get_feature_flag_details_from_server(
|
||||
key,
|
||||
distinct_id,
|
||||
groups,
|
||||
person_properties,
|
||||
group_properties,
|
||||
disable_geoip,
|
||||
flag_details, request_id, evaluated_at, errors_while_computing = (
|
||||
self._get_feature_flag_details_from_server(
|
||||
key,
|
||||
distinct_id,
|
||||
groups,
|
||||
person_properties,
|
||||
group_properties,
|
||||
disable_geoip,
|
||||
)
|
||||
)
|
||||
errors = []
|
||||
if errors_while_computing:
|
||||
errors.append(FeatureFlagError.ERRORS_WHILE_COMPUTING)
|
||||
if flag_details is None:
|
||||
errors.append(FeatureFlagError.FLAG_MISSING)
|
||||
if errors:
|
||||
feature_flag_error = ",".join(errors)
|
||||
|
||||
flag_result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value
|
||||
)
|
||||
@@ -1385,19 +1607,26 @@ class Client(object):
|
||||
self.log.debug(
|
||||
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
|
||||
)
|
||||
except QuotaLimitError as e:
|
||||
self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
|
||||
feature_flag_error = FeatureFlagError.QUOTA_LIMITED
|
||||
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
||||
except RequestsTimeout as e:
|
||||
self.log.warning(f"[FEATURE FLAGS] Request timed out: {e}")
|
||||
feature_flag_error = FeatureFlagError.TIMEOUT
|
||||
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
||||
except RequestsConnectionError as e:
|
||||
self.log.warning(f"[FEATURE FLAGS] Connection error: {e}")
|
||||
feature_flag_error = FeatureFlagError.CONNECTION_ERROR
|
||||
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
||||
except APIError as e:
|
||||
self.log.warning(f"[FEATURE FLAGS] API error: {e}")
|
||||
feature_flag_error = FeatureFlagError.api_error(e.status)
|
||||
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
|
||||
|
||||
# Fallback to cached value if remote evaluation fails
|
||||
if self.flag_cache:
|
||||
stale_result = self.flag_cache.get_stale_cached_flag(
|
||||
distinct_id, key
|
||||
)
|
||||
if stale_result:
|
||||
self.log.info(
|
||||
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
|
||||
)
|
||||
flag_result = stale_result
|
||||
feature_flag_error = FeatureFlagError.UNKNOWN_ERROR
|
||||
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
||||
|
||||
if send_feature_flag_events:
|
||||
self._capture_feature_flag_called(
|
||||
@@ -1409,7 +1638,9 @@ class Client(object):
|
||||
groups,
|
||||
disable_geoip,
|
||||
request_id,
|
||||
evaluated_at,
|
||||
flag_details,
|
||||
feature_flag_error,
|
||||
)
|
||||
|
||||
return flag_result
|
||||
@@ -1543,7 +1774,7 @@ class Client(object):
|
||||
self.log.debug(
|
||||
f"Successfully computed flag locally: {key} -> {response}"
|
||||
)
|
||||
except InconclusiveMatchError as e:
|
||||
except (RequiresServerEvaluation, InconclusiveMatchError) as e:
|
||||
self.log.debug(f"Failed to compute flag {key} locally: {e}")
|
||||
except Exception as e:
|
||||
self.log.exception(
|
||||
@@ -1561,7 +1792,7 @@ class Client(object):
|
||||
person_properties=None,
|
||||
group_properties=None,
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
send_feature_flag_events=False,
|
||||
disable_geoip=None,
|
||||
):
|
||||
"""
|
||||
@@ -1575,7 +1806,7 @@ class Client(object):
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
only_evaluate_locally: Whether to only evaluate locally.
|
||||
send_feature_flag_events: Whether to send feature flag events.
|
||||
send_feature_flag_events: Deprecated. Use get_feature_flag() instead if you need events.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Examples:
|
||||
@@ -1591,6 +1822,14 @@ class Client(object):
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
if send_feature_flag_events:
|
||||
warnings.warn(
|
||||
"send_feature_flag_events is deprecated in get_feature_flag_payload() and will be removed "
|
||||
"in a future version. Use get_feature_flag() if you want to send $feature_flag_called events.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
feature_flag_result = self._get_feature_flag_result(
|
||||
key,
|
||||
distinct_id,
|
||||
@@ -1612,9 +1851,10 @@ class Client(object):
|
||||
person_properties: dict[str, str],
|
||||
group_properties: dict[str, str],
|
||||
disable_geoip: Optional[bool],
|
||||
) -> tuple[Optional[FeatureFlag], Optional[str]]:
|
||||
) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]:
|
||||
"""
|
||||
Calls /flags and returns the flag details and request id
|
||||
Calls /flags and returns the flag details, request id, evaluated at timestamp,
|
||||
and whether there were errors while computing flags.
|
||||
"""
|
||||
resp_data = self.get_flags_decision(
|
||||
distinct_id,
|
||||
@@ -1625,9 +1865,11 @@ class Client(object):
|
||||
flag_keys_to_evaluate=[key],
|
||||
)
|
||||
request_id = resp_data.get("requestId")
|
||||
evaluated_at = resp_data.get("evaluatedAt")
|
||||
errors_while_computing = resp_data.get("errorsWhileComputingFlags", False)
|
||||
flags = resp_data.get("flags")
|
||||
flag_details = flags.get(key) if flags else None
|
||||
return flag_details, request_id
|
||||
return flag_details, request_id, evaluated_at, errors_while_computing
|
||||
|
||||
def _capture_feature_flag_called(
|
||||
self,
|
||||
@@ -1639,7 +1881,9 @@ class Client(object):
|
||||
groups: Dict[str, str],
|
||||
disable_geoip: Optional[bool],
|
||||
request_id: Optional[str],
|
||||
evaluated_at: Optional[int],
|
||||
flag_details: Optional[FeatureFlag],
|
||||
feature_flag_error: Optional[str] = None,
|
||||
):
|
||||
feature_flag_reported_key = (
|
||||
f"{key}_{'::null::' if response is None else str(response)}"
|
||||
@@ -1662,6 +1906,8 @@ class Client(object):
|
||||
|
||||
if request_id:
|
||||
properties["$feature_flag_request_id"] = request_id
|
||||
if evaluated_at:
|
||||
properties["$feature_flag_evaluated_at"] = evaluated_at
|
||||
if isinstance(flag_details, FeatureFlag):
|
||||
if flag_details.reason and flag_details.reason.description:
|
||||
properties["$feature_flag_reason"] = flag_details.reason.description
|
||||
@@ -1672,6 +1918,8 @@ class Client(object):
|
||||
)
|
||||
if flag_details.metadata.id:
|
||||
properties["$feature_flag_id"] = flag_details.metadata.id
|
||||
if feature_flag_error:
|
||||
properties["$feature_flag_error"] = feature_flag_error
|
||||
|
||||
self.capture(
|
||||
"$feature_flag_called",
|
||||
@@ -1937,9 +2185,9 @@ class Client(object):
|
||||
return None
|
||||
|
||||
try:
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
except ImportError:
|
||||
from urlparse import urlparse, parse_qs
|
||||
from urlparse import parse_qs, urlparse
|
||||
|
||||
try:
|
||||
parsed = urlparse(cache_url)
|
||||
|
||||
+86
-6
@@ -22,6 +22,9 @@ class ContextScope:
|
||||
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
|
||||
@@ -32,6 +35,15 @@ class ContextScope:
|
||||
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
|
||||
|
||||
@@ -50,14 +62,34 @@ class ContextScope:
|
||||
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
|
||||
# so collect parent tags first, then update with child tags.
|
||||
tags = self.parent.collect_tags()
|
||||
tags.update(self.tags)
|
||||
return tags
|
||||
return self.tags.copy()
|
||||
|
||||
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(
|
||||
@@ -243,6 +275,54 @@ def get_context_distinct_id() -> Optional[str]:
|
||||
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])
|
||||
|
||||
|
||||
|
||||
+250
-2
@@ -5,6 +5,7 @@
|
||||
# 💖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
|
||||
@@ -13,22 +14,23 @@ import types
|
||||
from datetime import datetime
|
||||
from types import FrameType, TracebackType # noqa: F401
|
||||
from typing import ( # noqa: F401
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Pattern,
|
||||
Set,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from posthog.args import ExcInfo, ExceptionArg # noqa: F401
|
||||
from posthog.args import ExceptionArg, ExcInfo # noqa: F401
|
||||
|
||||
try:
|
||||
# Python 3.11
|
||||
@@ -40,6 +42,46 @@ 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.*",
|
||||
r"(?i).*aws_access_key_id.*",
|
||||
r"(?i).*_pass",
|
||||
r"(?i)sk_.*",
|
||||
r"(?i).*jwt.*",
|
||||
]
|
||||
|
||||
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
|
||||
|
||||
CODE_VARIABLES_REDACTED_VALUE = "$$_posthog_redacted_based_on_masking_rules_$$"
|
||||
|
||||
DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 20 * 1024
|
||||
|
||||
|
||||
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(
|
||||
@@ -884,3 +926,209 @@ 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 Exception:
|
||||
pass
|
||||
return compiled
|
||||
|
||||
|
||||
def _pattern_matches(name, patterns):
|
||||
for pattern in patterns:
|
||||
if pattern.search(name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _mask_sensitive_data(value, compiled_mask):
|
||||
if not compiled_mask:
|
||||
return value
|
||||
|
||||
if isinstance(value, dict):
|
||||
result = {}
|
||||
for k, v in value.items():
|
||||
key_str = str(k) if not isinstance(k, str) else k
|
||||
if _pattern_matches(key_str, compiled_mask):
|
||||
result[k] = CODE_VARIABLES_REDACTED_VALUE
|
||||
else:
|
||||
result[k] = _mask_sensitive_data(v, compiled_mask)
|
||||
return result
|
||||
elif isinstance(value, (list, tuple)):
|
||||
masked_items = [_mask_sensitive_data(item, compiled_mask) for item in value]
|
||||
return type(value)(masked_items)
|
||||
elif isinstance(value, str):
|
||||
if _pattern_matches(value, compiled_mask):
|
||||
return CODE_VARIABLES_REDACTED_VALUE
|
||||
return value
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_variable_value(value, limiter, max_length=1024, compiled_mask=None):
|
||||
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):
|
||||
if compiled_mask and _pattern_matches(value, compiled_mask):
|
||||
result = CODE_VARIABLES_REDACTED_VALUE
|
||||
else:
|
||||
result = value
|
||||
else:
|
||||
masked_value = _mask_sensitive_data(value, compiled_mask)
|
||||
result = json.dumps(masked_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:
|
||||
result = repr(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, compiled_mask
|
||||
)
|
||||
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
|
||||
):
|
||||
try:
|
||||
attach_code_variables_to_frames(
|
||||
all_exceptions, exc_info, mask_patterns, ignore_patterns
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def 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
|
||||
|
||||
+26
-10
@@ -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
|
||||
@@ -220,14 +232,7 @@ def match_feature_flag_properties(
|
||||
) 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
|
||||
@@ -246,7 +251,12 @@ def match_feature_flag_properties(
|
||||
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:
|
||||
@@ -456,8 +466,8 @@ def match_cohort(
|
||||
# }
|
||||
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]
|
||||
@@ -510,6 +520,9 @@ def match_property_group(
|
||||
# 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
|
||||
@@ -559,6 +572,9 @@ def match_property_group(
|
||||
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
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Flag Definition Cache Provider interface for multi-worker environments.
|
||||
|
||||
EXPERIMENTAL: This API may change in future minor version bumps.
|
||||
|
||||
This module provides an interface for external caching of feature flag definitions,
|
||||
enabling multi-worker environments (Kubernetes, load-balanced servers, serverless
|
||||
functions) to share flag definitions and reduce API calls.
|
||||
|
||||
Usage:
|
||||
|
||||
from posthog import Posthog
|
||||
from posthog.flag_definition_cache import FlagDefinitionCacheProvider
|
||||
|
||||
cache = RedisFlagDefinitionCache(redis_client, "my-team")
|
||||
posthog = Posthog(
|
||||
"<project_api_key>",
|
||||
personal_api_key="<personal_api_key>",
|
||||
flag_definition_cache_provider=cache,
|
||||
)
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
from typing_extensions import Required, TypedDict
|
||||
|
||||
|
||||
class FlagDefinitionCacheData(TypedDict):
|
||||
"""
|
||||
Data structure for cached flag definitions.
|
||||
|
||||
Attributes:
|
||||
flags: List of feature flag definition dictionaries from the API.
|
||||
group_type_mapping: Mapping of group type indices to group names.
|
||||
cohorts: Dictionary of cohort definitions for local evaluation.
|
||||
"""
|
||||
|
||||
flags: Required[List[Dict[str, Any]]]
|
||||
group_type_mapping: Required[Dict[str, str]]
|
||||
cohorts: Required[Dict[str, Any]]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FlagDefinitionCacheProvider(Protocol):
|
||||
"""
|
||||
Interface for external caching of feature flag definitions.
|
||||
|
||||
Enables multi-worker environments to share flag definitions, reducing API
|
||||
calls while ensuring all workers have consistent data.
|
||||
|
||||
EXPERIMENTAL: This API may change in future minor version bumps.
|
||||
|
||||
The four methods handle the complete lifecycle of flag definition caching:
|
||||
|
||||
1. `should_fetch_flag_definitions()` - Called before each poll to determine
|
||||
if this worker should fetch new definitions. Use for distributed lock
|
||||
coordination to ensure only one worker fetches at a time.
|
||||
|
||||
2. `get_flag_definitions()` - Called when `should_fetch_flag_definitions()`
|
||||
returns False. Returns cached definitions if available.
|
||||
|
||||
3. `on_flag_definitions_received()` - Called after successfully fetching
|
||||
new definitions from the API. Store the data in your external cache
|
||||
and release any locks.
|
||||
|
||||
4. `shutdown()` - Called when the PostHog client shuts down. Release any
|
||||
distributed locks and clean up resources.
|
||||
|
||||
Error Handling:
|
||||
All methods are wrapped in try/except. Errors will be logged but will
|
||||
never break flag evaluation. On error:
|
||||
- `should_fetch_flag_definitions()` errors default to fetching (fail-safe)
|
||||
- `get_flag_definitions()` errors fall back to API fetch
|
||||
- `on_flag_definitions_received()` errors are logged but flags remain in memory
|
||||
- `shutdown()` errors are logged but shutdown continues
|
||||
"""
|
||||
|
||||
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
|
||||
"""
|
||||
Retrieve cached flag definitions.
|
||||
|
||||
Returns:
|
||||
Cached flag definitions if available and valid, None otherwise.
|
||||
Returning None will trigger a fetch from the API if this worker
|
||||
has no flags loaded yet.
|
||||
"""
|
||||
...
|
||||
|
||||
def should_fetch_flag_definitions(self) -> bool:
|
||||
"""
|
||||
Determine whether this instance should fetch new flag definitions.
|
||||
|
||||
Use this for distributed lock coordination. Only one worker should
|
||||
return True to avoid thundering herd problems. A typical implementation
|
||||
uses a distributed lock (e.g., Redis SETNX) that expires after the
|
||||
poll interval.
|
||||
|
||||
Returns:
|
||||
True if this instance should fetch from the API, False otherwise.
|
||||
When False, the client will call `get_flag_definitions()` to
|
||||
retrieve cached data instead.
|
||||
"""
|
||||
...
|
||||
|
||||
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
|
||||
"""
|
||||
Called after successfully receiving new flag definitions from PostHog.
|
||||
|
||||
Use this to store the data in your external cache and release any
|
||||
distributed locks acquired in `should_fetch_flag_definitions()`.
|
||||
|
||||
Args:
|
||||
data: The flag definitions to cache, containing flags,
|
||||
group_type_mapping, and cohorts.
|
||||
"""
|
||||
...
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""
|
||||
Called when the PostHog client shuts down.
|
||||
|
||||
Use this to release any distributed locks and clean up resources.
|
||||
This method is called even if `should_fetch_flag_definitions()`
|
||||
returned False, so implementations should handle the case where
|
||||
no lock was acquired.
|
||||
"""
|
||||
...
|
||||
+157
-19
@@ -1,10 +1,24 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from posthog import contexts, capture_exception
|
||||
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 # noqa: F401
|
||||
from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
|
||||
|
||||
|
||||
class PosthogContextMiddleware:
|
||||
@@ -31,11 +45,24 @@ class PosthogContextMiddleware:
|
||||
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: (Callable[[HttpRequest], HttpResponse]) -> None
|
||||
# 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
|
||||
|
||||
@@ -85,9 +112,18 @@ class PosthogContextMiddleware:
|
||||
|
||||
def extract_tags(self, request):
|
||||
# type: (HttpRequest) -> Dict[str, Any]
|
||||
tags = {}
|
||||
"""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)
|
||||
|
||||
(user_id, user_email) = self.extract_request_user(request)
|
||||
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")
|
||||
@@ -139,43 +175,145 @@ class PosthogContextMiddleware:
|
||||
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
|
||||
|
||||
user = getattr(request, "user", None)
|
||||
if user is None:
|
||||
return user_id, email
|
||||
|
||||
if user and getattr(user, "is_authenticated", False):
|
||||
try:
|
||||
user_id = str(user.pk)
|
||||
except Exception:
|
||||
pass
|
||||
# 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()
|
||||
|
||||
try:
|
||||
email = str(user.email)
|
||||
except Exception:
|
||||
pass
|
||||
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) -> HttpResponse
|
||||
# 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 self.get_response(request)
|
||||
return await self.get_response(request)
|
||||
|
||||
with contexts.new_context(self.capture_exceptions, client=self.client):
|
||||
for k, v in self.extract_tags(request).items():
|
||||
for k, v in (await self.aextract_tags(request)).items():
|
||||
contexts.tag(k, v)
|
||||
|
||||
return self.get_response(request)
|
||||
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)
|
||||
|
||||
+203
-23
@@ -1,28 +1,163 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from gzip import GzipFile
|
||||
from io import BytesIO
|
||||
from typing import Any, Optional, Union
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
from dateutil.tz import tzutc
|
||||
from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
|
||||
from urllib3.connection import HTTPConnection
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from posthog.utils import remove_trailing_slash
|
||||
from posthog.version import VERSION
|
||||
|
||||
# Retry on both connect and read errors
|
||||
# by default read errors will only retry idempotent HTTP methods (so not POST)
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
SocketOptions = List[Tuple[int, int, Union[int, bytes]]]
|
||||
|
||||
KEEPALIVE_IDLE_SECONDS = 60
|
||||
KEEPALIVE_INTERVAL_SECONDS = 60
|
||||
KEEPALIVE_PROBE_COUNT = 3
|
||||
|
||||
# TCP keepalive probes idle connections to prevent them from being dropped.
|
||||
# SO_KEEPALIVE is cross-platform, but timing options vary:
|
||||
# - Linux: TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT
|
||||
# - macOS: only SO_KEEPALIVE (uses system defaults)
|
||||
# - Windows: TCP_KEEPIDLE, TCP_KEEPINTVL (since Windows 10 1709)
|
||||
KEEP_ALIVE_SOCKET_OPTIONS: SocketOptions = list(
|
||||
HTTPConnection.default_socket_options
|
||||
) + [
|
||||
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
|
||||
]
|
||||
for attr, value in [
|
||||
("TCP_KEEPIDLE", KEEPALIVE_IDLE_SECONDS),
|
||||
("TCP_KEEPINTVL", KEEPALIVE_INTERVAL_SECONDS),
|
||||
("TCP_KEEPCNT", KEEPALIVE_PROBE_COUNT),
|
||||
]:
|
||||
if hasattr(socket, attr):
|
||||
KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value))
|
||||
|
||||
# Status codes that indicate transient server errors worth retrying
|
||||
RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504]
|
||||
|
||||
|
||||
def _mask_tokens_in_url(url: str) -> str:
|
||||
"""Mask token values in URLs for safe logging, keeping first 10 chars visible."""
|
||||
return re.sub(r"(token=)([^&]{10})[^&]*", r"\1\2...", url)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetResponse:
|
||||
"""Response from a GET request with ETag support."""
|
||||
|
||||
data: Any
|
||||
etag: Optional[str] = None
|
||||
not_modified: bool = False
|
||||
|
||||
|
||||
class HTTPAdapterWithSocketOptions(HTTPAdapter):
|
||||
"""HTTPAdapter with configurable socket options."""
|
||||
|
||||
def __init__(self, *args, socket_options: Optional[SocketOptions] = None, **kwargs):
|
||||
self.socket_options = socket_options
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def init_poolmanager(self, *args, **kwargs):
|
||||
if self.socket_options is not None:
|
||||
kwargs["socket_options"] = self.socket_options
|
||||
super().init_poolmanager(*args, **kwargs)
|
||||
|
||||
|
||||
def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session:
|
||||
"""Build a session for general requests (batch, decide, etc.)."""
|
||||
adapter = HTTPAdapterWithSocketOptions(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
),
|
||||
socket_options=socket_options,
|
||||
)
|
||||
)
|
||||
_session = requests.sessions.Session()
|
||||
_session.mount("https://", adapter)
|
||||
session = requests.Session()
|
||||
session.mount("https://", adapter)
|
||||
return session
|
||||
|
||||
|
||||
def _build_flags_session(
|
||||
socket_options: Optional[SocketOptions] = None,
|
||||
) -> requests.Session:
|
||||
"""
|
||||
Build a session for feature flag requests with POST retries.
|
||||
|
||||
Feature flag requests are idempotent (read-only), so retrying POST
|
||||
requests is safe. This session retries on transient server errors
|
||||
(408, 5xx) and network failures with exponential backoff
|
||||
(0.5s, 1s delays between retries).
|
||||
"""
|
||||
adapter = HTTPAdapterWithSocketOptions(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
backoff_factor=0.5,
|
||||
status_forcelist=RETRY_STATUS_FORCELIST,
|
||||
allowed_methods=["POST"],
|
||||
),
|
||||
socket_options=socket_options,
|
||||
)
|
||||
session = requests.Session()
|
||||
session.mount("https://", adapter)
|
||||
return session
|
||||
|
||||
|
||||
_session = _build_session()
|
||||
_flags_session = _build_flags_session()
|
||||
_socket_options: Optional[SocketOptions] = None
|
||||
_pooling_enabled = True
|
||||
|
||||
|
||||
def _get_session() -> requests.Session:
|
||||
if _pooling_enabled:
|
||||
return _session
|
||||
return _build_session(_socket_options)
|
||||
|
||||
|
||||
def _get_flags_session() -> requests.Session:
|
||||
if _pooling_enabled:
|
||||
return _flags_session
|
||||
return _build_flags_session(_socket_options)
|
||||
|
||||
|
||||
def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
|
||||
"""
|
||||
Configure socket options for all HTTP connections.
|
||||
|
||||
Example:
|
||||
from posthog import set_socket_options
|
||||
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
|
||||
"""
|
||||
global _session, _flags_session, _socket_options
|
||||
if socket_options == _socket_options:
|
||||
return
|
||||
_socket_options = socket_options
|
||||
_session = _build_session(socket_options)
|
||||
_flags_session = _build_flags_session(socket_options)
|
||||
|
||||
|
||||
def enable_keep_alive() -> None:
|
||||
"""Enable TCP keepalive to prevent idle connections from being dropped."""
|
||||
set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS)
|
||||
|
||||
|
||||
def disable_connection_reuse() -> None:
|
||||
"""Disable connection reuse, creating a fresh connection for each request."""
|
||||
global _pooling_enabled
|
||||
_pooling_enabled = False
|
||||
|
||||
|
||||
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
|
||||
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
|
||||
@@ -48,6 +183,7 @@ def post(
|
||||
path=None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
session: Optional[requests.Session] = None,
|
||||
**kwargs,
|
||||
) -> requests.Response:
|
||||
"""Post the `kwargs` to the API"""
|
||||
@@ -68,7 +204,9 @@ def post(
|
||||
gz.write(data.encode("utf-8"))
|
||||
data = buf.getvalue()
|
||||
|
||||
res = _session.post(url, data=data, headers=headers, timeout=timeout)
|
||||
res = (session or _get_session()).post(
|
||||
url, data=data, headers=headers, timeout=timeout
|
||||
)
|
||||
|
||||
if res.status_code == 200:
|
||||
log.debug("data uploaded successfully")
|
||||
@@ -124,8 +262,16 @@ def flags(
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""Post the `kwargs to the flags API endpoint"""
|
||||
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
|
||||
"""Post the kwargs to the flags API endpoint with automatic retries."""
|
||||
res = post(
|
||||
api_key,
|
||||
host,
|
||||
"/flags/?v=2",
|
||||
gzip,
|
||||
timeout,
|
||||
session=_get_flags_session(),
|
||||
**kwargs,
|
||||
)
|
||||
return _process_response(
|
||||
res, success_message="Feature flags evaluated successfully"
|
||||
)
|
||||
@@ -139,12 +285,13 @@ def remote_config(
|
||||
timeout: int = 15,
|
||||
) -> Any:
|
||||
"""Get remote config flag value from remote_config API endpoint"""
|
||||
return get(
|
||||
response = get(
|
||||
personal_api_key,
|
||||
f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
|
||||
host,
|
||||
timeout,
|
||||
)
|
||||
return response.data
|
||||
|
||||
|
||||
def batch_post(
|
||||
@@ -162,15 +309,42 @@ def batch_post(
|
||||
|
||||
|
||||
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,
|
||||
api_key: str,
|
||||
url: str,
|
||||
host: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
etag: Optional[str] = None,
|
||||
) -> GetResponse:
|
||||
"""
|
||||
Make a GET request with optional ETag support.
|
||||
|
||||
If an etag is provided, sends If-None-Match header. Returns GetResponse with:
|
||||
- not_modified=True and data=None if server returns 304
|
||||
- not_modified=False and data=response if server returns 200
|
||||
"""
|
||||
log = logging.getLogger("posthog")
|
||||
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
|
||||
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
|
||||
|
||||
if etag:
|
||||
headers["If-None-Match"] = etag
|
||||
|
||||
res = _get_session().get(full_url, headers=headers, timeout=timeout)
|
||||
|
||||
masked_url = _mask_tokens_in_url(full_url)
|
||||
|
||||
# Handle 304 Not Modified
|
||||
if res.status_code == 304:
|
||||
log.debug(f"GET {masked_url} returned 304 Not Modified")
|
||||
response_etag = res.headers.get("ETag")
|
||||
return GetResponse(data=None, etag=response_etag or etag, not_modified=True)
|
||||
|
||||
# Handle normal response
|
||||
data = _process_response(
|
||||
res, success_message=f"GET {masked_url} completed successfully"
|
||||
)
|
||||
return _process_response(res, success_message=f"GET {url} completed successfully")
|
||||
response_etag = res.headers.get("ETag")
|
||||
return GetResponse(data=data, etag=response_etag, not_modified=False)
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
@@ -187,6 +361,12 @@ class QuotaLimitError(APIError):
|
||||
pass
|
||||
|
||||
|
||||
# Re-export requests exceptions for use in client.py
|
||||
# This keeps all requests library imports centralized in this module
|
||||
RequestsTimeout = requests.exceptions.Timeout
|
||||
RequestsConnectionError = requests.exceptions.ConnectionError
|
||||
|
||||
|
||||
class DatetimeSerializer(json.JSONEncoder):
|
||||
def default(self, obj: Any):
|
||||
if isinstance(obj, (date, datetime)):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -1034,3 +1033,233 @@ def test_async_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with
|
||||
assert props["$ai_output_tokens"] == 25
|
||||
assert props["$ai_cache_read_input_tokens"] == 5
|
||||
assert props["$ai_cache_creation_input_tokens"] == 0
|
||||
|
||||
|
||||
def test_web_search_count(mock_client):
|
||||
"""Test that web search count is properly tracked from Anthropic responses."""
|
||||
|
||||
# Create a mock usage with web search
|
||||
class MockServerToolUse:
|
||||
def __init__(self):
|
||||
self.web_search_requests = 3
|
||||
|
||||
class MockUsageWithWebSearch:
|
||||
def __init__(self):
|
||||
self.input_tokens = 100
|
||||
self.output_tokens = 50
|
||||
self.cache_read_input_tokens = 0
|
||||
self.cache_creation_input_tokens = 0
|
||||
self.server_tool_use = MockServerToolUse()
|
||||
|
||||
class MockResponseWithWebSearch:
|
||||
def __init__(self):
|
||||
self.content = [MockContent(text="Search results show...")]
|
||||
self.model = "claude-3-opus-20240229"
|
||||
self.usage = MockUsageWithWebSearch()
|
||||
|
||||
mock_response = MockResponseWithWebSearch()
|
||||
|
||||
with patch("anthropic.resources.Messages.create", return_value=mock_response):
|
||||
client = Anthropic(api_key="test-key", posthog_client=mock_client)
|
||||
response = client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
messages=[{"role": "user", "content": "Search for recent 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 captured
|
||||
assert props["$ai_web_search_count"] == 3
|
||||
assert props["$ai_input_tokens"] == 100
|
||||
assert props["$ai_output_tokens"] == 50
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic_stream_with_web_search():
|
||||
"""Mock stream events for web search."""
|
||||
|
||||
class MockServerToolUse:
|
||||
def __init__(self):
|
||||
self.web_search_requests = 2
|
||||
|
||||
class MockMessage:
|
||||
def __init__(self):
|
||||
self.usage = MockUsage(
|
||||
input_tokens=50,
|
||||
cache_creation_input_tokens=0,
|
||||
cache_read_input_tokens=5,
|
||||
)
|
||||
|
||||
def stream_generator():
|
||||
# Message start with usage
|
||||
event = MockStreamEvent("message_start")
|
||||
event.message = MockMessage()
|
||||
yield event
|
||||
|
||||
# Text block start
|
||||
event = MockStreamEvent("content_block_start")
|
||||
event.content_block = MockContentBlock("text")
|
||||
event.index = 0
|
||||
yield event
|
||||
|
||||
# Text delta
|
||||
event = MockStreamEvent("content_block_delta")
|
||||
event.delta = MockDelta(text="Here are the search results...")
|
||||
event.index = 0
|
||||
yield event
|
||||
|
||||
# Text block stop
|
||||
event = MockStreamEvent("content_block_stop")
|
||||
event.index = 0
|
||||
yield event
|
||||
|
||||
# Message delta with final usage including web search
|
||||
event = MockStreamEvent("message_delta")
|
||||
usage = MockUsage(output_tokens=25)
|
||||
usage.server_tool_use = MockServerToolUse()
|
||||
event.usage = usage
|
||||
yield event
|
||||
|
||||
# Message stop
|
||||
event = MockStreamEvent("message_stop")
|
||||
yield event
|
||||
|
||||
return stream_generator()
|
||||
|
||||
|
||||
def test_streaming_with_web_search(mock_client, mock_anthropic_stream_with_web_search):
|
||||
"""Test that web search count is properly captured in streaming mode."""
|
||||
with patch(
|
||||
"anthropic.resources.Messages.create",
|
||||
return_value=mock_anthropic_stream_with_web_search,
|
||||
):
|
||||
client = Anthropic(api_key="test-key", posthog_client=mock_client)
|
||||
response = client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
messages=[{"role": "user", "content": "Search for recent news"}],
|
||||
stream=True,
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
# Consume the stream - this triggers the finally block synchronously
|
||||
list(response)
|
||||
|
||||
# Capture happens synchronously when generator is exhausted
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Verify web search count is captured
|
||||
assert props["$ai_web_search_count"] == 2
|
||||
assert props["$ai_input_tokens"] == 50
|
||||
assert props["$ai_output_tokens"] == 25
|
||||
|
||||
|
||||
def test_async_with_web_search(mock_client):
|
||||
"""Test that web search count is properly tracked in async non-streaming mode."""
|
||||
import asyncio
|
||||
|
||||
# Create a mock usage with web search
|
||||
class MockServerToolUse:
|
||||
def __init__(self):
|
||||
self.web_search_requests = 3
|
||||
|
||||
class MockUsageWithWebSearch:
|
||||
def __init__(self):
|
||||
self.input_tokens = 100
|
||||
self.output_tokens = 50
|
||||
self.cache_read_input_tokens = 0
|
||||
self.cache_creation_input_tokens = 0
|
||||
self.server_tool_use = MockServerToolUse()
|
||||
|
||||
class MockResponseWithWebSearch:
|
||||
def __init__(self):
|
||||
self.content = [MockContent(text="Search results show...")]
|
||||
self.model = "claude-3-opus-20240229"
|
||||
self.usage = MockUsageWithWebSearch()
|
||||
|
||||
mock_response = MockResponseWithWebSearch()
|
||||
|
||||
async def mock_async_create(**kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"anthropic.resources.AsyncMessages.create",
|
||||
side_effect=mock_async_create,
|
||||
):
|
||||
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
async def run_test():
|
||||
response = await async_client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
messages=[{"role": "user", "content": "Search for recent news"}],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
return response
|
||||
|
||||
# asyncio.run() waits for all async operations to complete
|
||||
response = asyncio.run(run_test())
|
||||
|
||||
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 captured
|
||||
assert props["$ai_web_search_count"] == 3
|
||||
assert props["$ai_input_tokens"] == 100
|
||||
assert props["$ai_output_tokens"] == 50
|
||||
|
||||
|
||||
def test_async_streaming_with_web_search(
|
||||
mock_client, mock_anthropic_stream_with_web_search
|
||||
):
|
||||
"""Test that web search count is properly captured in async streaming mode."""
|
||||
import asyncio
|
||||
|
||||
async def mock_async_generator():
|
||||
# Convert regular generator to async generator
|
||||
for event in mock_anthropic_stream_with_web_search:
|
||||
yield event
|
||||
|
||||
async def mock_async_create(**kwargs):
|
||||
# Return the async generator (to be awaited by the implementation)
|
||||
return mock_async_generator()
|
||||
|
||||
with patch(
|
||||
"anthropic.resources.AsyncMessages.create",
|
||||
side_effect=mock_async_create,
|
||||
):
|
||||
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
async def run_test():
|
||||
response = await async_client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
messages=[{"role": "user", "content": "Search for recent news"}],
|
||||
stream=True,
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
# Consume the async stream
|
||||
[event async for event in response]
|
||||
|
||||
# asyncio.run() waits for all async operations to complete
|
||||
asyncio.run(run_test())
|
||||
|
||||
# Capture completes before asyncio.run() returns
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Verify web search count is captured
|
||||
assert props["$ai_web_search_count"] == 2
|
||||
assert props["$ai_input_tokens"] == 50
|
||||
assert props["$ai_output_tokens"] == 25
|
||||
|
||||
@@ -407,7 +407,9 @@ def test_new_client_different_input_formats(
|
||||
)
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
|
||||
assert props["$ai_input"] == [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hey"}]}
|
||||
]
|
||||
|
||||
# Test multiple parts in the parts array
|
||||
mock_client.reset_mock()
|
||||
@@ -418,7 +420,15 @@ def test_new_client_different_input_formats(
|
||||
)
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "Hello world"}]
|
||||
assert props["$ai_input"] == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello "},
|
||||
{"type": "text", "text": "world"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Test list input with string
|
||||
mock_client.capture.reset_mock()
|
||||
@@ -616,6 +626,8 @@ def test_tool_use_response(mock_client, mock_google_genai_client, mock_gemini_re
|
||||
|
||||
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",
|
||||
@@ -835,3 +847,262 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
|
||||
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
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from google import genai as google_genai
|
||||
|
||||
from posthog.ai.gemini import AsyncClient
|
||||
|
||||
GEMINI_AVAILABLE = True
|
||||
except ImportError:
|
||||
GEMINI_AVAILABLE = False
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(
|
||||
not GEMINI_AVAILABLE, reason="Google Gemini package is not available"
|
||||
),
|
||||
pytest.mark.asyncio,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
with patch("posthog.client.Client") as mock_client:
|
||||
mock_client.privacy_mode = False
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gemini_response():
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response from Gemini"
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 20
|
||||
mock_usage.candidates_token_count = 10
|
||||
# Ensure cache and reasoning tokens are not present (not MagicMock)
|
||||
mock_usage.cached_content_token_count = 0
|
||||
mock_usage.thoughts_token_count = 0
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.text = "Test response from Gemini"
|
||||
mock_content = MagicMock()
|
||||
mock_part = MagicMock()
|
||||
mock_part.text = "Test response from Gemini"
|
||||
mock_content.parts = [mock_part]
|
||||
mock_candidate.content = mock_content
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
return mock_response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_google_genai_client():
|
||||
"""Mock for the google-genai Client with async support"""
|
||||
with patch.object(google_genai, "Client") as mock_client_class:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_models = MagicMock()
|
||||
mock_aio = MagicMock()
|
||||
mock_aio_models = MagicMock()
|
||||
|
||||
mock_client_instance.models = mock_models
|
||||
mock_client_instance.aio = mock_aio
|
||||
mock_aio.models = mock_aio_models
|
||||
|
||||
mock_client_class.return_value = mock_client_instance
|
||||
yield mock_client_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gemini_response_with_function_calls():
|
||||
mock_response = MagicMock()
|
||||
|
||||
# Mock usage metadata
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 25
|
||||
mock_usage.candidates_token_count = 15
|
||||
mock_usage.cached_content_token_count = 0
|
||||
mock_usage.thoughts_token_count = 0
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
# Mock function call
|
||||
mock_function_call = MagicMock()
|
||||
mock_function_call.name = "get_current_weather"
|
||||
mock_function_call.args = {"location": "San Francisco"}
|
||||
|
||||
# Mock text part 1
|
||||
mock_text_part1 = MagicMock()
|
||||
mock_text_part1.text = "I'll check the weather for you."
|
||||
type(mock_text_part1).text = mock_text_part1.text
|
||||
|
||||
# Mock text part 2
|
||||
mock_text_part2 = MagicMock()
|
||||
mock_text_part2.text = " Let me look that up."
|
||||
type(mock_text_part2).text = mock_text_part2.text
|
||||
|
||||
# Mock function call part
|
||||
mock_function_part = MagicMock()
|
||||
mock_function_part.function_call = mock_function_call
|
||||
type(mock_function_part).function_call = mock_function_part.function_call
|
||||
del mock_function_part.text
|
||||
|
||||
# Mock content with 2 text parts and 1 function call part
|
||||
mock_content = MagicMock()
|
||||
mock_content.parts = [mock_text_part1, mock_text_part2, mock_function_part]
|
||||
|
||||
# Mock candidate
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.content = mock_content
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
return mock_response
|
||||
|
||||
|
||||
async def test_async_client_basic_generation(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test the async Client/AsyncModels API structure"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Tell me a fun fact about hedgehogs"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_gemini_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.0-flash"
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["foo"] == "bar"
|
||||
assert "$ai_trace_id" in props
|
||||
assert props["$ai_latency"] > 0
|
||||
|
||||
|
||||
async def test_async_client_streaming_with_generate_content_stream(
|
||||
mock_client, mock_google_genai_client
|
||||
):
|
||||
"""Test the async generate_content_stream method"""
|
||||
|
||||
async def mock_streaming_response():
|
||||
mock_chunk1 = MagicMock()
|
||||
mock_chunk1.text = "Hello "
|
||||
mock_usage1 = MagicMock()
|
||||
mock_usage1.prompt_token_count = 10
|
||||
mock_usage1.candidates_token_count = 5
|
||||
mock_usage1.cached_content_token_count = 0
|
||||
mock_usage1.thoughts_token_count = 0
|
||||
mock_chunk1.usage_metadata = mock_usage1
|
||||
yield mock_chunk1
|
||||
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.text = "world!"
|
||||
mock_usage2 = MagicMock()
|
||||
mock_usage2.prompt_token_count = 10
|
||||
mock_usage2.candidates_token_count = 10
|
||||
mock_usage2.cached_content_token_count = 0
|
||||
mock_usage2.thoughts_token_count = 0
|
||||
mock_chunk2.usage_metadata = mock_usage2
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the async generate_content_stream method
|
||||
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
|
||||
return_value=mock_streaming_response()
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content_stream(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Write a short story"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"feature": "streaming"},
|
||||
)
|
||||
|
||||
chunks = []
|
||||
async for chunk in response:
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].text == "Hello "
|
||||
assert chunks[1].text == "world!"
|
||||
|
||||
# Check that the streaming event was captured
|
||||
assert mock_client.capture.call_count == 1
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.0-flash"
|
||||
assert props["$ai_input_tokens"] == 10
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["feature"] == "streaming"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
async def test_async_client_streaming_with_tools(mock_client, mock_google_genai_client):
|
||||
"""Test that tools are captured in async streaming mode"""
|
||||
|
||||
async def mock_streaming_response():
|
||||
mock_chunk1 = MagicMock()
|
||||
mock_chunk1.text = "I'll check "
|
||||
mock_usage1 = MagicMock()
|
||||
mock_usage1.prompt_token_count = 15
|
||||
mock_usage1.candidates_token_count = 5
|
||||
mock_usage1.cached_content_token_count = 0
|
||||
mock_usage1.thoughts_token_count = 0
|
||||
mock_chunk1.usage_metadata = mock_usage1
|
||||
yield mock_chunk1
|
||||
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.text = "the weather"
|
||||
mock_usage2 = MagicMock()
|
||||
mock_usage2.prompt_token_count = 15
|
||||
mock_usage2.candidates_token_count = 10
|
||||
mock_usage2.cached_content_token_count = 0
|
||||
mock_usage2.thoughts_token_count = 0
|
||||
mock_chunk2.usage_metadata = mock_usage2
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the async generate_content_stream method
|
||||
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
|
||||
return_value=mock_streaming_response()
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
# Create mock tools configuration
|
||||
mock_tool = MagicMock()
|
||||
mock_tool.function_declarations = [
|
||||
MagicMock(
|
||||
name="get_current_weather",
|
||||
description="Gets the current weather for a given location.",
|
||||
parameters=MagicMock(
|
||||
type="OBJECT",
|
||||
properties={
|
||||
"location": MagicMock(
|
||||
type="STRING",
|
||||
description="The city and state, e.g. San Francisco, CA",
|
||||
)
|
||||
},
|
||||
required=["location"],
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.tools = [mock_tool]
|
||||
|
||||
response = await client.models.generate_content_stream(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["What's the weather in SF?"],
|
||||
config=mock_config,
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"feature": "streaming_with_tools"},
|
||||
)
|
||||
|
||||
chunks = []
|
||||
async for chunk in response:
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].text == "I'll check "
|
||||
assert chunks[1].text == "the weather"
|
||||
|
||||
# Check that the streaming event was captured with tools
|
||||
assert mock_client.capture.call_count == 1
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.0-flash"
|
||||
assert props["$ai_input_tokens"] == 15
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["feature"] == "streaming_with_tools"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
# Verify that tools are captured in the $ai_tools property in streaming mode
|
||||
assert props["$ai_tools"] == [mock_tool]
|
||||
|
||||
|
||||
async def test_async_client_groups(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test groups functionality with async Client API"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_groups={"company": "company_123"},
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
assert call_args["groups"] == {"company": "company_123"}
|
||||
|
||||
|
||||
async def test_async_client_privacy_mode_local(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test local privacy mode with async Client API"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_privacy_mode=True,
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] is None
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
async def test_async_client_privacy_mode_global(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test global privacy mode with async Client API"""
|
||||
mock_client.privacy_mode = True
|
||||
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] is None
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
async def test_async_client_different_input_formats(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test different input formats with async Client API"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
# Test string input
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id"
|
||||
)
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
|
||||
|
||||
# Test Gemini-specific format with parts array
|
||||
mock_client.reset_mock()
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=[{"role": "user", "parts": [{"text": "hey"}]}],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hey"}]}
|
||||
]
|
||||
|
||||
# Test multiple parts in the parts array
|
||||
mock_client.reset_mock()
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=[{"role": "user", "parts": [{"text": "Hello "}, {"text": "world"}]}],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello "},
|
||||
{"type": "text", "text": "world"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Test list input with string
|
||||
mock_client.capture.reset_mock()
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash", contents=["List item"], posthog_distinct_id="test-id"
|
||||
)
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "List item"}]
|
||||
|
||||
|
||||
async def test_async_client_model_parameters(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test model parameters with async Client API"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
temperature=0.7,
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_model_parameters"]["temperature"] == 0.7
|
||||
assert props["$ai_model_parameters"]["max_tokens"] == 100
|
||||
|
||||
|
||||
async def test_async_client_default_settings(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test async client with default PostHog settings"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(
|
||||
api_key="test-key",
|
||||
posthog_client=mock_client,
|
||||
posthog_distinct_id="default_user",
|
||||
posthog_properties={"team": "ai"},
|
||||
posthog_privacy_mode=False,
|
||||
posthog_groups={"company": "acme_corp"},
|
||||
)
|
||||
|
||||
# Call without overriding defaults
|
||||
await client.models.generate_content(model="gemini-2.0-flash", contents=["Hello"])
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "default_user"
|
||||
assert call_args["groups"] == {"company": "acme_corp"}
|
||||
assert props["team"] == "ai"
|
||||
|
||||
|
||||
async def test_async_client_override_defaults(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test overriding async client defaults per call"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(
|
||||
api_key="test-key",
|
||||
posthog_client=mock_client,
|
||||
posthog_distinct_id="default_user",
|
||||
posthog_properties={"team": "ai"},
|
||||
posthog_privacy_mode=False,
|
||||
posthog_groups={"company": "acme_corp"},
|
||||
)
|
||||
|
||||
# Override defaults in call
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="specific_user",
|
||||
posthog_properties={"feature": "chat", "urgent": True},
|
||||
posthog_privacy_mode=True,
|
||||
posthog_groups={"organization": "special_org"},
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Check overrides
|
||||
assert call_args["distinct_id"] == "specific_user"
|
||||
assert call_args["groups"] == {"organization": "special_org"}
|
||||
assert props["$ai_input"] is None # privacy mode was overridden
|
||||
|
||||
# Check merged properties (defaults + call-specific)
|
||||
assert props["team"] == "ai" # from defaults
|
||||
assert props["feature"] == "chat" # from call
|
||||
assert props["urgent"] is True # from call
|
||||
|
||||
|
||||
async def test_async_vertex_ai_parameters_passed_through(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test that Vertex AI parameters are properly passed to genai.Client"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
# Mock credentials object
|
||||
mock_credentials = MagicMock()
|
||||
mock_debug_config = MagicMock()
|
||||
mock_http_options = MagicMock()
|
||||
|
||||
# Create client with Vertex AI parameters
|
||||
AsyncClient(
|
||||
vertexai=True,
|
||||
credentials=mock_credentials,
|
||||
project="test-project",
|
||||
location="us-central1",
|
||||
debug_config=mock_debug_config,
|
||||
http_options=mock_http_options,
|
||||
posthog_client=mock_client,
|
||||
)
|
||||
|
||||
# Verify genai.Client was called with correct parameters
|
||||
google_genai.Client.assert_called_once_with(
|
||||
vertexai=True,
|
||||
credentials=mock_credentials,
|
||||
project="test-project",
|
||||
location="us-central1",
|
||||
debug_config=mock_debug_config,
|
||||
http_options=mock_http_options,
|
||||
)
|
||||
|
||||
|
||||
async def test_async_api_key_mode(mock_client, mock_google_genai_client):
|
||||
"""Test API key authentication mode with async client"""
|
||||
|
||||
# Create async client with just API key (traditional mode)
|
||||
AsyncClient(
|
||||
api_key="test-api-key",
|
||||
posthog_client=mock_client,
|
||||
)
|
||||
|
||||
# Verify genai.Client was called with only api_key
|
||||
google_genai.Client.assert_called_once_with(api_key="test-api-key")
|
||||
|
||||
|
||||
async def test_async_function_calls_in_output_choices(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response_with_function_calls
|
||||
):
|
||||
"""Test that function calls are properly included in $ai_output_choices with async"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response_with_function_calls
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=["What's the weather in San Francisco?"],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_gemini_response_with_function_calls
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.5-flash"
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I'll check the weather for you."},
|
||||
{"type": "text", "text": " Let me look that up."},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_current_weather",
|
||||
"arguments": {"location": "San Francisco"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Check token usage
|
||||
assert props["$ai_input_tokens"] == 25
|
||||
assert props["$ai_output_tokens"] == 15
|
||||
assert props["$ai_http_status"] == 200
|
||||
|
||||
|
||||
async def test_async_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
|
||||
"""Test that cache and reasoning tokens are properly extracted with async"""
|
||||
# Create a mock response with cache and reasoning tokens
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response with cache"
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 100
|
||||
mock_usage.candidates_token_count = 50
|
||||
mock_usage.cached_content_token_count = 30 # Cache tokens
|
||||
mock_usage.thoughts_token_count = 10 # Reasoning tokens
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
# Mock candidates
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.text = "Test response with cache"
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.5-pro",
|
||||
contents="Test with cache",
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Check that all token types are present
|
||||
assert props["$ai_input_tokens"] == 100
|
||||
assert props["$ai_output_tokens"] == 50
|
||||
assert props["$ai_cache_read_input_tokens"] == 30
|
||||
assert props["$ai_reasoning_tokens"] == 10
|
||||
|
||||
|
||||
async def test_async_streaming_cache_and_reasoning_tokens(
|
||||
mock_client, mock_google_genai_client
|
||||
):
|
||||
"""Test that cache and reasoning tokens are properly extracted in async streaming"""
|
||||
|
||||
async def mock_streaming_response():
|
||||
# Create mock chunks with cache and reasoning tokens
|
||||
chunk1 = MagicMock()
|
||||
chunk1.text = "Hello "
|
||||
chunk1_usage = MagicMock()
|
||||
chunk1_usage.prompt_token_count = 100
|
||||
chunk1_usage.candidates_token_count = 5
|
||||
chunk1_usage.cached_content_token_count = 30 # Cache tokens
|
||||
chunk1_usage.thoughts_token_count = 0
|
||||
chunk1.usage_metadata = chunk1_usage
|
||||
yield chunk1
|
||||
|
||||
chunk2 = MagicMock()
|
||||
chunk2.text = "world!"
|
||||
chunk2_usage = MagicMock()
|
||||
chunk2_usage.prompt_token_count = 100
|
||||
chunk2_usage.candidates_token_count = 10
|
||||
chunk2_usage.cached_content_token_count = 30 # Same cache tokens
|
||||
chunk2_usage.thoughts_token_count = 5 # Reasoning tokens
|
||||
chunk2.usage_metadata = chunk2_usage
|
||||
yield chunk2
|
||||
|
||||
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
|
||||
return_value=mock_streaming_response()
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content_stream(
|
||||
model="gemini-2.5-pro",
|
||||
contents="Test streaming with cache",
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
# Consume the stream
|
||||
result = []
|
||||
async for chunk in response:
|
||||
result.append(chunk)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
# Check PostHog capture was called
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Check that all token types are present (should use final chunk's usage)
|
||||
assert props["$ai_input_tokens"] == 100
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_cache_read_input_tokens"] == 30
|
||||
assert props["$ai_reasoning_tokens"] == 5
|
||||
|
||||
|
||||
async def test_async_web_search_grounding(mock_client, mock_google_genai_client):
|
||||
"""Test async web search detection via grounding_metadata."""
|
||||
|
||||
# Create mock response with grounding metadata
|
||||
mock_response = MagicMock()
|
||||
|
||||
# Mock usage metadata
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 60
|
||||
mock_usage.candidates_token_count = 40
|
||||
mock_usage.cached_content_token_count = 0
|
||||
mock_usage.thoughts_token_count = 0
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
# Mock grounding metadata
|
||||
mock_grounding_chunk = MagicMock()
|
||||
mock_grounding_chunk.uri = "https://example.com"
|
||||
|
||||
mock_grounding_metadata = MagicMock()
|
||||
mock_grounding_metadata.grounding_chunks = [mock_grounding_chunk]
|
||||
|
||||
# Mock text part
|
||||
mock_text_part = MagicMock()
|
||||
mock_text_part.text = "According to search results..."
|
||||
type(mock_text_part).text = mock_text_part.text
|
||||
|
||||
# Mock content with parts
|
||||
mock_content = MagicMock()
|
||||
mock_content.parts = [mock_text_part]
|
||||
|
||||
# Mock candidate with grounding metadata
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.content = mock_content
|
||||
mock_candidate.grounding_metadata = mock_grounding_metadata
|
||||
type(mock_candidate).grounding_metadata = mock_candidate.grounding_metadata
|
||||
|
||||
mock_response.candidates = [mock_candidate]
|
||||
mock_response.text = "According to search results..."
|
||||
|
||||
# Mock the async generate_content method
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents="What's the latest news?",
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Verify web search count is detected (binary for grounding)
|
||||
assert props["$ai_web_search_count"] == 1
|
||||
assert props["$ai_input_tokens"] == 60
|
||||
assert props["$ai_output_tokens"] == 40
|
||||
|
||||
|
||||
async def test_async_streaming_with_web_search(mock_client, mock_google_genai_client):
|
||||
"""Test that web search count is properly captured in async streaming mode."""
|
||||
|
||||
async def mock_streaming_response():
|
||||
# Create chunk 1 with grounding metadata
|
||||
mock_chunk1 = MagicMock()
|
||||
mock_chunk1.text = "According to "
|
||||
|
||||
mock_usage1 = MagicMock()
|
||||
mock_usage1.prompt_token_count = 30
|
||||
mock_usage1.candidates_token_count = 5
|
||||
mock_usage1.cached_content_token_count = 0
|
||||
mock_usage1.thoughts_token_count = 0
|
||||
mock_chunk1.usage_metadata = mock_usage1
|
||||
|
||||
# Add grounding metadata to first chunk
|
||||
mock_grounding_chunk = MagicMock()
|
||||
mock_grounding_chunk.uri = "https://example.com"
|
||||
|
||||
mock_grounding_metadata = MagicMock()
|
||||
mock_grounding_metadata.grounding_chunks = [mock_grounding_chunk]
|
||||
|
||||
mock_candidate1 = MagicMock()
|
||||
mock_candidate1.grounding_metadata = mock_grounding_metadata
|
||||
type(mock_candidate1).grounding_metadata = mock_candidate1.grounding_metadata
|
||||
|
||||
mock_chunk1.candidates = [mock_candidate1]
|
||||
yield mock_chunk1
|
||||
|
||||
# Create chunk 2
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.text = "search results..."
|
||||
|
||||
mock_usage2 = MagicMock()
|
||||
mock_usage2.prompt_token_count = 30
|
||||
mock_usage2.candidates_token_count = 15
|
||||
mock_usage2.cached_content_token_count = 0
|
||||
mock_usage2.thoughts_token_count = 0
|
||||
mock_chunk2.usage_metadata = mock_usage2
|
||||
|
||||
mock_candidate2 = MagicMock()
|
||||
mock_chunk2.candidates = [mock_candidate2]
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the async generate_content_stream method
|
||||
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
|
||||
return_value=mock_streaming_response()
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content_stream(
|
||||
model="gemini-2.5-flash",
|
||||
contents="What's the latest news?",
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
chunks = []
|
||||
async for chunk in response:
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) == 2
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Verify web search count is detected (binary for grounding)
|
||||
assert props["$ai_web_search_count"] == 1
|
||||
assert props["$ai_input_tokens"] == 30
|
||||
assert props["$ai_output_tokens"] == 15
|
||||
@@ -1,5 +1,5 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("langchain")
|
||||
pytest.importorskip("langchain_core")
|
||||
pytest.importorskip("langchain_community")
|
||||
pytest.importorskip("langgraph")
|
||||
|
||||
@@ -113,6 +113,7 @@ def test_metadata_capture(mock_client):
|
||||
base_url="https://us.posthog.com",
|
||||
name="test",
|
||||
end_time=None,
|
||||
posthog_properties=None,
|
||||
)
|
||||
assert callbacks._runs[run_id] == expected
|
||||
with patch("time.time", return_value=1234567891):
|
||||
@@ -204,6 +205,7 @@ def test_basic_chat_chain(mock_client, stream):
|
||||
# Generation is second
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
assert "distinct_id" in generation_args
|
||||
assert generation_props["$ai_framework"] == "langchain"
|
||||
assert "$ai_model" in generation_props
|
||||
assert "$ai_provider" in generation_props
|
||||
assert generation_props["$ai_input"] == [
|
||||
@@ -1123,9 +1125,9 @@ def test_anthropic_chain(mock_client):
|
||||
)
|
||||
chain = prompt | ChatAnthropic(
|
||||
api_key=ANTHROPIC_API_KEY,
|
||||
model="claude-3-opus-20240229",
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
temperature=0,
|
||||
max_tokens=1,
|
||||
max_tokens=1024,
|
||||
)
|
||||
callbacks = CallbackHandler(
|
||||
mock_client,
|
||||
@@ -1148,12 +1150,12 @@ def test_anthropic_chain(mock_client):
|
||||
assert gen_args["event"] == "$ai_generation"
|
||||
assert gen_props["$ai_trace_id"] == "test-trace-id"
|
||||
assert gen_props["$ai_provider"] == "anthropic"
|
||||
assert gen_props["$ai_model"] == "claude-3-opus-20240229"
|
||||
assert gen_props["$ai_model"] == "claude-sonnet-4-5-20250929"
|
||||
assert gen_props["foo"] == "bar"
|
||||
|
||||
assert gen_props["$ai_model_parameters"] == {
|
||||
"temperature": 0.0,
|
||||
"max_tokens": 1,
|
||||
"max_tokens": 1024,
|
||||
"streaming": False,
|
||||
}
|
||||
assert gen_props["$ai_input"] == [
|
||||
@@ -1169,7 +1171,7 @@ def test_anthropic_chain(mock_client):
|
||||
<= approximate_latency
|
||||
)
|
||||
assert gen_props["$ai_input_tokens"] == 17
|
||||
assert gen_props["$ai_output_tokens"] == 1
|
||||
assert gen_props["$ai_output_tokens"] == 4
|
||||
|
||||
assert trace_args["event"] == "$ai_trace"
|
||||
assert trace_props["$ai_input_state"] == {}
|
||||
@@ -1186,9 +1188,9 @@ async def test_async_anthropic_streaming(mock_client):
|
||||
)
|
||||
chain = prompt | ChatAnthropic(
|
||||
api_key=ANTHROPIC_API_KEY,
|
||||
model="claude-3-opus-20240229",
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
temperature=0,
|
||||
max_tokens=1,
|
||||
max_tokens=1024,
|
||||
streaming=True,
|
||||
stream_usage=True,
|
||||
)
|
||||
@@ -1268,6 +1270,7 @@ def test_metadata_tools(mock_client):
|
||||
name="test",
|
||||
tools=tools,
|
||||
end_time=None,
|
||||
posthog_properties=None,
|
||||
)
|
||||
assert callbacks._runs[run_id] == expected
|
||||
with patch("time.time", return_value=1234567891):
|
||||
@@ -1564,9 +1567,9 @@ def test_anthropic_cache_write_and_read_tokens(mock_client):
|
||||
AIMessage(
|
||||
content="Using cached analysis to provide quick response.",
|
||||
usage_metadata={
|
||||
"input_tokens": 200,
|
||||
"input_tokens": 1200,
|
||||
"output_tokens": 30,
|
||||
"total_tokens": 1030,
|
||||
"total_tokens": 1230,
|
||||
"cache_read_input_tokens": 800, # Anthropic cache read
|
||||
},
|
||||
)
|
||||
@@ -1583,13 +1586,147 @@ def test_anthropic_cache_write_and_read_tokens(mock_client):
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
assert generation_props["$ai_input_tokens"] == 200
|
||||
assert (
|
||||
generation_props["$ai_input_tokens"] == 1200
|
||||
) # No provider metadata, no subtraction
|
||||
assert generation_props["$ai_output_tokens"] == 30
|
||||
assert generation_props["$ai_cache_creation_input_tokens"] == 0
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 800
|
||||
assert generation_props["$ai_reasoning_tokens"] == 0
|
||||
|
||||
|
||||
def test_anthropic_provider_subtracts_cache_tokens(mock_client):
|
||||
"""Test that Anthropic provider correctly subtracts cache tokens from input tokens."""
|
||||
from langchain_core.outputs import LLMResult, ChatGeneration
|
||||
from langchain_core.messages import AIMessage
|
||||
from uuid import uuid4
|
||||
|
||||
cb = CallbackHandler(mock_client)
|
||||
run_id = uuid4()
|
||||
|
||||
# Set up with Anthropic provider
|
||||
cb._set_llm_metadata(
|
||||
serialized={},
|
||||
run_id=run_id,
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
metadata={"ls_provider": "anthropic", "ls_model_name": "claude-3-sonnet"},
|
||||
)
|
||||
|
||||
# Response with cache tokens: 1200 input (includes 800 cached)
|
||||
response = LLMResult(
|
||||
generations=[
|
||||
[
|
||||
ChatGeneration(
|
||||
message=AIMessage(content="Response"),
|
||||
generation_info={
|
||||
"usage_metadata": {
|
||||
"input_tokens": 1200,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 800,
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
],
|
||||
llm_output={},
|
||||
)
|
||||
|
||||
cb._pop_run_and_capture_generation(run_id, None, response)
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[0][1]
|
||||
assert generation_args["properties"]["$ai_input_tokens"] == 400 # 1200 - 800
|
||||
assert generation_args["properties"]["$ai_cache_read_input_tokens"] == 800
|
||||
|
||||
|
||||
def test_anthropic_provider_subtracts_cache_write_tokens(mock_client):
|
||||
"""Test that Anthropic provider correctly subtracts cache write tokens from input tokens."""
|
||||
from langchain_core.outputs import LLMResult, ChatGeneration
|
||||
from langchain_core.messages import AIMessage
|
||||
from uuid import uuid4
|
||||
|
||||
cb = CallbackHandler(mock_client)
|
||||
run_id = uuid4()
|
||||
|
||||
# Set up with Anthropic provider
|
||||
cb._set_llm_metadata(
|
||||
serialized={},
|
||||
run_id=run_id,
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
metadata={"ls_provider": "anthropic", "ls_model_name": "claude-3-sonnet"},
|
||||
)
|
||||
|
||||
# Response with cache creation: 1000 input (includes 800 being written to cache)
|
||||
response = LLMResult(
|
||||
generations=[
|
||||
[
|
||||
ChatGeneration(
|
||||
message=AIMessage(content="Response"),
|
||||
generation_info={
|
||||
"usage_metadata": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 50,
|
||||
"cache_creation_input_tokens": 800,
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
],
|
||||
llm_output={},
|
||||
)
|
||||
|
||||
cb._pop_run_and_capture_generation(run_id, None, response)
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[0][1]
|
||||
assert generation_args["properties"]["$ai_input_tokens"] == 200 # 1000 - 800
|
||||
assert generation_args["properties"]["$ai_cache_creation_input_tokens"] == 800
|
||||
|
||||
|
||||
def test_anthropic_provider_subtracts_both_cache_read_and_write_tokens(mock_client):
|
||||
"""Test that Anthropic provider correctly subtracts both cache read and write tokens."""
|
||||
from langchain_core.outputs import LLMResult, ChatGeneration
|
||||
from langchain_core.messages import AIMessage
|
||||
from uuid import uuid4
|
||||
|
||||
cb = CallbackHandler(mock_client)
|
||||
run_id = uuid4()
|
||||
|
||||
# Set up with Anthropic provider
|
||||
cb._set_llm_metadata(
|
||||
serialized={},
|
||||
run_id=run_id,
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
metadata={"ls_provider": "anthropic", "ls_model_name": "claude-3-sonnet"},
|
||||
)
|
||||
|
||||
# Response with both cache read and creation
|
||||
response = LLMResult(
|
||||
generations=[
|
||||
[
|
||||
ChatGeneration(
|
||||
message=AIMessage(content="Response"),
|
||||
generation_info={
|
||||
"usage_metadata": {
|
||||
"input_tokens": 2000,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 800,
|
||||
"cache_creation_input_tokens": 500,
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
],
|
||||
llm_output={},
|
||||
)
|
||||
|
||||
cb._pop_run_and_capture_generation(run_id, None, response)
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[0][1]
|
||||
# 2000 - 800 (read) - 500 (write) = 700
|
||||
assert generation_args["properties"]["$ai_input_tokens"] == 700
|
||||
assert generation_args["properties"]["$ai_cache_read_input_tokens"] == 800
|
||||
assert generation_args["properties"]["$ai_cache_creation_input_tokens"] == 500
|
||||
|
||||
|
||||
def test_openai_cache_read_tokens(mock_client):
|
||||
"""Test that OpenAI cache read tokens are captured correctly."""
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
@@ -1625,7 +1762,7 @@ def test_openai_cache_read_tokens(mock_client):
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
assert generation_props["$ai_input_tokens"] == 150
|
||||
assert generation_props["$ai_input_tokens"] == 150 # No subtraction for OpenAI
|
||||
assert generation_props["$ai_output_tokens"] == 40
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 100
|
||||
assert generation_props["$ai_cache_creation_input_tokens"] == 0
|
||||
@@ -1707,7 +1844,7 @@ def test_combined_reasoning_and_cache_tokens(mock_client):
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
assert generation_props["$ai_input_tokens"] == 500
|
||||
assert generation_props["$ai_input_tokens"] == 500 # No subtraction for OpenAI
|
||||
assert generation_props["$ai_output_tokens"] == 100
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 300
|
||||
assert generation_props["$ai_cache_creation_input_tokens"] == 0
|
||||
@@ -1715,7 +1852,7 @@ def test_combined_reasoning_and_cache_tokens(mock_client):
|
||||
|
||||
|
||||
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OPENAI_API_KEY is not set")
|
||||
def test_openai_reasoning_tokens(mock_client):
|
||||
def test_openai_reasoning_tokens_o4_mini(mock_client):
|
||||
model = ChatOpenAI(
|
||||
api_key=OPENAI_API_KEY, model="o4-mini", max_completion_tokens=10
|
||||
)
|
||||
@@ -1876,3 +2013,634 @@ def test_tool_definition(mock_client):
|
||||
assert props["$ai_latency"] == 1.0
|
||||
# Verify that tools are captured in the $ai_tools property
|
||||
assert props["$ai_tools"] == tools
|
||||
|
||||
|
||||
def test_cache_read_tokens_subtraction_from_input_tokens(mock_client):
|
||||
"""Test that cache_read_tokens are properly subtracted from input_tokens.
|
||||
|
||||
This tests the logic in callbacks.py lines 757-758:
|
||||
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)
|
||||
"""
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[("user", "Use the cached prompt for this request")]
|
||||
)
|
||||
|
||||
# Scenario 1: input_tokens includes cache_read_tokens (typical case)
|
||||
# input_tokens=150 includes 100 cache_read tokens, so actual input is 50
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="Response using cached prompt context.",
|
||||
usage_metadata={
|
||||
"input_tokens": 150, # Total includes cache reads
|
||||
"output_tokens": 40,
|
||||
"total_tokens": 190,
|
||||
"cache_read_input_tokens": 100, # 100 tokens read from cache
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Response using cached prompt context."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
# Input tokens not reduced without provider metadata
|
||||
assert generation_props["$ai_input_tokens"] == 150
|
||||
assert generation_props["$ai_output_tokens"] == 40
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 100
|
||||
|
||||
|
||||
def test_cache_read_tokens_subtraction_prevents_negative(mock_client):
|
||||
"""Test that cache_read_tokens subtraction doesn't result in negative input_tokens.
|
||||
|
||||
This tests the max(..., 0) part of the logic in callbacks.py lines 757-758.
|
||||
"""
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[("user", "Edge case with large cache read")]
|
||||
)
|
||||
|
||||
# Edge case: cache_read_tokens >= input_tokens
|
||||
# This could happen in some API responses where accounting differs
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="Response with edge case token counts.",
|
||||
usage_metadata={
|
||||
"input_tokens": 80,
|
||||
"output_tokens": 20,
|
||||
"total_tokens": 100,
|
||||
"cache_read_input_tokens": 100, # More than input_tokens
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Response with edge case token counts."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
# Input tokens not reduced without provider metadata
|
||||
assert generation_props["$ai_input_tokens"] == 80
|
||||
assert generation_props["$ai_output_tokens"] == 20
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 100
|
||||
|
||||
|
||||
def test_no_cache_read_tokens_no_subtraction(mock_client):
|
||||
"""Test that when there are no cache_read_tokens, input_tokens remain unchanged.
|
||||
|
||||
This tests the conditional check before the subtraction in callbacks.py line 757.
|
||||
"""
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[("user", "Normal request without cache")]
|
||||
)
|
||||
|
||||
# No cache usage - input_tokens should remain as-is
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="Response without cache.",
|
||||
usage_metadata={
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 30,
|
||||
"total_tokens": 130,
|
||||
# No cache_read_input_tokens
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Response without cache."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
# Input tokens should remain unchanged at 100
|
||||
assert generation_props["$ai_input_tokens"] == 100
|
||||
assert generation_props["$ai_output_tokens"] == 30
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 0
|
||||
|
||||
|
||||
def test_zero_input_tokens_with_cache_read(mock_client):
|
||||
"""Test edge case where input_tokens is 0 but cache_read_tokens exist.
|
||||
|
||||
This tests the falsy check in the conditional (line 757).
|
||||
"""
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "Edge case query")])
|
||||
|
||||
# Edge case: input_tokens is 0 (falsy), should skip subtraction
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="Response.",
|
||||
usage_metadata={
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 10,
|
||||
"total_tokens": 10,
|
||||
"cache_read_input_tokens": 50,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Response."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
# Input tokens should remain 0 (no subtraction because input_tokens is falsy)
|
||||
assert generation_props["$ai_input_tokens"] == 0
|
||||
assert generation_props["$ai_output_tokens"] == 10
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 50
|
||||
|
||||
|
||||
def test_non_anthropic_cache_write_tokens_not_subtracted_from_input(mock_client):
|
||||
"""Test that cache_creation_input_tokens do NOT affect input_tokens for non-Anthropic providers.
|
||||
|
||||
When no provider metadata is set (or for non-Anthropic providers), cache tokens should
|
||||
NOT be subtracted from input_tokens. This is because different providers report tokens
|
||||
differently - only Anthropic's LangChain integration requires subtraction.
|
||||
"""
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "Create cache")])
|
||||
|
||||
# Cache creation without cache read
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="Creating cache.",
|
||||
usage_metadata={
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 20,
|
||||
"total_tokens": 1020,
|
||||
"cache_creation_input_tokens": 800, # Cache write, not read
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Creating cache."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
# Input tokens should NOT be reduced by cache_creation_input_tokens
|
||||
assert generation_props["$ai_input_tokens"] == 1000
|
||||
assert generation_props["$ai_output_tokens"] == 20
|
||||
assert generation_props["$ai_cache_creation_input_tokens"] == 800
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 0
|
||||
|
||||
|
||||
def test_agent_action_and_finish_imports():
|
||||
"""
|
||||
Regression test for LangChain 1.0+ compatibility (Issue #362).
|
||||
Verifies that AgentAction and AgentFinish can be imported and used.
|
||||
This test ensures the imports work with both LangChain 0.x and 1.0+.
|
||||
"""
|
||||
# Import the types that caused the compatibility issue
|
||||
try:
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
from langchain.schema.agent import AgentAction, AgentFinish # type: ignore
|
||||
|
||||
# Verify they're available in the callbacks module
|
||||
from posthog.ai.langchain.callbacks import CallbackHandler
|
||||
|
||||
# Test on_agent_action with mock data
|
||||
mock_client = MagicMock()
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
run_id = uuid.uuid4()
|
||||
parent_run_id = uuid.uuid4()
|
||||
|
||||
# Create mock AgentAction
|
||||
action = AgentAction(tool="test_tool", tool_input="test_input", log="test_log")
|
||||
|
||||
# Should not raise an exception
|
||||
callbacks.on_agent_action(action, run_id=run_id, parent_run_id=parent_run_id)
|
||||
|
||||
# Verify parent was set
|
||||
assert run_id in callbacks._parent_tree
|
||||
assert callbacks._parent_tree[run_id] == parent_run_id
|
||||
|
||||
# Test on_agent_finish with mock data
|
||||
finish = AgentFinish(return_values={"output": "test_output"}, log="finish_log")
|
||||
|
||||
# Should not raise an exception
|
||||
callbacks.on_agent_finish(finish, run_id=run_id, parent_run_id=parent_run_id)
|
||||
|
||||
# Verify capture was called
|
||||
assert mock_client.capture.call_count == 1
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
assert call_args["event"] == "$ai_span"
|
||||
|
||||
|
||||
def test_posthog_properties_field_in_generation_metadata(mock_client):
|
||||
"""Test that posthog_properties is properly stored in GenerationMetadata."""
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
run_id = uuid.uuid4()
|
||||
|
||||
# Test with billable=True
|
||||
with patch("time.time", return_value=1234567890):
|
||||
callbacks._set_llm_metadata(
|
||||
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
|
||||
run_id,
|
||||
messages=[{"role": "user", "content": "Test message"}],
|
||||
invocation_params={"temperature": 0.5},
|
||||
metadata={
|
||||
"ls_model_name": "gpt-4o",
|
||||
"ls_provider": "openai",
|
||||
"posthog_properties": {"$ai_billable": True},
|
||||
},
|
||||
name="test",
|
||||
)
|
||||
|
||||
expected = GenerationMetadata(
|
||||
model="gpt-4o",
|
||||
input=[{"role": "user", "content": "Test message"}],
|
||||
start_time=1234567890,
|
||||
model_params={"temperature": 0.5},
|
||||
provider="openai",
|
||||
base_url="https://api.openai.com",
|
||||
name="test",
|
||||
posthog_properties={"$ai_billable": True},
|
||||
end_time=None,
|
||||
)
|
||||
assert callbacks._runs[run_id] == expected
|
||||
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
|
||||
|
||||
callbacks._pop_run_metadata(run_id)
|
||||
|
||||
# Test with billable=False (explicit)
|
||||
run_id2 = uuid.uuid4()
|
||||
with patch("time.time", return_value=1234567890):
|
||||
callbacks._set_llm_metadata(
|
||||
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
|
||||
run_id2,
|
||||
messages=[{"role": "user", "content": "Test message"}],
|
||||
invocation_params={"temperature": 0.5},
|
||||
metadata={
|
||||
"ls_model_name": "gpt-4o",
|
||||
"ls_provider": "openai",
|
||||
"posthog_properties": {"$ai_billable": False},
|
||||
},
|
||||
name="test",
|
||||
)
|
||||
|
||||
assert callbacks._runs[run_id2].posthog_properties == {"$ai_billable": False}
|
||||
callbacks._pop_run_metadata(run_id2)
|
||||
|
||||
# Test when posthog_properties not provided
|
||||
run_id3 = uuid.uuid4()
|
||||
with patch("time.time", return_value=1234567890):
|
||||
callbacks._set_llm_metadata(
|
||||
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
|
||||
run_id3,
|
||||
messages=[{"role": "user", "content": "Test message"}],
|
||||
invocation_params={"temperature": 0.5},
|
||||
metadata={"ls_model_name": "gpt-4o", "ls_provider": "openai"},
|
||||
name="test",
|
||||
)
|
||||
|
||||
assert callbacks._runs[run_id3].posthog_properties is None
|
||||
|
||||
|
||||
def test_billable_property_in_generation_event(mock_client):
|
||||
"""Test that the billable property is captured in the $ai_generation event."""
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
|
||||
# We need to test the _set_llm_metadata directly since FakeMessagesListChatModel
|
||||
# doesn't support metadata in the same way as real models
|
||||
run_id = uuid.uuid4()
|
||||
with patch("time.time", return_value=1234567890):
|
||||
callbacks._set_llm_metadata(
|
||||
{},
|
||||
run_id,
|
||||
messages=[{"role": "user", "content": "Test"}],
|
||||
metadata={
|
||||
"posthog_properties": {"$ai_billable": True},
|
||||
"ls_model_name": "test-model",
|
||||
},
|
||||
invocation_params={},
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.generations = [[MagicMock()]]
|
||||
|
||||
with patch("time.time", return_value=1234567891):
|
||||
run = callbacks._pop_run_metadata(run_id)
|
||||
|
||||
callbacks._capture_generation(
|
||||
trace_id=run_id,
|
||||
run_id=run_id,
|
||||
run=run,
|
||||
output=mock_response,
|
||||
parent_run_id=None,
|
||||
)
|
||||
|
||||
assert mock_client.capture.call_count == 1
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_billable"] is True
|
||||
|
||||
|
||||
def test_billable_defaults_to_false_in_event(mock_client):
|
||||
"""Test that $ai_billable is not present when not specified."""
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "Test query")])
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[AIMessage(content="Test response")],
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
generation_call = None
|
||||
for call in mock_client.capture.call_args_list:
|
||||
if call[1]["event"] == "$ai_generation":
|
||||
generation_call = call
|
||||
break
|
||||
|
||||
assert generation_call is not None
|
||||
props = generation_call[1]["properties"]
|
||||
assert "$ai_billable" not in props
|
||||
|
||||
|
||||
def test_billable_with_real_chain(mock_client):
|
||||
"""Test billable tracking through a complete chain execution with mocked metadata."""
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
run_id = uuid.uuid4()
|
||||
|
||||
with patch("time.time", return_value=1000.0):
|
||||
callbacks._set_llm_metadata(
|
||||
{},
|
||||
run_id,
|
||||
messages=[{"role": "user", "content": "What's the weather?"}],
|
||||
metadata={
|
||||
"ls_model_name": "fake-model",
|
||||
"ls_provider": "fake",
|
||||
"posthog_properties": {"$ai_billable": True},
|
||||
},
|
||||
invocation_params={"temperature": 0.7},
|
||||
)
|
||||
|
||||
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.generations = [[MagicMock()]]
|
||||
|
||||
with patch("time.time", return_value=1001.0):
|
||||
run = callbacks._pop_run_metadata(run_id)
|
||||
|
||||
callbacks._capture_generation(
|
||||
trace_id=run_id,
|
||||
run_id=run_id,
|
||||
run=run,
|
||||
output=mock_response,
|
||||
parent_run_id=None,
|
||||
)
|
||||
|
||||
assert mock_client.capture.call_count == 1
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_billable"] is True
|
||||
assert props["$ai_model"] == "fake-model"
|
||||
assert props["$ai_provider"] == "fake"
|
||||
|
||||
|
||||
# Exception Capture Integration Tests
|
||||
|
||||
|
||||
def test_exception_autocapture_on_span_error():
|
||||
"""Test that capture_exception is called when a span errors and autocapture is enabled."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.privacy_mode = False
|
||||
mock_client.enable_exception_autocapture = True
|
||||
mock_client.capture_exception.return_value = "exception-uuid-123"
|
||||
|
||||
def failing_span(_):
|
||||
raise ValueError("test error")
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = RunnableLambda(failing_span)
|
||||
|
||||
try:
|
||||
chain.invoke({}, config={"callbacks": callbacks})
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Verify capture_exception was called
|
||||
assert mock_client.capture_exception.call_count == 1
|
||||
exception_call = mock_client.capture_exception.call_args
|
||||
assert isinstance(exception_call[0][0], ValueError)
|
||||
assert str(exception_call[0][0]) == "test error"
|
||||
|
||||
|
||||
def test_exception_autocapture_adds_exception_id_to_span_event():
|
||||
"""Test that $exception_event_id is added to the span event properties."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.privacy_mode = False
|
||||
mock_client.enable_exception_autocapture = True
|
||||
mock_client.capture_exception.return_value = "exception-uuid-456"
|
||||
|
||||
def failing_span(_):
|
||||
raise ValueError("test error")
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = RunnableLambda(failing_span)
|
||||
|
||||
try:
|
||||
chain.invoke({}, config={"callbacks": callbacks})
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Find the span event (should have $ai_is_error=True)
|
||||
span_calls = [
|
||||
call
|
||||
for call in mock_client.capture.call_args_list
|
||||
if call[1].get("properties", {}).get("$ai_is_error") is True
|
||||
]
|
||||
assert len(span_calls) >= 1
|
||||
|
||||
span_props = span_calls[0][1]["properties"]
|
||||
assert span_props["$exception_event_id"] == "exception-uuid-456"
|
||||
assert span_props["$ai_error"] == "ValueError: test error"
|
||||
|
||||
|
||||
def test_exception_autocapture_disabled_does_not_capture():
|
||||
"""Test that capture_exception is NOT called when autocapture is disabled."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.privacy_mode = False
|
||||
mock_client.enable_exception_autocapture = False
|
||||
|
||||
def failing_span(_):
|
||||
raise ValueError("test error")
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = RunnableLambda(failing_span)
|
||||
|
||||
try:
|
||||
chain.invoke({}, config={"callbacks": callbacks})
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Verify capture_exception was NOT called
|
||||
assert mock_client.capture_exception.call_count == 0
|
||||
|
||||
# But the span event should still have error info
|
||||
span_calls = [
|
||||
call
|
||||
for call in mock_client.capture.call_args_list
|
||||
if call[1].get("properties", {}).get("$ai_is_error") is True
|
||||
]
|
||||
assert len(span_calls) >= 1
|
||||
|
||||
span_props = span_calls[0][1]["properties"]
|
||||
assert "$exception_event_id" not in span_props
|
||||
assert span_props["$ai_error"] == "ValueError: test error"
|
||||
|
||||
|
||||
def test_exception_autocapture_on_llm_generation_error(mock_client):
|
||||
"""Test that capture_exception is called when an LLM generation fails."""
|
||||
mock_client.privacy_mode = False
|
||||
mock_client.enable_exception_autocapture = True
|
||||
mock_client.capture_exception.return_value = "exception-uuid-789"
|
||||
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
run_id = uuid.uuid4()
|
||||
|
||||
# Simulate LLM start
|
||||
callbacks.on_llm_start(
|
||||
serialized={"kwargs": {"openai_api_base": "https://api.openai.com"}},
|
||||
prompts=["Hello"],
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
# Simulate LLM error
|
||||
error = Exception("API rate limit exceeded")
|
||||
callbacks.on_llm_error(error, run_id=run_id)
|
||||
|
||||
# Verify capture_exception was called
|
||||
assert mock_client.capture_exception.call_count == 1
|
||||
exception_call = mock_client.capture_exception.call_args
|
||||
assert exception_call[0][0] is error
|
||||
|
||||
# Verify the generation event has $exception_event_id
|
||||
generation_calls = [
|
||||
call
|
||||
for call in mock_client.capture.call_args_list
|
||||
if call[1].get("event") == "$ai_generation"
|
||||
]
|
||||
assert len(generation_calls) == 1
|
||||
|
||||
gen_props = generation_calls[0][1]["properties"]
|
||||
assert gen_props["$exception_event_id"] == "exception-uuid-789"
|
||||
assert gen_props["$ai_is_error"] is True
|
||||
|
||||
|
||||
def test_exception_autocapture_passes_ai_properties_to_exception():
|
||||
"""Test that AI properties are passed to the exception event."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.privacy_mode = False
|
||||
mock_client.enable_exception_autocapture = True
|
||||
mock_client.capture_exception.return_value = "exception-uuid-abc"
|
||||
|
||||
callbacks = CallbackHandler(
|
||||
mock_client,
|
||||
distinct_id="user-123",
|
||||
properties={"custom_prop": "custom_value"},
|
||||
)
|
||||
run_id = uuid.uuid4()
|
||||
|
||||
# Simulate LLM start
|
||||
callbacks.on_llm_start(
|
||||
serialized={"kwargs": {"openai_api_base": "https://api.openai.com"}},
|
||||
prompts=["Hello"],
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
# Simulate LLM error
|
||||
error = Exception("API error")
|
||||
callbacks.on_llm_error(error, run_id=run_id)
|
||||
|
||||
# Verify capture_exception received the properties
|
||||
exception_call = mock_client.capture_exception.call_args
|
||||
props = exception_call[1]["properties"]
|
||||
|
||||
# Should have AI-related properties
|
||||
assert "$ai_trace_id" in props
|
||||
assert "$ai_is_error" in props
|
||||
assert props["$ai_is_error"] is True
|
||||
|
||||
# Should have distinct_id passed through
|
||||
assert exception_call[1]["distinct_id"] == "user-123"
|
||||
|
||||
|
||||
def test_exception_autocapture_none_return_no_exception_id():
|
||||
"""Test that when capture_exception returns None, no $exception_event_id is added."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.privacy_mode = False
|
||||
mock_client.enable_exception_autocapture = True
|
||||
mock_client.capture_exception.return_value = (
|
||||
None # e.g., exception already captured
|
||||
)
|
||||
|
||||
def failing_span(_):
|
||||
raise ValueError("test error")
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = RunnableLambda(failing_span)
|
||||
|
||||
try:
|
||||
chain.invoke({}, config={"callbacks": callbacks})
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# capture_exception was called but returned None
|
||||
assert mock_client.capture_exception.call_count == 1
|
||||
|
||||
# Span event should NOT have $exception_event_id
|
||||
span_calls = [
|
||||
call
|
||||
for call in mock_client.capture.call_args_list
|
||||
if call[1].get("properties", {}).get("$ai_is_error") is True
|
||||
]
|
||||
assert len(span_calls) >= 1
|
||||
|
||||
span_props = span_calls[0][1]["properties"]
|
||||
assert "$exception_event_id" not in span_props
|
||||
|
||||
+1061
-102
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from posthog.ai.sanitization import (
|
||||
@@ -331,5 +332,191 @@ class TestSanitization(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestAIMultipartRequest(unittest.TestCase):
|
||||
"""Test that _INTERNAL_LLMA_MULTIMODAL environment variable controls sanitization."""
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up environment variable after each test
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
def test_multimodal_disabled_redacts_images(self):
|
||||
"""When _INTERNAL_LLMA_MULTIMODAL is not set, images should be redacted."""
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, REDACTED_IMAGE_PLACEHOLDER)
|
||||
|
||||
def test_multimodal_enabled_preserves_images(self):
|
||||
"""When _INTERNAL_LLMA_MULTIMODAL is true, images should be preserved."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, base64_image)
|
||||
|
||||
def test_multimodal_enabled_with_1(self):
|
||||
"""_INTERNAL_LLMA_MULTIMODAL=1 should enable multimodal."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "1"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, base64_image)
|
||||
|
||||
def test_multimodal_enabled_with_yes(self):
|
||||
"""_INTERNAL_LLMA_MULTIMODAL=yes should enable multimodal."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "yes"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, base64_image)
|
||||
|
||||
def test_multimodal_false_redacts_images(self):
|
||||
"""_INTERNAL_LLMA_MULTIMODAL=false should still redact."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "false"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, REDACTED_IMAGE_PLACEHOLDER)
|
||||
|
||||
def test_anthropic_multimodal_enabled(self):
|
||||
"""Anthropic images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": "base64data",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_anthropic(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["source"]["data"], "base64data")
|
||||
|
||||
def test_gemini_multimodal_enabled(self):
|
||||
"""Gemini images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{"inline_data": {"mime_type": "image/jpeg", "data": "base64data"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
self.assertEqual(result[0]["parts"][0]["inline_data"]["data"], "base64data")
|
||||
|
||||
def test_langchain_anthropic_style_multimodal_enabled(self):
|
||||
"""LangChain Anthropic-style images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"data": "base64data"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_langchain(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["source"]["data"], "base64data")
|
||||
|
||||
def test_openai_audio_redacted_by_default(self):
|
||||
"""OpenAI audio should be redacted when _INTERNAL_LLMA_MULTIMODAL is not set."""
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "audio", "data": "base64audiodata", "id": "audio_123"}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["data"], REDACTED_IMAGE_PLACEHOLDER)
|
||||
self.assertEqual(result[0]["content"][0]["id"], "audio_123")
|
||||
|
||||
def test_openai_audio_preserved_with_flag(self):
|
||||
"""OpenAI audio should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "audio", "data": "base64audiodata", "id": "audio_123"}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["data"], "base64audiodata")
|
||||
|
||||
def test_gemini_audio_redacted_by_default(self):
|
||||
"""Gemini audio should be redacted when _INTERNAL_LLMA_MULTIMODAL is not set."""
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "audio/L16;codec=pcm;rate=24000",
|
||||
"data": "base64audiodata",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][0]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_gemini_audio_preserved_with_flag(self):
|
||||
"""Gemini audio should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "audio/L16;codec=pcm;rate=24000",
|
||||
"data": "base64audiodata",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][0]["inline_data"]["data"], "base64audiodata"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
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 MagicMock, patch
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
|
||||
|
||||
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 = Client(FAKE_TEST_API_KEY)
|
||||
self.client._enqueue = 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 openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
from posthog.ai.openai import OpenAI
|
||||
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._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["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 openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
from posthog.ai.openai import OpenAI
|
||||
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._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
def test_openai_streaming_system_parameter(self):
|
||||
"""Test OpenAI streaming with system parameter."""
|
||||
try:
|
||||
from openai.types.chat.chat_completion_chunk import (
|
||||
ChatCompletionChunk,
|
||||
ChoiceDelta,
|
||||
)
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
from posthog.ai.openai import OpenAI
|
||||
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._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["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._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["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._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["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._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["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._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
@@ -4,7 +4,21 @@ from posthog.contexts import (
|
||||
get_context_distinct_id,
|
||||
)
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
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
|
||||
|
||||
@@ -38,14 +52,33 @@ class TestPosthogContextMiddleware(unittest.TestCase):
|
||||
request_filter=None,
|
||||
tag_map=None,
|
||||
capture_exceptions=True,
|
||||
get_response=None,
|
||||
):
|
||||
"""Helper to create middleware instance without calling __init__"""
|
||||
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
|
||||
middleware.get_response = Mock()
|
||||
middleware.extra_tags = extra_tags
|
||||
middleware.request_filter = request_filter
|
||||
middleware.tag_map = tag_map
|
||||
middleware.capture_exceptions = capture_exceptions
|
||||
"""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):
|
||||
@@ -168,6 +201,573 @@ class TestPosthogContextMiddleware(unittest.TestCase):
|
||||
|
||||
self.assertEqual(tags["$request_method"], "PATCH")
|
||||
|
||||
def test_process_exception_called_during_view_exception(self):
|
||||
"""
|
||||
Unit test verifying process_exception captures exceptions per Django's contract.
|
||||
|
||||
Since this is a library test (no Django runtime), we simulate how Django
|
||||
would invoke our middleware in production:
|
||||
1. Middleware.__call__ creates context with request tags
|
||||
2. View raises exception inside get_response
|
||||
3. Django's BaseHandler catches it, calls process_exception, returns error response
|
||||
4. Exception never propagates to middleware's context manager
|
||||
|
||||
We manually call process_exception to simulate Django's behavior - this is
|
||||
the only way to test the hook without a full Django integration test.
|
||||
"""
|
||||
mock_client = Mock()
|
||||
view_exception = ValueError("View raised this error")
|
||||
error_response = Mock(status_code=500)
|
||||
|
||||
def mock_get_response(request):
|
||||
# Simulate Django's exception handling: catches view exception,
|
||||
# calls process_exception hook if it exists, returns error response
|
||||
if hasattr(middleware, "process_exception"):
|
||||
middleware.process_exception(request, view_exception)
|
||||
return error_response
|
||||
|
||||
middleware = self.create_middleware(get_response=mock_get_response)
|
||||
middleware.client = mock_client
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-DISTINCT-ID": "test-user"},
|
||||
method="POST",
|
||||
path="/api/endpoint",
|
||||
)
|
||||
response = middleware(request)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
mock_client.capture_exception.assert_called_once_with(view_exception)
|
||||
|
||||
def test_process_exception_respects_capture_exceptions_false(self):
|
||||
"""Verify process_exception respects capture_exceptions=False setting"""
|
||||
mock_client = Mock()
|
||||
view_exception = ValueError("Should not be captured")
|
||||
|
||||
def mock_get_response(request):
|
||||
if hasattr(middleware, "process_exception"):
|
||||
middleware.process_exception(request, view_exception)
|
||||
return Mock(status_code=500)
|
||||
|
||||
middleware = self.create_middleware(
|
||||
capture_exceptions=False, get_response=mock_get_response
|
||||
)
|
||||
middleware.client = mock_client
|
||||
|
||||
request = MockRequest()
|
||||
middleware(request)
|
||||
|
||||
mock_client.capture_exception.assert_not_called()
|
||||
|
||||
def test_process_exception_respects_request_filter(self):
|
||||
"""Verify process_exception respects request_filter setting"""
|
||||
mock_client = Mock()
|
||||
view_exception = ValueError("Should be filtered")
|
||||
|
||||
def mock_get_response(request):
|
||||
if hasattr(middleware, "process_exception"):
|
||||
middleware.process_exception(request, view_exception)
|
||||
return Mock(status_code=500)
|
||||
|
||||
middleware = self.create_middleware(
|
||||
request_filter=lambda req: False,
|
||||
capture_exceptions=True,
|
||||
get_response=mock_get_response,
|
||||
)
|
||||
middleware.client = mock_client
|
||||
|
||||
request = MockRequest()
|
||||
middleware(request)
|
||||
|
||||
mock_client.capture_exception.assert_not_called()
|
||||
|
||||
|
||||
class TestPosthogContextMiddlewareSync(unittest.TestCase):
|
||||
"""Test synchronous middleware behavior"""
|
||||
|
||||
def test_sync_middleware_call(self):
|
||||
"""Test that sync middleware correctly processes requests"""
|
||||
mock_response = Mock()
|
||||
get_response = Mock(return_value=mock_response)
|
||||
|
||||
# Create middleware with sync get_response
|
||||
middleware = PosthogContextMiddleware(get_response)
|
||||
|
||||
# Verify sync mode detected
|
||||
self.assertFalse(middleware._is_coroutine)
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"},
|
||||
method="GET",
|
||||
path="/test",
|
||||
)
|
||||
|
||||
with new_context():
|
||||
response = middleware(request)
|
||||
|
||||
# Verify response returned
|
||||
self.assertEqual(response, mock_response)
|
||||
get_response.assert_called_once_with(request)
|
||||
|
||||
def test_sync_middleware_with_filter(self):
|
||||
"""Test sync middleware respects request filter"""
|
||||
mock_response = Mock()
|
||||
get_response = Mock(return_value=mock_response)
|
||||
|
||||
# Create middleware with request filter that filters all requests
|
||||
def request_filter(req):
|
||||
return False
|
||||
|
||||
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
|
||||
middleware.get_response = get_response
|
||||
middleware._is_coroutine = False
|
||||
middleware.request_filter = request_filter
|
||||
middleware.capture_exceptions = True
|
||||
middleware.client = None
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
# Should skip context creation and return response directly
|
||||
response = middleware(request)
|
||||
self.assertEqual(response, mock_response)
|
||||
get_response.assert_called_once_with(request)
|
||||
|
||||
def test_view_exceptions_only_captured_via_process_exception(self):
|
||||
"""
|
||||
Demonstrates that process_exception is required to capture view exceptions.
|
||||
|
||||
In production Django, view exceptions don't propagate to middleware's context
|
||||
manager because Django's BaseHandler catches them first and converts them to
|
||||
error responses. Django provides the exception via process_exception hook instead.
|
||||
|
||||
This unit test proves:
|
||||
1. Context manager in __call__ never sees view exceptions (Django intercepts)
|
||||
2. Only process_exception can capture them
|
||||
3. Without process_exception, exceptions are silently lost (v6.7.5 regression)
|
||||
|
||||
We manually call process_exception to verify the hook works - in production,
|
||||
Django's BaseHandler would call it when a view raises.
|
||||
"""
|
||||
mock_client = Mock()
|
||||
get_response = Mock(return_value=Mock(status_code=500))
|
||||
|
||||
middleware = PosthogContextMiddleware(get_response)
|
||||
middleware.client = mock_client
|
||||
|
||||
def get_response_simulating_django(request):
|
||||
# Simulates Django behavior: view exception converted to error response,
|
||||
# never propagates to middleware's context manager
|
||||
return Mock(status_code=500)
|
||||
|
||||
middleware._sync_get_response = get_response_simulating_django
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
response = middleware(request)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
# Context manager didn't capture anything - exception was intercepted by Django
|
||||
mock_client.capture_exception.assert_not_called()
|
||||
|
||||
# Verify process_exception hook exists and captures exceptions when called
|
||||
if hasattr(middleware, "process_exception"):
|
||||
exception = ValueError("View error")
|
||||
middleware.process_exception(request, exception)
|
||||
mock_client.capture_exception.assert_called_once_with(exception)
|
||||
else:
|
||||
self.fail(
|
||||
"process_exception missing - view exceptions will not be captured!"
|
||||
)
|
||||
|
||||
|
||||
class TestPosthogContextMiddlewareAsync(unittest.TestCase):
|
||||
"""Test asynchronous middleware behavior"""
|
||||
|
||||
def test_async_middleware_detection(self):
|
||||
"""Test that async get_response is correctly detected"""
|
||||
|
||||
async def async_get_response(request):
|
||||
return Mock()
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
|
||||
# Verify async mode detected
|
||||
self.assertTrue(middleware._is_coroutine)
|
||||
|
||||
def test_async_middleware_call(self):
|
||||
"""Test that async middleware correctly processes requests"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "async-session"},
|
||||
method="POST",
|
||||
path="/async-test",
|
||||
)
|
||||
|
||||
with new_context():
|
||||
# Call should return the coroutine from __acall__
|
||||
result = middleware(request)
|
||||
|
||||
# Verify it's a coroutine
|
||||
self.assertTrue(asyncio.iscoroutine(result))
|
||||
|
||||
# Await the result
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_filter(self):
|
||||
"""Test async middleware respects request filter"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
# Properly initialize middleware
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
# Override request filter after initialization
|
||||
middleware.request_filter = lambda req: False
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
# Should skip context creation and return response directly
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_context_propagation(self):
|
||||
"""Test that async middleware properly propagates context"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
# Verify context is available during async processing
|
||||
session_id = get_context_session_id()
|
||||
self.assertEqual(session_id, "async-session-123")
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "async-session-123"},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
await result
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_exception_capture(self):
|
||||
"""Test that async middleware captures exceptions during request processing"""
|
||||
|
||||
async def run_test():
|
||||
mock_client = Mock()
|
||||
|
||||
# Make async_get_response raise an exception
|
||||
async def raise_exception(request):
|
||||
raise ValueError("Async test exception")
|
||||
|
||||
# Properly initialize middleware
|
||||
middleware = PosthogContextMiddleware(raise_exception)
|
||||
middleware.client = mock_client # Override with mock client
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
# Should capture exception and re-raise
|
||||
with self.assertRaises(ValueError):
|
||||
result = middleware(request)
|
||||
await result
|
||||
|
||||
# Verify exception was captured by middleware
|
||||
mock_client.capture_exception.assert_called_once()
|
||||
captured_exception = mock_client.capture_exception.call_args[0][0]
|
||||
self.assertIsInstance(captured_exception, ValueError)
|
||||
self.assertEqual(str(captured_exception), "Async test exception")
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_authenticated_user(self):
|
||||
"""
|
||||
Test that async middleware correctly extracts user info in async context.
|
||||
|
||||
Django's request.user is a SimpleLazyObject that defers DB access.
|
||||
In async context, accessing it directly raises SynchronousOnlyOperation.
|
||||
The middleware should use request.auser() instead.
|
||||
|
||||
This tests the fix for issue #355.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = True
|
||||
mock_user.pk = 123
|
||||
mock_user.email = "test@example.com"
|
||||
|
||||
async def async_get_response(request):
|
||||
# Verify user info was extracted and set as distinct_id
|
||||
distinct_id = get_context_distinct_id()
|
||||
self.assertEqual(distinct_id, "123")
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
|
||||
)
|
||||
|
||||
# Mock auser() to return authenticated user
|
||||
async def mock_auser():
|
||||
return mock_user
|
||||
|
||||
request.auser = mock_auser
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_unauthenticated_user(self):
|
||||
"""
|
||||
Test that async middleware handles unauthenticated users correctly.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = False # Not authenticated
|
||||
|
||||
async def async_get_response(request):
|
||||
# Verify no distinct_id was set (no user)
|
||||
distinct_id = get_context_distinct_id()
|
||||
self.assertIsNone(distinct_id)
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
|
||||
)
|
||||
|
||||
async def mock_auser():
|
||||
return mock_user
|
||||
|
||||
request.auser = mock_auser
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_without_user_attribute(self):
|
||||
"""
|
||||
Test that async middleware handles requests without user attribute (no auth middleware).
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
# Request without auser method (no auth middleware)
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
|
||||
)
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_extra_tags(self):
|
||||
"""
|
||||
Test that async middleware works with extra_tags callback.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
def extra_tags_callback(request):
|
||||
# Simple sync callback - should work
|
||||
return {"custom_tag": "custom_value"}
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.extra_tags = extra_tags_callback
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
|
||||
)
|
||||
|
||||
# Mock auser for no user
|
||||
async def mock_auser():
|
||||
return None
|
||||
|
||||
request.auser = mock_auser
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_tag_map(self):
|
||||
"""
|
||||
Test that async middleware works with tag_map callback.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
def tag_map_callback(tags):
|
||||
# Simple sync callback - should work
|
||||
tags["mapped"] = "yes"
|
||||
return tags
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.tag_map = tag_map_callback
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
|
||||
)
|
||||
|
||||
# Mock auser for no user
|
||||
async def mock_auser():
|
||||
return None
|
||||
|
||||
request.auser = mock_auser
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_user_extraction_with_all_headers(self):
|
||||
"""
|
||||
Test async middleware extracts all request info correctly.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = True
|
||||
mock_user.pk = 456
|
||||
mock_user.email = "async@test.com"
|
||||
|
||||
async def async_get_response(request):
|
||||
# Verify all context was set correctly
|
||||
distinct_id = get_context_distinct_id()
|
||||
session_id = get_context_session_id()
|
||||
self.assertEqual(distinct_id, "456")
|
||||
self.assertEqual(session_id, "async-sess-123")
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={
|
||||
"X-POSTHOG-SESSION-ID": "async-sess-123",
|
||||
"X-Forwarded-For": "192.168.1.1",
|
||||
"User-Agent": "TestAgent/1.0",
|
||||
},
|
||||
method="POST",
|
||||
path="/api/test",
|
||||
)
|
||||
|
||||
async def mock_auser():
|
||||
return mock_user
|
||||
|
||||
request.auser = mock_auser
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
class TestPosthogContextMiddlewareHybrid(unittest.TestCase):
|
||||
"""Test hybrid middleware behavior with mixed sync/async chains"""
|
||||
|
||||
def test_hybrid_flags_set(self):
|
||||
"""Test that both capability flags are set"""
|
||||
self.assertTrue(PosthogContextMiddleware.sync_capable)
|
||||
self.assertTrue(PosthogContextMiddleware.async_capable)
|
||||
|
||||
def test_sync_to_async_routing(self):
|
||||
"""Test that __call__ routes to __acall__ when async"""
|
||||
|
||||
async def run_test():
|
||||
async def async_get_response(request):
|
||||
return Mock()
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
|
||||
# Verify routing happens
|
||||
request = MockRequest()
|
||||
result = middleware(request)
|
||||
|
||||
# Should be a coroutine from __acall__
|
||||
self.assertTrue(asyncio.iscoroutine(result))
|
||||
await result # Clean up
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_sync_path_direct_return(self):
|
||||
"""Test that sync path returns directly without coroutine"""
|
||||
mock_response = Mock()
|
||||
|
||||
def sync_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(sync_get_response)
|
||||
|
||||
request = MockRequest()
|
||||
result = middleware(request)
|
||||
|
||||
# Should NOT be a coroutine
|
||||
self.assertFalse(asyncio.iscoroutine(result))
|
||||
self.assertEqual(result, mock_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+250
-22
@@ -9,7 +9,7 @@ from parameterized import parameterized
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.contexts import get_context_session_id, new_context, set_context_session
|
||||
from posthog.request import APIError
|
||||
from posthog.request import APIError, GetResponse
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.types import FeatureFlag, LegacyFlagMetadata
|
||||
from posthog.version import VERSION
|
||||
@@ -198,12 +198,6 @@ class TestClient(unittest.TestCase):
|
||||
print(capture_call)
|
||||
self.assertEqual(capture_call[1]["distinct_id"], "distinct_id")
|
||||
self.assertEqual(capture_call[0][0], "$exception")
|
||||
self.assertEqual(
|
||||
capture_call[1]["properties"]["$exception_type"], "Exception"
|
||||
)
|
||||
self.assertEqual(
|
||||
capture_call[1]["properties"]["$exception_message"], "test exception"
|
||||
)
|
||||
self.assertEqual(
|
||||
capture_call[1]["properties"]["$exception_list"][0]["mechanism"][
|
||||
"type"
|
||||
@@ -415,7 +409,9 @@ class TestClient(unittest.TestCase):
|
||||
)
|
||||
client.feature_flags = [multivariate_flag, basic_flag, false_flag]
|
||||
|
||||
msg_uuid = client.capture("python test event", distinct_id="distinct_id")
|
||||
msg_uuid = client.capture(
|
||||
"python test event", distinct_id="distinct_id", send_feature_flags=True
|
||||
)
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
@@ -571,6 +567,7 @@ class TestClient(unittest.TestCase):
|
||||
"python test event",
|
||||
distinct_id="distinct_id",
|
||||
properties={"$feature/beta-feature-local": "my-custom-variant"},
|
||||
send_feature_flags=True,
|
||||
)
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
self.assertFalse(self.failed)
|
||||
@@ -752,6 +749,178 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_with_send_feature_flags_false_and_local_evaluation_doesnt_send_flags(
|
||||
self, patch_flags
|
||||
):
|
||||
"""Test that send_feature_flags=False with local evaluation enabled does NOT send flags"""
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "remote-variant"}}
|
||||
|
||||
multivariate_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature-local",
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{
|
||||
"key": "first-variant",
|
||||
"name": "First Variant",
|
||||
"rollout_percentage": 50,
|
||||
},
|
||||
{
|
||||
"key": "second-variant",
|
||||
"name": "Second Variant",
|
||||
"rollout_percentage": 50,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
simple_flag = {
|
||||
"id": 2,
|
||||
"name": "Simple Flag",
|
||||
"key": "simple-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
personal_api_key=FAKE_TEST_API_KEY,
|
||||
sync_mode=True,
|
||||
)
|
||||
client.feature_flags = [multivariate_flag, simple_flag]
|
||||
|
||||
msg_uuid = client.capture(
|
||||
"python test event",
|
||||
distinct_id="distinct_id",
|
||||
send_feature_flags=False,
|
||||
)
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
# Get the enqueued message from the mock
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
msg = batch_data[0]
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
# CRITICAL: Verify local flags are NOT included in the event
|
||||
self.assertNotIn("$feature/beta-feature-local", msg["properties"])
|
||||
self.assertNotIn("$feature/simple-flag", msg["properties"])
|
||||
self.assertNotIn("$active_feature_flags", msg["properties"])
|
||||
|
||||
# CRITICAL: Verify the /flags API was NOT called
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_flags(
|
||||
self, patch_flags
|
||||
):
|
||||
"""Test that send_feature_flags=True with local evaluation enabled uses local flags without API call"""
|
||||
patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-variant"}}
|
||||
|
||||
multivariate_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature-local",
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{
|
||||
"key": "first-variant",
|
||||
"name": "First Variant",
|
||||
"rollout_percentage": 50,
|
||||
},
|
||||
{
|
||||
"key": "second-variant",
|
||||
"name": "Second Variant",
|
||||
"rollout_percentage": 50,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
simple_flag = {
|
||||
"id": 2,
|
||||
"name": "Simple Flag",
|
||||
"key": "simple-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
personal_api_key=FAKE_TEST_API_KEY,
|
||||
sync_mode=True,
|
||||
)
|
||||
client.feature_flags = [multivariate_flag, simple_flag]
|
||||
|
||||
msg_uuid = client.capture(
|
||||
"python test event",
|
||||
distinct_id="distinct_id",
|
||||
send_feature_flags=True,
|
||||
)
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
# Get the enqueued message from the mock
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
msg = batch_data[0]
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
# Verify local flags are included in the event
|
||||
self.assertIn("$feature/beta-feature-local", msg["properties"])
|
||||
self.assertIn("$feature/simple-flag", msg["properties"])
|
||||
self.assertEqual(msg["properties"]["$feature/simple-flag"], True)
|
||||
|
||||
# Verify active feature flags are set correctly
|
||||
active_flags = msg["properties"]["$active_feature_flags"]
|
||||
self.assertIn("beta-feature-local", active_flags)
|
||||
self.assertIn("simple-flag", active_flags)
|
||||
|
||||
# The remote flag should NOT be included since we used local evaluation
|
||||
self.assertNotIn("$feature/remote-flag", msg["properties"])
|
||||
|
||||
# CRITICAL: Verify the /flags API was NOT called
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_with_send_feature_flags_options_only_evaluate_locally_true(
|
||||
self, patch_flags
|
||||
@@ -2095,13 +2264,21 @@ class TestClient(unittest.TestCase):
|
||||
self, patch_get, patch_poller
|
||||
):
|
||||
"""Test that when enable_local_evaluation=False, the poller is not started"""
|
||||
patch_get.return_value = {
|
||||
"flags": [
|
||||
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
|
||||
],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
}
|
||||
patch_get.return_value = GetResponse(
|
||||
data={
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"active": True,
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
},
|
||||
etag='"test-etag"',
|
||||
)
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
@@ -2123,13 +2300,21 @@ class TestClient(unittest.TestCase):
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_enable_local_evaluation_true_starts_poller(self, patch_get, patch_poller):
|
||||
"""Test that when enable_local_evaluation=True (default), the poller is started"""
|
||||
patch_get.return_value = {
|
||||
"flags": [
|
||||
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
|
||||
],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
}
|
||||
patch_get.return_value = GetResponse(
|
||||
data={
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"active": True,
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
},
|
||||
etag='"test-etag"',
|
||||
)
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
@@ -2423,3 +2608,46 @@ class TestClient(unittest.TestCase):
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
msg = batch_data[0]
|
||||
self.assertEqual(msg["properties"]["$context_tags"], ["random_tag"])
|
||||
|
||||
@mock.patch(
|
||||
"posthog.client.Client._enqueue", side_effect=Exception("Unexpected error")
|
||||
)
|
||||
def test_methods_handle_exceptions(self, mock_enqueue):
|
||||
"""Test that all decorated methods handle exceptions gracefully."""
|
||||
client = Client("test-key")
|
||||
|
||||
test_cases = [
|
||||
("capture", ["test_event"], {}),
|
||||
("set", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
|
||||
("set_once", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
|
||||
("group_identify", ["group-type", "group-key"], {}),
|
||||
("alias", ["some-id", "new-id"], {}),
|
||||
]
|
||||
|
||||
for method_name, args, kwargs in test_cases:
|
||||
with self.subTest(method=method_name):
|
||||
method = getattr(client, method_name)
|
||||
result = method(*args, **kwargs)
|
||||
self.assertEqual(result, None)
|
||||
|
||||
@mock.patch(
|
||||
"posthog.client.Client._enqueue", side_effect=Exception("Expected error")
|
||||
)
|
||||
def test_debug_flag_re_raises_exceptions(self, mock_enqueue):
|
||||
"""Test that methods re-raise exceptions when debug=True."""
|
||||
client = Client("test-key", debug=True)
|
||||
|
||||
test_cases = [
|
||||
("capture", ["test_event"], {}),
|
||||
("set", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
|
||||
("set_once", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
|
||||
("group_identify", ["group-type", "group-key"], {}),
|
||||
("alias", ["some-id", "new-id"], {}),
|
||||
]
|
||||
|
||||
for method_name, args, kwargs in test_cases:
|
||||
with self.subTest(method=method_name):
|
||||
method = getattr(client, method_name)
|
||||
with self.assertRaises(Exception) as cm:
|
||||
method(*args, **kwargs)
|
||||
self.assertEqual(str(cm.exception), "Expected error")
|
||||
|
||||
@@ -191,6 +191,32 @@ class TestContexts(unittest.TestCase):
|
||||
assert get_context_distinct_id() == "user123"
|
||||
assert get_context_session_id() == "session456"
|
||||
|
||||
def test_child_tags_override_parent_tags_in_non_fresh_context(self):
|
||||
with new_context(fresh=True):
|
||||
tag("shared_key", "parent_value")
|
||||
tag("parent_only", "parent")
|
||||
|
||||
with new_context(fresh=False):
|
||||
# Child should inherit parent tags
|
||||
assert get_tags()["parent_only"] == "parent"
|
||||
|
||||
# Child sets same key - should override parent
|
||||
tag("shared_key", "child_value")
|
||||
tag("child_only", "child")
|
||||
|
||||
tags = get_tags()
|
||||
# Child value should win for shared key
|
||||
assert tags["shared_key"] == "child_value"
|
||||
# Both parent and child tags should be present
|
||||
assert tags["parent_only"] == "parent"
|
||||
assert tags["child_only"] == "child"
|
||||
|
||||
# Parent context should be unchanged
|
||||
parent_tags = get_tags()
|
||||
assert parent_tags["shared_key"] == "parent_value"
|
||||
assert parent_tags["parent_only"] == "parent"
|
||||
assert "child_only" not in parent_tags
|
||||
|
||||
def test_scoped_decorator_with_context_ids(self):
|
||||
@scoped()
|
||||
def function_with_context():
|
||||
|
||||
@@ -32,3 +32,421 @@ def test_excepthook(tmpdir):
|
||||
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
|
||||
in output
|
||||
)
|
||||
|
||||
|
||||
def test_code_variables_capture(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
from posthog import Posthog
|
||||
|
||||
class UnserializableObject:
|
||||
pass
|
||||
|
||||
posthog = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=True,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def trigger_error():
|
||||
my_string = "hello world"
|
||||
my_number = 42
|
||||
my_bool = True
|
||||
my_dict = {"name": "test", "value": 123}
|
||||
my_sensitive_dict = {
|
||||
"safe_key": "safe_value",
|
||||
"password": "secret123", # key matches pattern -> should be masked
|
||||
"other_key": "contains_password_here", # value matches pattern -> should be masked
|
||||
}
|
||||
my_nested_dict = {
|
||||
"level1": {
|
||||
"level2": {
|
||||
"api_key": "nested_secret", # deeply nested key matches
|
||||
"data": "contains_token_here", # deeply nested value matches
|
||||
"safe": "visible",
|
||||
}
|
||||
}
|
||||
}
|
||||
my_list = ["safe_item", "has_password_inside", "another_safe"]
|
||||
my_tuple = ("tuple_safe", "secret_in_value", "tuple_also_safe")
|
||||
my_list_of_dicts = [
|
||||
{"id": 1, "password": "list_dict_secret"},
|
||||
{"id": 2, "value": "safe_value"},
|
||||
]
|
||||
my_obj = UnserializableObject()
|
||||
my_password = "secret123" # Should be masked by default (name matches)
|
||||
my_innocent_var = "contains_password_here" # Should be masked by default (value matches)
|
||||
__should_be_ignored = "hidden" # Should be ignored by default
|
||||
|
||||
1/0 # Trigger exception
|
||||
|
||||
def intermediate_function():
|
||||
request_id = "abc-123"
|
||||
user_count = 100
|
||||
is_active = True
|
||||
|
||||
trigger_error()
|
||||
|
||||
def process_data():
|
||||
batch_size = 50
|
||||
retry_count = 3
|
||||
|
||||
intermediate_function()
|
||||
|
||||
process_data()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output
|
||||
|
||||
assert b"ZeroDivisionError" in output
|
||||
assert b"code_variables" in output
|
||||
|
||||
# Variables from trigger_error frame
|
||||
assert b"'my_string': 'hello world'" in output
|
||||
assert b"'my_number': 42" in output
|
||||
assert b"'my_bool': 'True'" in output
|
||||
assert b'"my_dict": "{\\"name\\": \\"test\\", \\"value\\": 123}"' in output
|
||||
assert (
|
||||
b'{\\"safe_key\\": \\"safe_value\\", \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"other_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'{\\"level1\\": {\\"level2\\": {\\"api_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"data\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"safe\\": \\"visible\\"}}}'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'[\\"safe_item\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"another_safe\\"]'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'[\\"tuple_safe\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"tuple_also_safe\\"]'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'[{\\"id\\": 1, \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}, {\\"id\\": 2, \\"value\\": \\"safe_value\\"}]'
|
||||
in output
|
||||
)
|
||||
assert b"<__main__.UnserializableObject object at" in output
|
||||
assert b"'my_password': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
|
||||
assert (
|
||||
b"'my_innocent_var': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
|
||||
)
|
||||
assert b"'__should_be_ignored':" not in output
|
||||
|
||||
# Variables from intermediate_function frame
|
||||
assert b"'request_id': 'abc-123'" in output
|
||||
assert b"'user_count': 100" in output
|
||||
assert b"'is_active': 'True'" in output
|
||||
|
||||
# Variables from process_data frame
|
||||
assert b"'batch_size': 50" in output
|
||||
assert b"'retry_count': 3" in output
|
||||
|
||||
|
||||
def test_code_variables_context_override(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
import posthog
|
||||
from posthog import Posthog
|
||||
|
||||
posthog_client = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=False,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def process_data():
|
||||
bank = "should_be_masked"
|
||||
__dunder_var = "should_be_visible"
|
||||
|
||||
1/0
|
||||
|
||||
with posthog.new_context(client=posthog_client):
|
||||
posthog.set_capture_exception_code_variables_context(True)
|
||||
posthog.set_code_variables_mask_patterns_context([r"(?i).*bank.*"])
|
||||
posthog.set_code_variables_ignore_patterns_context([])
|
||||
|
||||
process_data()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output
|
||||
|
||||
assert b"ZeroDivisionError" in output
|
||||
assert b"code_variables" in output
|
||||
assert b"'bank': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
|
||||
assert b"'__dunder_var': 'should_be_visible'" in output
|
||||
|
||||
|
||||
def test_code_variables_size_limiter(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
from posthog import Posthog
|
||||
|
||||
posthog = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=True,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def trigger_error():
|
||||
var_a = "a" * 2000
|
||||
var_b = "b" * 2000
|
||||
var_c = "c" * 2000
|
||||
var_d = "d" * 2000
|
||||
var_e = "e" * 2000
|
||||
var_f = "f" * 2000
|
||||
var_g = "g" * 2000
|
||||
|
||||
1/0
|
||||
|
||||
def intermediate_function():
|
||||
var_h = "h" * 2000
|
||||
var_i = "i" * 2000
|
||||
var_j = "j" * 2000
|
||||
var_k = "k" * 2000
|
||||
var_l = "l" * 2000
|
||||
var_m = "m" * 2000
|
||||
var_n = "n" * 2000
|
||||
|
||||
trigger_error()
|
||||
|
||||
def process_data():
|
||||
var_o = "o" * 2000
|
||||
var_p = "p" * 2000
|
||||
var_q = "q" * 2000
|
||||
var_r = "r" * 2000
|
||||
var_s = "s" * 2000
|
||||
var_t = "t" * 2000
|
||||
var_u = "u" * 2000
|
||||
|
||||
intermediate_function()
|
||||
|
||||
process_data()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output.decode("utf-8")
|
||||
|
||||
assert "ZeroDivisionError" in output
|
||||
assert "code_variables" in output
|
||||
|
||||
captured_vars = []
|
||||
for var_name in [
|
||||
"var_a",
|
||||
"var_b",
|
||||
"var_c",
|
||||
"var_d",
|
||||
"var_e",
|
||||
"var_f",
|
||||
"var_g",
|
||||
"var_h",
|
||||
"var_i",
|
||||
"var_j",
|
||||
"var_k",
|
||||
"var_l",
|
||||
"var_m",
|
||||
"var_n",
|
||||
"var_o",
|
||||
"var_p",
|
||||
"var_q",
|
||||
"var_r",
|
||||
"var_s",
|
||||
"var_t",
|
||||
"var_u",
|
||||
]:
|
||||
if f"'{var_name}'" in output:
|
||||
captured_vars.append(var_name)
|
||||
|
||||
assert len(captured_vars) > 0
|
||||
assert len(captured_vars) < 21
|
||||
|
||||
|
||||
def test_code_variables_disabled_capture(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
from posthog import Posthog
|
||||
|
||||
posthog = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=False,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def trigger_error():
|
||||
my_string = "hello world"
|
||||
my_number = 42
|
||||
my_bool = True
|
||||
|
||||
1/0
|
||||
|
||||
trigger_error()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output.decode("utf-8")
|
||||
|
||||
assert "ZeroDivisionError" in output
|
||||
assert "'code_variables':" not in output
|
||||
assert '"code_variables":' not in output
|
||||
assert "'my_string'" not in output
|
||||
assert "'my_number'" not in output
|
||||
|
||||
|
||||
def test_code_variables_enabled_then_disabled_in_context(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
import posthog
|
||||
from posthog import Posthog
|
||||
|
||||
posthog_client = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=True,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def process_data():
|
||||
my_var = "should not be captured"
|
||||
important_value = 123
|
||||
|
||||
1/0
|
||||
|
||||
with posthog.new_context(client=posthog_client):
|
||||
posthog.set_capture_exception_code_variables_context(False)
|
||||
|
||||
process_data()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output.decode("utf-8")
|
||||
|
||||
assert "ZeroDivisionError" in output
|
||||
assert "'code_variables':" not in output
|
||||
assert '"code_variables":' not in output
|
||||
assert "'my_var'" not in output
|
||||
assert "'important_value'" not in output
|
||||
|
||||
|
||||
def test_code_variables_repr_fallback(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from posthog import Posthog
|
||||
|
||||
class CustomReprClass:
|
||||
def __repr__(self):
|
||||
return '<CustomReprClass: custom representation>'
|
||||
|
||||
posthog = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=True,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def trigger_error():
|
||||
my_regex = re.compile(r'\\d+')
|
||||
my_datetime = datetime(2024, 1, 15, 10, 30, 45)
|
||||
my_timedelta = timedelta(days=5, hours=3)
|
||||
my_decimal = Decimal('123.456')
|
||||
my_fraction = Fraction(3, 4)
|
||||
my_set = {1, 2, 3}
|
||||
my_frozenset = frozenset([4, 5, 6])
|
||||
my_bytes = b'hello bytes'
|
||||
my_bytearray = bytearray(b'mutable bytes')
|
||||
my_memoryview = memoryview(b'memory view')
|
||||
my_complex = complex(3, 4)
|
||||
my_range = range(10)
|
||||
my_custom = CustomReprClass()
|
||||
my_lambda = lambda x: x * 2
|
||||
my_function = trigger_error
|
||||
|
||||
1/0
|
||||
|
||||
trigger_error()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output.decode("utf-8")
|
||||
|
||||
assert "ZeroDivisionError" in output
|
||||
assert "code_variables" in output
|
||||
|
||||
assert "re.compile(" in output and "\\\\d+" in output
|
||||
assert "datetime.datetime(2024, 1, 15, 10, 30, 45)" in output
|
||||
assert "datetime.timedelta(days=5, seconds=10800)" in output
|
||||
assert "Decimal('123.456')" in output
|
||||
assert "Fraction(3, 4)" in output
|
||||
assert "{1, 2, 3}" in output
|
||||
assert "frozenset({4, 5, 6})" in output
|
||||
assert "b'hello bytes'" in output
|
||||
assert "bytearray(b'mutable bytes')" in output
|
||||
assert "<memory at" in output
|
||||
assert "(3+4j)" in output
|
||||
assert "range(0, 10)" in output
|
||||
assert "<CustomReprClass: custom representation>" in output
|
||||
assert "<lambda>" in output
|
||||
assert "<function trigger_error at" in output
|
||||
|
||||
@@ -4,7 +4,13 @@ import mock
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason
|
||||
from posthog.types import (
|
||||
FeatureFlag,
|
||||
FeatureFlagError,
|
||||
FeatureFlagResult,
|
||||
FlagMetadata,
|
||||
FlagReason,
|
||||
)
|
||||
|
||||
|
||||
class TestFeatureFlagResult(unittest.TestCase):
|
||||
@@ -189,7 +195,6 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
print("FAIL", e, batch) # noqa: T201
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
@@ -241,6 +246,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture):
|
||||
@@ -295,6 +303,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
another_flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "another-distinct-id", person_properties={"region": "USA"}
|
||||
@@ -360,6 +371,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
@@ -403,6 +417,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
@@ -438,6 +455,428 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/no-person-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.FLAG_MISSING,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_with_errors_while_computing_flags(
|
||||
self, patch_capture, patch_flags
|
||||
):
|
||||
"""Test that errors_while_computing_flags is included in the $feature_flag_called event.
|
||||
|
||||
When the server returns errorsWhileComputingFlags=true, it indicates that there
|
||||
was an error computing one or more flags. We include this in the event so users
|
||||
can identify and debug flag evaluation issues.
|
||||
"""
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"my-flag": {
|
||||
"key": "my-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {"description": "Matched condition set 1"},
|
||||
"metadata": {"id": 1, "version": 1, "payload": None},
|
||||
},
|
||||
},
|
||||
"requestId": "test-request-id-789",
|
||||
"errorsWhileComputingFlags": True,
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": True,
|
||||
"$feature_flag_request_id": "test-request-id-789",
|
||||
"$feature_flag_reason": "Matched condition set 1",
|
||||
"$feature_flag_id": 1,
|
||||
"$feature_flag_version": 1,
|
||||
"$feature_flag_error": FeatureFlagError.ERRORS_WHILE_COMPUTING,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_flag_not_in_response(
|
||||
self, patch_capture, patch_flags
|
||||
):
|
||||
"""Test that when a flag is not in the API response, we capture flag_missing error.
|
||||
|
||||
This happens when a flag doesn't exist or the user doesn't match any conditions.
|
||||
"""
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"other-flag": {
|
||||
"key": "other-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {"description": "Matched condition set 1"},
|
||||
"metadata": {"id": 1, "version": 1, "payload": None},
|
||||
},
|
||||
},
|
||||
"requestId": "test-request-id-456",
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"missing-flag", "some-distinct-id"
|
||||
)
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "missing-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/missing-flag": None,
|
||||
"$feature_flag_request_id": "test-request-id-456",
|
||||
"$feature_flag_error": FeatureFlagError.FLAG_MISSING,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_errors_computing_and_flag_missing(
|
||||
self, patch_capture, patch_flags
|
||||
):
|
||||
"""Test that both errors are reported when errorsWhileComputingFlags=true AND flag is missing.
|
||||
|
||||
This can happen when the server encounters errors computing flags AND the requested
|
||||
flag is not in the response. Both conditions should be reported for debugging.
|
||||
"""
|
||||
patch_flags.return_value = {
|
||||
"flags": {}, # Flag is missing
|
||||
"requestId": "test-request-id-999",
|
||||
"errorsWhileComputingFlags": True, # But errors also occurred
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"missing-flag", "some-distinct-id"
|
||||
)
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "missing-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/missing-flag": None,
|
||||
"$feature_flag_request_id": "test-request-id-999",
|
||||
"$feature_flag_error": f"{FeatureFlagError.ERRORS_WHILE_COMPUTING},{FeatureFlagError.FLAG_MISSING}",
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_unknown_error(self, patch_capture, patch_flags):
|
||||
"""Test that unexpected exceptions are captured as unknown_error."""
|
||||
patch_flags.side_effect = Exception("Unexpected error")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.UNKNOWN_ERROR,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_timeout_error(self, patch_capture, patch_flags):
|
||||
"""Test that timeout errors are captured specifically."""
|
||||
from posthog.request import RequestsTimeout
|
||||
|
||||
patch_flags.side_effect = RequestsTimeout("Request timed out")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.TIMEOUT,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_connection_error(self, patch_capture, patch_flags):
|
||||
"""Test that connection errors are captured specifically."""
|
||||
from posthog.request import RequestsConnectionError
|
||||
|
||||
patch_flags.side_effect = RequestsConnectionError("Connection refused")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.CONNECTION_ERROR,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_api_error(self, patch_capture, patch_flags):
|
||||
"""Test that API errors include the status code."""
|
||||
from posthog.request import APIError
|
||||
|
||||
patch_flags.side_effect = APIError(500, "Internal server error")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.api_error(500),
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_quota_limited(self, patch_capture, patch_flags):
|
||||
"""Test that quota limit errors are captured specifically."""
|
||||
from posthog.request import QuotaLimitError
|
||||
|
||||
patch_flags.side_effect = QuotaLimitError(429, "Rate limit exceeded")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.QUOTA_LIMITED,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
|
||||
class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
|
||||
"""Tests for stale cache fallback behavior when flag evaluation fails.
|
||||
|
||||
When the PostHog API is unavailable (timeout, connection error, etc.), the SDK
|
||||
falls back to stale cached flag values if available. These tests verify that:
|
||||
1. The stale cached value is returned when an error occurs
|
||||
2. The $feature_flag_error property is still set (for debugging)
|
||||
3. The response reflects the cached value, not None
|
||||
"""
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
# Create client with memory-based flag cache enabled
|
||||
self.client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
flag_fallback_cache_url="memory://local/?ttl=300&size=10000",
|
||||
)
|
||||
|
||||
def _populate_stale_cache(self, distinct_id, flag_key, flag_result):
|
||||
"""Pre-populate the flag cache with a value that will be used for stale fallback."""
|
||||
self.client.flag_cache.set_cached_flag(
|
||||
distinct_id,
|
||||
flag_key,
|
||||
flag_result,
|
||||
flag_definition_version=self.client.flag_definition_version,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_timeout_error_returns_stale_cached_value(self, patch_capture, patch_flags):
|
||||
"""Test that timeout errors return stale cached value when available."""
|
||||
from posthog.request import RequestsTimeout
|
||||
|
||||
# Pre-populate cache with a flag result
|
||||
cached_result = FeatureFlagResult.from_value_and_payload(
|
||||
"my-flag", "cached-variant", '{"from": "cache"}'
|
||||
)
|
||||
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
|
||||
|
||||
# Simulate timeout error
|
||||
patch_flags.side_effect = RequestsTimeout("Request timed out")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
# Should return the stale cached value
|
||||
self.assertIsNotNone(flag_result)
|
||||
self.assertEqual(flag_result.variant, "cached-variant")
|
||||
self.assertEqual(flag_result.payload, {"from": "cache"})
|
||||
|
||||
# Error should still be tracked for debugging
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": "cached-variant",
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": "cached-variant",
|
||||
"$feature_flag_payload": {"from": "cache"},
|
||||
"$feature_flag_error": FeatureFlagError.TIMEOUT,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_connection_error_returns_stale_cached_value(
|
||||
self, patch_capture, patch_flags
|
||||
):
|
||||
"""Test that connection errors return stale cached value when available."""
|
||||
from posthog.request import RequestsConnectionError
|
||||
|
||||
# Pre-populate cache with a boolean flag result
|
||||
cached_result = FeatureFlagResult.from_value_and_payload("my-flag", True, None)
|
||||
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
|
||||
|
||||
# Simulate connection error
|
||||
patch_flags.side_effect = RequestsConnectionError("Connection refused")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
# Should return the stale cached value
|
||||
self.assertIsNotNone(flag_result)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertIsNone(flag_result.variant)
|
||||
|
||||
# Error should still be tracked
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": True,
|
||||
"$feature_flag_error": FeatureFlagError.CONNECTION_ERROR,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_api_error_returns_stale_cached_value(self, patch_capture, patch_flags):
|
||||
"""Test that API errors return stale cached value when available."""
|
||||
from posthog.request import APIError
|
||||
|
||||
# Pre-populate cache
|
||||
cached_result = FeatureFlagResult.from_value_and_payload(
|
||||
"my-flag", "control", None
|
||||
)
|
||||
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
|
||||
|
||||
# Simulate API error
|
||||
patch_flags.side_effect = APIError(503, "Service unavailable")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
# Should return the stale cached value
|
||||
self.assertIsNotNone(flag_result)
|
||||
self.assertEqual(flag_result.variant, "control")
|
||||
|
||||
# Error should still be tracked with status code
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": "control",
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": "control",
|
||||
"$feature_flag_error": FeatureFlagError.api_error(503),
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_error_without_cache_returns_none(self, patch_capture, patch_flags):
|
||||
"""Test that errors return None when no stale cache is available."""
|
||||
from posthog.request import RequestsTimeout
|
||||
|
||||
# Do NOT populate cache - no fallback available
|
||||
|
||||
patch_flags.side_effect = RequestsTimeout("Request timed out")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
# Should return None since no cache available
|
||||
self.assertIsNone(flag_result)
|
||||
|
||||
# Error should still be tracked
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.TIMEOUT,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
|
||||
+308
-104
@@ -11,7 +11,7 @@ from posthog.feature_flags import (
|
||||
match_property,
|
||||
relative_date_parse_for_feature_flag_matching,
|
||||
)
|
||||
from posthog.request import APIError
|
||||
from posthog.request import APIError, GetResponse
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
|
||||
|
||||
@@ -2348,23 +2348,27 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags(self, patch_get, patch_poll):
|
||||
patch_get.return_value = {
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"active": True,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Alpha Feature",
|
||||
"key": "alpha-feature",
|
||||
"active": False,
|
||||
},
|
||||
],
|
||||
"group_type_mapping": {"0": "company"},
|
||||
}
|
||||
patch_get.return_value = GetResponse(
|
||||
data={
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"active": True,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Alpha Feature",
|
||||
"key": "alpha-feature",
|
||||
"active": False,
|
||||
},
|
||||
],
|
||||
"group_type_mapping": {"0": "company"},
|
||||
"cohorts": {},
|
||||
},
|
||||
etag='"abc123"',
|
||||
)
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
with freeze_time("2020-01-01T12:01:00.0000Z"):
|
||||
client.load_feature_flags()
|
||||
@@ -2375,6 +2379,139 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
client._last_feature_flag_poll.isoformat(), "2020-01-01T12:01:00+00:00"
|
||||
)
|
||||
self.assertEqual(patch_poll.call_count, 1)
|
||||
# Verify ETag is stored
|
||||
self.assertEqual(client._flags_etag, '"abc123"')
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags_sends_etag_on_subsequent_requests(
|
||||
self, patch_get, patch_poll
|
||||
):
|
||||
"""Test that the ETag is sent in If-None-Match header on subsequent requests"""
|
||||
patch_get.return_value = GetResponse(
|
||||
data={
|
||||
"flags": [{"id": 1, "key": "beta-feature", "active": True}],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
},
|
||||
etag='"initial-etag"',
|
||||
)
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.load_feature_flags()
|
||||
|
||||
# First call should have no etag
|
||||
first_call_kwargs = patch_get.call_args_list[0][1]
|
||||
self.assertIsNone(first_call_kwargs.get("etag"))
|
||||
|
||||
# Simulate second call
|
||||
client._load_feature_flags()
|
||||
|
||||
# Second call should have the etag
|
||||
second_call_kwargs = patch_get.call_args_list[1][1]
|
||||
self.assertEqual(second_call_kwargs.get("etag"), '"initial-etag"')
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags_304_not_modified(self, patch_get, patch_poll):
|
||||
"""Test that 304 Not Modified responses skip flag processing"""
|
||||
# First response with flags
|
||||
initial_response = GetResponse(
|
||||
data={
|
||||
"flags": [{"id": 1, "key": "beta-feature", "active": True}],
|
||||
"group_type_mapping": {"0": "company"},
|
||||
"cohorts": {},
|
||||
},
|
||||
etag='"test-etag"',
|
||||
)
|
||||
# Second response is 304 Not Modified
|
||||
not_modified_response = GetResponse(
|
||||
data=None,
|
||||
etag='"test-etag"',
|
||||
not_modified=True,
|
||||
)
|
||||
patch_get.side_effect = [initial_response, not_modified_response]
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.load_feature_flags()
|
||||
|
||||
# Verify initial flags are loaded
|
||||
self.assertEqual(len(client.feature_flags), 1)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
|
||||
self.assertEqual(client.group_type_mapping, {"0": "company"})
|
||||
|
||||
# Second call with 304
|
||||
client._load_feature_flags()
|
||||
|
||||
# Flags should still be the same (not cleared)
|
||||
self.assertEqual(len(client.feature_flags), 1)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
|
||||
self.assertEqual(client.group_type_mapping, {"0": "company"})
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags_etag_updated_on_new_response(
|
||||
self, patch_get, patch_poll
|
||||
):
|
||||
"""Test that ETag is updated when flags change"""
|
||||
patch_get.side_effect = [
|
||||
GetResponse(
|
||||
data={
|
||||
"flags": [{"id": 1, "key": "flag-v1", "active": True}],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
},
|
||||
etag='"etag-v1"',
|
||||
),
|
||||
GetResponse(
|
||||
data={
|
||||
"flags": [{"id": 1, "key": "flag-v2", "active": True}],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
},
|
||||
etag='"etag-v2"',
|
||||
),
|
||||
]
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.load_feature_flags()
|
||||
self.assertEqual(client._flags_etag, '"etag-v1"')
|
||||
|
||||
client._load_feature_flags()
|
||||
self.assertEqual(client._flags_etag, '"etag-v2"')
|
||||
self.assertEqual(client.feature_flags[0]["key"], "flag-v2")
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags_clears_etag_when_server_stops_sending(
|
||||
self, patch_get, patch_poll
|
||||
):
|
||||
"""Test that ETag is cleared when server stops sending it"""
|
||||
patch_get.side_effect = [
|
||||
GetResponse(
|
||||
data={
|
||||
"flags": [{"id": 1, "key": "flag-v1", "active": True}],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
},
|
||||
etag='"etag-v1"',
|
||||
),
|
||||
GetResponse(
|
||||
data={
|
||||
"flags": [{"id": 1, "key": "flag-v2", "active": True}],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
},
|
||||
etag=None, # Server stopped sending ETag
|
||||
),
|
||||
]
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.load_feature_flags()
|
||||
self.assertEqual(client._flags_etag, '"etag-v1"')
|
||||
|
||||
client._load_feature_flags()
|
||||
self.assertIsNone(client._flags_etag)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "flag-v2")
|
||||
|
||||
def test_load_feature_flags_wrong_key(self):
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
@@ -2804,73 +2941,61 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_flag_with_multiple_variant_overrides(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
def test_conditions_evaluated_in_order(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"order-test": "server-variant"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"name": "Order Test Flag",
|
||||
"key": "order-test",
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"rollout_percentage": 100,
|
||||
# The override applies even if the first condition matches all and gives everyone their default group
|
||||
},
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "email",
|
||||
"type": "person",
|
||||
"value": "test@posthog.com",
|
||||
"operator": "exact",
|
||||
"value": "@vip.com",
|
||||
"operator": "icontains",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
"variant": "second-variant",
|
||||
"variant": "vip-variant",
|
||||
},
|
||||
{"rollout_percentage": 50, "variant": "third-variant"},
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{
|
||||
"key": "first-variant",
|
||||
"name": "First Variant",
|
||||
"rollout_percentage": 50,
|
||||
"key": "control",
|
||||
"name": "Control",
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
{
|
||||
"key": "second-variant",
|
||||
"name": "Second Variant",
|
||||
"rollout_percentage": 25,
|
||||
},
|
||||
{
|
||||
"key": "third-variant",
|
||||
"name": "Third Variant",
|
||||
"rollout_percentage": 25,
|
||||
"key": "vip-variant",
|
||||
"name": "VIP Variant",
|
||||
"rollout_percentage": 0,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
self.assertEqual(
|
||||
client.get_feature_flag(
|
||||
"beta-feature",
|
||||
"test_id",
|
||||
person_properties={"email": "test@posthog.com"},
|
||||
),
|
||||
"second-variant",
|
||||
|
||||
# Even though user@vip.com would match the second condition with variant override,
|
||||
# they should match the first condition and get control
|
||||
result = client.get_feature_flag(
|
||||
"order-test",
|
||||
"user123",
|
||||
person_properties={"email": "user@vip.com"},
|
||||
)
|
||||
self.assertEqual(
|
||||
client.get_feature_flag("beta-feature", "example_id"), "third-variant"
|
||||
)
|
||||
self.assertEqual(
|
||||
client.get_feature_flag("beta-feature", "another_id"), "second-variant"
|
||||
)
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(result, "control")
|
||||
|
||||
# server not called because this can be evaluated locally
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@@ -2937,6 +3062,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"some-distinct-id",
|
||||
match_value=True,
|
||||
person_properties={"region": "USA"},
|
||||
send_feature_flag_events=True,
|
||||
),
|
||||
300,
|
||||
)
|
||||
@@ -3025,6 +3151,75 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_fallback_to_api_when_flag_has_static_cohort_in_multi_condition(
|
||||
self, patch_get, patch_flags
|
||||
):
|
||||
"""
|
||||
When a flag has multiple conditions and one contains a static cohort,
|
||||
the SDK should fallback to API for the entire flag, not just skip that
|
||||
condition and evaluate the next one locally.
|
||||
|
||||
This prevents returning wrong variants when later conditions could match
|
||||
locally but the user is actually in the static cohort.
|
||||
"""
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
|
||||
# Mock the local flags response - cohort 999 is NOT in cohorts map (static cohort)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "multi-condition-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{"key": "id", "value": 999, "type": "cohort"}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
"variant": "set-1",
|
||||
},
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "$geoip_country_code",
|
||||
"operator": "exact",
|
||||
"value": ["DE"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
"variant": "set-8",
|
||||
},
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{"key": "set-1", "rollout_percentage": 50},
|
||||
{"key": "set-8", "rollout_percentage": 50},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
client.cohorts = {} # Note: cohort 999 is NOT here - it's a static cohort
|
||||
|
||||
# Mock the API response - user is in the static cohort
|
||||
patch_flags.return_value = {"featureFlags": {"multi-condition-flag": "set-1"}}
|
||||
|
||||
result = client.get_feature_flag(
|
||||
"multi-condition-flag",
|
||||
"test-distinct-id",
|
||||
person_properties={"$geoip_country_code": "DE"},
|
||||
)
|
||||
|
||||
# Should return the API result (set-1), not local evaluation (set-8)
|
||||
self.assertEqual(result, "set-1")
|
||||
|
||||
# Verify API was called (fallback occurred)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
|
||||
class TestMatchProperties(unittest.TestCase):
|
||||
def property(self, key, value, operator=None):
|
||||
@@ -3802,6 +3997,7 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
},
|
||||
},
|
||||
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||||
"evaluatedAt": 1234567890,
|
||||
}
|
||||
client = Client(FAKE_TEST_API_KEY)
|
||||
|
||||
@@ -3821,6 +4017,7 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"$feature_flag_id": 23,
|
||||
"$feature_flag_version": 42,
|
||||
"$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||||
"$feature_flag_evaluated_at": 1234567890,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
@@ -3855,7 +4052,9 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
client.get_feature_flag_payload(
|
||||
"decide-flag-with-payload", "some-distinct-id"
|
||||
"decide-flag-with-payload",
|
||||
"some-distinct-id",
|
||||
send_feature_flag_events=True,
|
||||
),
|
||||
{"foo": "bar"},
|
||||
)
|
||||
@@ -3931,9 +4130,10 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_is_called_in_get_feature_flag_payload(
|
||||
def test_get_feature_flag_payload_does_not_send_feature_flag_called_events(
|
||||
self, patch_flags, patch_capture
|
||||
):
|
||||
"""Test that get_feature_flag_payload does NOT send $feature_flag_called events"""
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"person-flag": True},
|
||||
"featureFlagPayloads": {"person-flag": 300},
|
||||
@@ -3955,68 +4155,72 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"payloads": {"true": '"payload"'},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Call get_feature_flag_payload with match_value=None to trigger get_feature_flag
|
||||
client.get_feature_flag_payload(
|
||||
payload = client.get_feature_flag_payload(
|
||||
key="person-flag",
|
||||
distinct_id="some-distinct-id",
|
||||
person_properties={"region": "USA", "name": "Aloha"},
|
||||
)
|
||||
|
||||
# Assert that capture was called once, with the correct parameters
|
||||
self.assertEqual(patch_capture.call_count, 1)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": True,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
# Reset mocks for further tests
|
||||
patch_capture.reset_mock()
|
||||
patch_flags.reset_mock()
|
||||
|
||||
# Call get_feature_flag_payload again for the same user; capture should not be called again because we've already reported an event for this distinct_id + flag
|
||||
client.get_feature_flag_payload(
|
||||
key="person-flag",
|
||||
distinct_id="some-distinct-id",
|
||||
person_properties={"region": "USA", "name": "Aloha"},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(payload)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
patch_capture.reset_mock()
|
||||
|
||||
# Call get_feature_flag_payload for a different user; capture should be called
|
||||
client.get_feature_flag_payload(
|
||||
key="person-flag",
|
||||
distinct_id="some-distinct-id2",
|
||||
person_properties={"region": "USA", "name": "Aloha"},
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_fallback_to_api_in_get_feature_flag_payload_when_flag_has_static_cohort(
|
||||
self, patch_flags
|
||||
):
|
||||
"""
|
||||
Test that get_feature_flag_payload falls back to API when evaluating
|
||||
a flag with static cohorts, similar to get_feature_flag behavior.
|
||||
"""
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
|
||||
# Mock the local flags response - cohort 999 is NOT in cohorts map (static cohort)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Multi-condition Flag",
|
||||
"key": "multi-condition-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{"key": "id", "value": 999, "type": "cohort"}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
"variant": "variant-1",
|
||||
}
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [{"key": "variant-1", "rollout_percentage": 100}]
|
||||
},
|
||||
"payloads": {"variant-1": '{"message": "local-payload"}'},
|
||||
},
|
||||
}
|
||||
]
|
||||
client.cohorts = {} # Note: cohort 999 is NOT here - it's a static cohort
|
||||
|
||||
# Mock the API response - user is in the static cohort
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"multi-condition-flag": "variant-1"},
|
||||
"featureFlagPayloads": {"multi-condition-flag": '{"message": "from-api"}'},
|
||||
}
|
||||
|
||||
# Call get_feature_flag_payload without match_value to trigger evaluation
|
||||
result = client.get_feature_flag_payload(
|
||||
"multi-condition-flag",
|
||||
"test-distinct-id",
|
||||
)
|
||||
|
||||
self.assertEqual(patch_capture.call_count, 1)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id2",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": True,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
# Should return the API payload, not local payload
|
||||
self.assertEqual(result, {"message": "from-api"})
|
||||
|
||||
patch_capture.reset_mock()
|
||||
# Verify API was called (fallback occurred)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.flags")
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
"""
|
||||
Tests for FlagDefinitionCacheProvider functionality.
|
||||
|
||||
These tests follow the patterns from the TypeScript implementation in posthog-js/packages/node.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import unittest
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.flag_definition_cache import (
|
||||
FlagDefinitionCacheData,
|
||||
FlagDefinitionCacheProvider,
|
||||
)
|
||||
from posthog.request import GetResponse
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
|
||||
|
||||
class MockCacheProvider:
|
||||
"""A mock implementation of FlagDefinitionCacheProvider for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.stored_data: Optional[FlagDefinitionCacheData] = None
|
||||
self.should_fetch_return_value = True
|
||||
self.get_call_count = 0
|
||||
self.should_fetch_call_count = 0
|
||||
self.on_received_call_count = 0
|
||||
self.shutdown_call_count = 0
|
||||
self.should_fetch_error: Optional[Exception] = None
|
||||
self.get_error: Optional[Exception] = None
|
||||
self.on_received_error: Optional[Exception] = None
|
||||
self.shutdown_error: Optional[Exception] = None
|
||||
|
||||
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
|
||||
self.get_call_count += 1
|
||||
if self.get_error:
|
||||
raise self.get_error
|
||||
return self.stored_data
|
||||
|
||||
def should_fetch_flag_definitions(self) -> bool:
|
||||
self.should_fetch_call_count += 1
|
||||
if self.should_fetch_error:
|
||||
raise self.should_fetch_error
|
||||
return self.should_fetch_return_value
|
||||
|
||||
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
|
||||
self.on_received_call_count += 1
|
||||
if self.on_received_error:
|
||||
raise self.on_received_error
|
||||
self.stored_data = data
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_call_count += 1
|
||||
if self.shutdown_error:
|
||||
raise self.shutdown_error
|
||||
|
||||
|
||||
class TestFlagDefinitionCacheProvider(unittest.TestCase):
|
||||
"""Tests for the FlagDefinitionCacheProvider protocol."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Prevent real HTTP requests
|
||||
cls.client_post_patcher = mock.patch("posthog.client.batch_post")
|
||||
cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post")
|
||||
cls.client_post_patcher.start()
|
||||
cls.consumer_post_patcher.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.client_post_patcher.stop()
|
||||
cls.consumer_post_patcher.stop()
|
||||
|
||||
def setUp(self):
|
||||
self.cache_provider = MockCacheProvider()
|
||||
self.sample_flags_data: FlagDefinitionCacheData = {
|
||||
"flags": [
|
||||
{"key": "test-flag", "active": True, "filters": {}},
|
||||
{"key": "another-flag", "active": False, "filters": {}},
|
||||
],
|
||||
"group_type_mapping": {"0": "company", "1": "project"},
|
||||
"cohorts": {"1": {"properties": []}},
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
# Ensure client cleanup
|
||||
pass
|
||||
|
||||
def _create_client_with_cache(self) -> Client:
|
||||
"""Create a client with the mock cache provider."""
|
||||
return Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
personal_api_key="test-personal-key",
|
||||
flag_definition_cache_provider=self.cache_provider,
|
||||
sync_mode=True,
|
||||
enable_local_evaluation=False, # Disable poller for tests
|
||||
)
|
||||
|
||||
|
||||
class TestCacheInitialization(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for cache initialization behavior."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_uses_cached_data_when_should_fetch_returns_false(self, mock_get):
|
||||
"""When should_fetch returns False and cache has data, use cached data."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = self.sample_flags_data
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should not call API
|
||||
mock_get.assert_not_called()
|
||||
|
||||
# Should have called cache methods
|
||||
self.assertEqual(self.cache_provider.should_fetch_call_count, 1)
|
||||
self.assertEqual(self.cache_provider.get_call_count, 1)
|
||||
|
||||
# Flags should be loaded from cache
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "test-flag")
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_fetches_from_api_when_should_fetch_returns_true(self, mock_get):
|
||||
"""When should_fetch returns True, fetch from API."""
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should call API
|
||||
mock_get.assert_called_once()
|
||||
|
||||
# Should have called should_fetch but not get
|
||||
self.assertEqual(self.cache_provider.should_fetch_call_count, 1)
|
||||
self.assertEqual(self.cache_provider.get_call_count, 0)
|
||||
|
||||
# Should have called on_received to store in cache
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_emergency_fallback_when_cache_empty_and_no_flags(self, mock_get):
|
||||
"""When should_fetch=False but cache is empty and no flags loaded, fetch anyway."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = None # Empty cache
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should call API due to emergency fallback
|
||||
mock_get.assert_called_once()
|
||||
|
||||
# Should have called on_received
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_preserves_existing_flags_when_cache_returns_none(self, mock_get):
|
||||
"""When cache returns None but client has flags, preserve existing flags."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = None # Empty cache
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
|
||||
# Pre-load flags (simulating a previous successful fetch)
|
||||
client.feature_flags = self.sample_flags_data["flags"]
|
||||
client.group_type_mapping = self.sample_flags_data["group_type_mapping"]
|
||||
client.cohorts = self.sample_flags_data["cohorts"]
|
||||
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should NOT call API since we already have flags
|
||||
mock_get.assert_not_called()
|
||||
|
||||
# Existing flags should be preserved
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "test-flag")
|
||||
|
||||
client.join()
|
||||
|
||||
|
||||
class TestFetchCoordination(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for fetch coordination between workers."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_calls_should_fetch_before_each_poll(self, mock_get):
|
||||
"""should_fetch_flag_definitions is called before each poll cycle."""
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
|
||||
# First poll
|
||||
client._load_feature_flags()
|
||||
self.assertEqual(self.cache_provider.should_fetch_call_count, 1)
|
||||
|
||||
# Second poll
|
||||
client._load_feature_flags()
|
||||
self.assertEqual(self.cache_provider.should_fetch_call_count, 2)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_does_not_call_on_received_when_fetch_skipped(self, mock_get):
|
||||
"""on_flag_definitions_received is NOT called when fetch is skipped."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = self.sample_flags_data
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should not call on_received since we didn't fetch
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 0)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_stores_data_in_cache_after_api_fetch(self, mock_get):
|
||||
"""on_flag_definitions_received receives the fetched data."""
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should have stored data in cache
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
self.assertIsNotNone(self.cache_provider.stored_data)
|
||||
self.assertEqual(len(self.cache_provider.stored_data["flags"]), 2)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_304_not_modified_does_not_update_cache(self, mock_get):
|
||||
"""When API returns 304 Not Modified, cache should not be updated."""
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
|
||||
# First fetch to populate flags and ETag
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Verify initial fetch worked
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
|
||||
# Second fetch returns 304 Not Modified
|
||||
mock_get.return_value = GetResponse(
|
||||
data=None, etag="test-etag", not_modified=True
|
||||
)
|
||||
|
||||
client._load_feature_flags()
|
||||
|
||||
# API was called twice
|
||||
self.assertEqual(mock_get.call_count, 2)
|
||||
|
||||
# should_fetch was called twice
|
||||
self.assertEqual(self.cache_provider.should_fetch_call_count, 2)
|
||||
|
||||
# on_received should NOT be called again (304 = no new data)
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
|
||||
# Flags should still be present
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
|
||||
client.join()
|
||||
|
||||
|
||||
class TestErrorHandling(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for error handling in cache provider operations."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_should_fetch_error_defaults_to_fetching(self, mock_get):
|
||||
"""When should_fetch throws an error, default to fetching from API."""
|
||||
self.cache_provider.should_fetch_error = Exception("Lock acquisition failed")
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should still fetch from API
|
||||
mock_get.assert_called_once()
|
||||
|
||||
# Flags should be loaded
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_get_error_falls_back_to_api_fetch(self, mock_get):
|
||||
"""When get_flag_definitions throws an error, fetch from API."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.get_error = Exception("Cache read failed")
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should fall back to API
|
||||
mock_get.assert_called_once()
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_on_received_error_keeps_flags_in_memory(self, mock_get):
|
||||
"""When on_flag_definitions_received throws, flags are still in memory."""
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
self.cache_provider.on_received_error = Exception("Cache write failed")
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Flags should still be loaded in memory despite cache error
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "test-flag")
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_shutdown_error_is_logged_but_continues(self, mock_get):
|
||||
"""When shutdown throws an error, it's logged but shutdown continues."""
|
||||
self.cache_provider.shutdown_error = Exception("Lock release failed")
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should not raise when joining
|
||||
client.join()
|
||||
|
||||
# Shutdown was called
|
||||
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
|
||||
|
||||
|
||||
class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for shutdown lifecycle."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_shutdown_calls_cache_provider_shutdown(self, mock_get):
|
||||
"""Client shutdown calls cache provider shutdown."""
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Shutdown
|
||||
client.join()
|
||||
|
||||
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_shutdown_called_even_without_fetching(self, mock_get):
|
||||
"""Shutdown is called even when cache was used instead of fetching."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = self.sample_flags_data
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
client.join()
|
||||
|
||||
# Shutdown should still be called
|
||||
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_multiple_join_calls_only_shutdown_once(self, mock_get):
|
||||
"""Calling join() multiple times should only call cache provider shutdown once."""
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Call join multiple times
|
||||
client.join()
|
||||
client.join()
|
||||
client.join()
|
||||
|
||||
# Shutdown should be called each time (current behavior - no guard)
|
||||
# This test documents the current behavior
|
||||
self.assertGreaterEqual(self.cache_provider.shutdown_call_count, 1)
|
||||
|
||||
|
||||
class TestBackwardCompatibility(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for backward compatibility without cache provider."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_works_without_cache_provider(self, mock_get):
|
||||
"""Client works normally without a cache provider configured."""
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
# Create client without cache provider
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
personal_api_key="test-personal-key",
|
||||
sync_mode=True,
|
||||
enable_local_evaluation=False,
|
||||
)
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should fetch from API
|
||||
mock_get.assert_called_once()
|
||||
|
||||
# Flags should be loaded
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
|
||||
client.join()
|
||||
|
||||
|
||||
class TestDataIntegrity(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for data integrity between cache and client state."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_cached_flags_available_for_evaluation(self, mock_get):
|
||||
"""Flags loaded from cache are available for local evaluation."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = {
|
||||
"flags": [
|
||||
{
|
||||
"key": "test-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
}
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Flag should be accessible
|
||||
self.assertEqual(len(client.feature_flags), 1)
|
||||
self.assertEqual(client.feature_flags_by_key["test-flag"]["key"], "test-flag")
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_group_type_mapping_loaded_from_cache(self, mock_get):
|
||||
"""Group type mapping is correctly loaded from cache."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = self.sample_flags_data
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
self.assertEqual(client.group_type_mapping["0"], "company")
|
||||
self.assertEqual(client.group_type_mapping["1"], "project")
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_cohorts_loaded_from_cache(self, mock_get):
|
||||
"""Cohorts are correctly loaded from cache."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = self.sample_flags_data
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
self.assertIn("1", client.cohorts)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_cache_updated_when_api_returns_new_data(self, mock_get):
|
||||
"""State transition: cache has old data -> API returns new -> cache updated."""
|
||||
# Start with old cached data
|
||||
old_flags_data: FlagDefinitionCacheData = {
|
||||
"flags": [{"key": "old-flag", "active": True, "filters": {}}],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
}
|
||||
self.cache_provider.stored_data = old_flags_data
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
|
||||
# First load from cache
|
||||
client._load_feature_flags()
|
||||
self.assertEqual(client.feature_flags[0]["key"], "old-flag")
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 0)
|
||||
|
||||
# Now trigger API fetch with new data
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
new_flags_data: FlagDefinitionCacheData = {
|
||||
"flags": [{"key": "new-flag", "active": True, "filters": {}}],
|
||||
"group_type_mapping": {"0": "company"},
|
||||
"cohorts": {"1": {"properties": []}},
|
||||
}
|
||||
mock_get.return_value = GetResponse(
|
||||
data=new_flags_data, etag="new-etag", not_modified=False
|
||||
)
|
||||
|
||||
client._load_feature_flags()
|
||||
|
||||
# Verify new flags loaded
|
||||
self.assertEqual(client.feature_flags[0]["key"], "new-flag")
|
||||
self.assertEqual(client.group_type_mapping["0"], "company")
|
||||
|
||||
# Verify cache was updated
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
self.assertEqual(self.cache_provider.stored_data["flags"][0]["key"], "new-flag")
|
||||
|
||||
client.join()
|
||||
|
||||
|
||||
class TestConcurrency(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for thread safety and concurrent access."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_concurrent_load_feature_flags_is_thread_safe(self, mock_get):
|
||||
"""Multiple threads calling _load_feature_flags should not cause errors."""
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
errors = []
|
||||
|
||||
def load_flags():
|
||||
try:
|
||||
client._load_feature_flags()
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
# Launch 5 threads concurrently
|
||||
threads = [threading.Thread(target=load_flags) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Should complete without errors
|
||||
self.assertEqual(len(errors), 0, f"Unexpected errors: {errors}")
|
||||
|
||||
# Flags should be loaded
|
||||
self.assertIsNotNone(client.feature_flags)
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
|
||||
client.join()
|
||||
|
||||
|
||||
class TestProtocolCompliance(unittest.TestCase):
|
||||
"""Tests for Protocol compliance."""
|
||||
|
||||
def test_mock_provider_is_protocol_instance(self):
|
||||
"""MockCacheProvider satisfies FlagDefinitionCacheProvider protocol."""
|
||||
provider = MockCacheProvider()
|
||||
self.assertIsInstance(provider, FlagDefinitionCacheProvider)
|
||||
|
||||
def test_incomplete_provider_is_not_protocol_instance(self):
|
||||
"""Class missing methods is not a FlagDefinitionCacheProvider."""
|
||||
|
||||
class IncompleteProvider:
|
||||
def get_flag_definitions(self):
|
||||
return None
|
||||
|
||||
provider = IncompleteProvider()
|
||||
self.assertNotIsInstance(provider, FlagDefinitionCacheProvider)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -18,14 +18,6 @@ class TestModule(unittest.TestCase):
|
||||
"testsecret", host="http://localhost:8000", on_error=self.failed
|
||||
)
|
||||
|
||||
def test_no_api_key(self):
|
||||
self.posthog.api_key = None
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_no_host(self):
|
||||
self.posthog.host = None
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_track(self):
|
||||
res = self.posthog.capture("python module event", distinct_id="distinct_id")
|
||||
self._assert_enqueue_result(res)
|
||||
|
||||
@@ -6,16 +6,60 @@ import mock
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
import posthog.request as request_module
|
||||
from posthog.request import (
|
||||
APIError,
|
||||
DatetimeSerializer,
|
||||
GetResponse,
|
||||
KEEP_ALIVE_SOCKET_OPTIONS,
|
||||
QuotaLimitError,
|
||||
_mask_tokens_in_url,
|
||||
batch_post,
|
||||
decide,
|
||||
determine_server_host,
|
||||
disable_connection_reuse,
|
||||
enable_keep_alive,
|
||||
flags,
|
||||
get,
|
||||
set_socket_options,
|
||||
)
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, expected",
|
||||
[
|
||||
# Token with params after - masks keeping first 10 chars
|
||||
(
|
||||
"https://example.com/api/flags?token=phc_abc123xyz789&send_cohorts",
|
||||
"https://example.com/api/flags?token=phc_abc123...&send_cohorts",
|
||||
),
|
||||
# Token at end of URL
|
||||
(
|
||||
"https://example.com/api/flags?token=phc_abc123xyz789",
|
||||
"https://example.com/api/flags?token=phc_abc123...",
|
||||
),
|
||||
# No token - unchanged
|
||||
(
|
||||
"https://example.com/api/flags?other=value",
|
||||
"https://example.com/api/flags?other=value",
|
||||
),
|
||||
# Short token (<10 chars) - unchanged
|
||||
(
|
||||
"https://example.com/api/flags?token=short",
|
||||
"https://example.com/api/flags?token=short",
|
||||
),
|
||||
# Exactly 10 char token - gets ellipsis
|
||||
(
|
||||
"https://example.com/api/flags?token=1234567890",
|
||||
"https://example.com/api/flags?token=1234567890...",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_mask_tokens_in_url(url, expected):
|
||||
assert _mask_tokens_in_url(url) == expected
|
||||
|
||||
|
||||
class TestRequests(unittest.TestCase):
|
||||
def test_valid_request(self):
|
||||
res = batch_post(
|
||||
@@ -107,6 +151,184 @@ class TestRequests(unittest.TestCase):
|
||||
self.assertEqual(response["featureFlags"], {"flag1": True})
|
||||
|
||||
|
||||
class TestGet(unittest.TestCase):
|
||||
"""Unit tests for the get() function HTTP-level behavior."""
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_returns_data_and_etag(self, mock_get):
|
||||
"""Test that get() returns GetResponse with data and etag from headers."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers["ETag"] = '"abc123"'
|
||||
mock_response._content = json.dumps({"flags": [{"key": "test-flag"}]}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = get("api_key", "/test-url", host="https://example.com")
|
||||
|
||||
self.assertIsInstance(response, GetResponse)
|
||||
self.assertEqual(response.data, {"flags": [{"key": "test-flag"}]})
|
||||
self.assertEqual(response.etag, '"abc123"')
|
||||
self.assertFalse(response.not_modified)
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_sends_if_none_match_header_when_etag_provided(self, mock_get):
|
||||
"""Test that If-None-Match header is sent when etag parameter is provided."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers["ETag"] = '"new-etag"'
|
||||
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/test-url", host="https://example.com", etag='"previous-etag"')
|
||||
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
self.assertEqual(call_kwargs["headers"]["If-None-Match"], '"previous-etag"')
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_does_not_send_if_none_match_when_no_etag(self, mock_get):
|
||||
"""Test that If-None-Match header is not sent when no etag provided."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/test-url", host="https://example.com")
|
||||
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
self.assertNotIn("If-None-Match", call_kwargs["headers"])
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_handles_304_not_modified(self, mock_get):
|
||||
"""Test that 304 Not Modified response returns not_modified=True with no data."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 304
|
||||
mock_response.headers["ETag"] = '"unchanged-etag"'
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = get(
|
||||
"api_key", "/test-url", host="https://example.com", etag='"unchanged-etag"'
|
||||
)
|
||||
|
||||
self.assertIsInstance(response, GetResponse)
|
||||
self.assertIsNone(response.data)
|
||||
self.assertEqual(response.etag, '"unchanged-etag"')
|
||||
self.assertTrue(response.not_modified)
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_304_without_etag_header_uses_request_etag(self, mock_get):
|
||||
"""Test that 304 response without ETag header falls back to request etag."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 304
|
||||
# Server doesn't return ETag header on 304
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = get(
|
||||
"api_key", "/test-url", host="https://example.com", etag='"original-etag"'
|
||||
)
|
||||
|
||||
self.assertTrue(response.not_modified)
|
||||
self.assertEqual(response.etag, '"original-etag"')
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_200_without_etag_header(self, mock_get):
|
||||
"""Test that 200 response without ETag header returns None for etag."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
|
||||
# No ETag header
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = get("api_key", "/test-url", host="https://example.com")
|
||||
|
||||
self.assertFalse(response.not_modified)
|
||||
self.assertIsNone(response.etag)
|
||||
self.assertEqual(response.data, {"flags": []})
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_error_response_raises_api_error(self, mock_get):
|
||||
"""Test that error responses raise APIError."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 401
|
||||
mock_response._content = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with self.assertRaises(APIError) as ctx:
|
||||
get("bad_key", "/test-url", host="https://example.com")
|
||||
|
||||
self.assertEqual(ctx.exception.status, 401)
|
||||
self.assertEqual(ctx.exception.message, "Unauthorized")
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_sends_authorization_header(self, mock_get):
|
||||
"""Test that Authorization header is sent with Bearer token."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("my-api-key", "/test-url", host="https://example.com")
|
||||
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer my-api-key")
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_sends_user_agent_header(self, mock_get):
|
||||
"""Test that User-Agent header is sent."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/test-url", host="https://example.com")
|
||||
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
self.assertIn("User-Agent", call_kwargs["headers"])
|
||||
self.assertTrue(
|
||||
call_kwargs["headers"]["User-Agent"].startswith("posthog-python/")
|
||||
)
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_passes_timeout(self, mock_get):
|
||||
"""Test that timeout parameter is passed to the request."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/test-url", host="https://example.com", timeout=30)
|
||||
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
self.assertEqual(call_kwargs["timeout"], 30)
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_constructs_full_url(self, mock_get):
|
||||
"""Test that host and url are combined correctly."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/api/flags", host="https://example.com")
|
||||
|
||||
call_args = mock_get.call_args[0]
|
||||
self.assertEqual(call_args[0], "https://example.com/api/flags")
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_removes_trailing_slash_from_host(self, mock_get):
|
||||
"""Test that trailing slash is removed from host."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/api/flags", host="https://example.com/")
|
||||
|
||||
call_args = mock_get.call_args[0]
|
||||
self.assertEqual(call_args[0], "https://example.com/api/flags")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host, expected",
|
||||
[
|
||||
@@ -128,3 +350,317 @@ class TestRequests(unittest.TestCase):
|
||||
)
|
||||
def test_routing_to_custom_host(host, expected):
|
||||
assert determine_server_host(host) == expected
|
||||
|
||||
|
||||
def test_enable_keep_alive_sets_socket_options():
|
||||
try:
|
||||
enable_keep_alive()
|
||||
from posthog.request import _session
|
||||
|
||||
adapter = _session.get_adapter("https://example.com")
|
||||
assert adapter.socket_options == KEEP_ALIVE_SOCKET_OPTIONS
|
||||
finally:
|
||||
set_socket_options(None)
|
||||
|
||||
|
||||
def test_set_socket_options_clears_with_none():
|
||||
try:
|
||||
enable_keep_alive()
|
||||
set_socket_options(None)
|
||||
from posthog.request import _session
|
||||
|
||||
adapter = _session.get_adapter("https://example.com")
|
||||
assert adapter.socket_options is None
|
||||
finally:
|
||||
set_socket_options(None)
|
||||
|
||||
|
||||
def test_disable_connection_reuse_creates_fresh_sessions():
|
||||
try:
|
||||
disable_connection_reuse()
|
||||
session1 = request_module._get_session()
|
||||
session2 = request_module._get_session()
|
||||
assert session1 is not session2
|
||||
finally:
|
||||
request_module._pooling_enabled = True
|
||||
|
||||
|
||||
def test_set_socket_options_is_idempotent():
|
||||
try:
|
||||
enable_keep_alive()
|
||||
session1 = request_module._session
|
||||
enable_keep_alive()
|
||||
session2 = request_module._session
|
||||
assert session1 is session2
|
||||
finally:
|
||||
set_socket_options(None)
|
||||
|
||||
|
||||
class TestFlagsSession(unittest.TestCase):
|
||||
"""Tests for flags session configuration."""
|
||||
|
||||
def test_retry_status_forcelist_excludes_rate_limits(self):
|
||||
"""Verify 429 (rate limit) is NOT retried - need to wait, not hammer."""
|
||||
from posthog.request import RETRY_STATUS_FORCELIST
|
||||
|
||||
self.assertNotIn(429, RETRY_STATUS_FORCELIST)
|
||||
|
||||
def test_retry_status_forcelist_excludes_quota_errors(self):
|
||||
"""Verify 402 (payment required/quota) is NOT retried - won't resolve."""
|
||||
from posthog.request import RETRY_STATUS_FORCELIST
|
||||
|
||||
self.assertNotIn(402, RETRY_STATUS_FORCELIST)
|
||||
|
||||
@mock.patch("posthog.request._get_flags_session")
|
||||
def test_flags_uses_flags_session(self, mock_get_flags_session):
|
||||
"""flags() uses the dedicated flags session, not the general session."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"featureFlags": {"test-flag": True},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
mock_session = mock.MagicMock()
|
||||
mock_session.post.return_value = mock_response
|
||||
mock_get_flags_session.return_value = mock_session
|
||||
|
||||
result = flags("test-key", "https://test.posthog.com", distinct_id="user123")
|
||||
|
||||
self.assertEqual(result["featureFlags"]["test-flag"], True)
|
||||
mock_get_flags_session.assert_called_once()
|
||||
mock_session.post.assert_called_once()
|
||||
|
||||
@mock.patch("posthog.request._get_flags_session")
|
||||
def test_flags_no_retry_on_quota_limit(self, mock_get_flags_session):
|
||||
"""flags() raises QuotaLimitError without retrying (at application level)."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"quotaLimited": ["feature_flags"],
|
||||
"featureFlags": {},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
mock_session = mock.MagicMock()
|
||||
mock_session.post.return_value = mock_response
|
||||
mock_get_flags_session.return_value = mock_session
|
||||
|
||||
with self.assertRaises(QuotaLimitError):
|
||||
flags("test-key", "https://test.posthog.com", distinct_id="user123")
|
||||
|
||||
# QuotaLimitError is raised after response is received, not retried
|
||||
self.assertEqual(mock_session.post.call_count, 1)
|
||||
|
||||
|
||||
class TestFlagsSessionNetworkRetries(unittest.TestCase):
|
||||
"""Tests for network failure retries in the flags session."""
|
||||
|
||||
def test_flags_session_retry_config_includes_connection_errors(self):
|
||||
"""
|
||||
Verify that the flags session is configured to retry on connection errors.
|
||||
|
||||
The urllib3 Retry adapter with connect=2 and read=2 automatically
|
||||
retries on network-level failures (DNS failures, connection refused,
|
||||
connection reset, etc.) up to 2 times each.
|
||||
"""
|
||||
from posthog.request import _build_flags_session
|
||||
|
||||
session = _build_flags_session()
|
||||
|
||||
# Get the adapter for https://
|
||||
adapter = session.get_adapter("https://test.posthog.com")
|
||||
|
||||
# Verify retry configuration
|
||||
retry = adapter.max_retries
|
||||
self.assertEqual(retry.total, 2, "Should have 2 total retries")
|
||||
self.assertEqual(retry.connect, 2, "Should retry connection errors twice")
|
||||
self.assertEqual(retry.read, 2, "Should retry read errors twice")
|
||||
self.assertIn("POST", retry.allowed_methods, "Should allow POST retries")
|
||||
|
||||
def test_flags_session_retries_on_server_errors(self):
|
||||
"""
|
||||
Verify that transient server errors (5xx) trigger retries.
|
||||
|
||||
This tests the status_forcelist configuration which specifies
|
||||
which HTTP status codes should trigger a retry.
|
||||
"""
|
||||
from posthog.request import _build_flags_session, RETRY_STATUS_FORCELIST
|
||||
|
||||
session = _build_flags_session()
|
||||
adapter = session.get_adapter("https://test.posthog.com")
|
||||
retry = adapter.max_retries
|
||||
|
||||
# Verify the status codes that trigger retries
|
||||
self.assertEqual(
|
||||
set(retry.status_forcelist),
|
||||
set(RETRY_STATUS_FORCELIST),
|
||||
"Should retry on transient server errors",
|
||||
)
|
||||
|
||||
# Verify specific codes are included
|
||||
self.assertIn(500, retry.status_forcelist)
|
||||
self.assertIn(502, retry.status_forcelist)
|
||||
self.assertIn(503, retry.status_forcelist)
|
||||
self.assertIn(504, retry.status_forcelist)
|
||||
|
||||
# Verify rate limits and quota errors are NOT retried
|
||||
self.assertNotIn(429, retry.status_forcelist)
|
||||
self.assertNotIn(402, retry.status_forcelist)
|
||||
|
||||
def test_flags_session_has_backoff(self):
|
||||
"""
|
||||
Verify that retries use exponential backoff to avoid thundering herd.
|
||||
"""
|
||||
from posthog.request import _build_flags_session
|
||||
|
||||
session = _build_flags_session()
|
||||
adapter = session.get_adapter("https://test.posthog.com")
|
||||
retry = adapter.max_retries
|
||||
|
||||
self.assertEqual(
|
||||
retry.backoff_factor,
|
||||
0.5,
|
||||
"Should use 0.5s backoff factor (0.5s, 1s delays)",
|
||||
)
|
||||
|
||||
|
||||
class TestFlagsSessionRetryIntegration(unittest.TestCase):
|
||||
"""Integration tests that verify actual retry behavior with a local server."""
|
||||
|
||||
def test_retries_on_503_then_succeeds(self):
|
||||
"""
|
||||
Verify that 503 errors trigger retries and eventually succeed.
|
||||
|
||||
Uses a local HTTP server that fails twice with 503, then succeeds.
|
||||
This tests the full retry flow including backoff timing.
|
||||
"""
|
||||
import threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib3.util.retry import Retry
|
||||
from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
|
||||
|
||||
request_count = 0
|
||||
|
||||
class RetryTestHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_POST(self):
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
|
||||
# Read and discard request body to prevent connection issues
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
if content_length > 0:
|
||||
self.rfile.read(content_length)
|
||||
|
||||
if request_count <= 2:
|
||||
self.send_response(503)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
body = b'{"error": "Service unavailable"}'
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
body = (
|
||||
b'{"featureFlags": {"test": true}, "featureFlagPayloads": {}}'
|
||||
)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress logging
|
||||
|
||||
# Use ThreadingMixIn for cleaner shutdown
|
||||
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
# Start server on a random available port
|
||||
server = ThreadedHTTPServer(("127.0.0.1", 0), RetryTestHandler)
|
||||
port = server.server_address[1]
|
||||
server_thread = threading.Thread(target=server.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
|
||||
try:
|
||||
# Build session with same retry config as _build_flags_session
|
||||
# but mounted on http:// for local testing
|
||||
adapter = HTTPAdapterWithSocketOptions(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
backoff_factor=0.01, # Fast backoff for testing
|
||||
status_forcelist=RETRY_STATUS_FORCELIST,
|
||||
allowed_methods=["POST"],
|
||||
),
|
||||
)
|
||||
session = requests.Session()
|
||||
session.mount("http://", adapter)
|
||||
|
||||
response = session.post(
|
||||
f"http://127.0.0.1:{port}/flags/?v=2",
|
||||
json={"distinct_id": "user123"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
# Should succeed on 3rd attempt
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(request_count, 3) # 1 initial + 2 retries
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
def test_connection_errors_are_retried(self):
|
||||
"""
|
||||
Verify that connection errors (no server) trigger retries.
|
||||
|
||||
Binds a socket to get a guaranteed available port, then closes it
|
||||
so connection attempts fail with ConnectionError.
|
||||
"""
|
||||
import socket
|
||||
import time
|
||||
from urllib3.util.retry import Retry
|
||||
from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
|
||||
|
||||
# Get an available port by binding then closing a socket
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close() # Port is now available but nothing is listening
|
||||
|
||||
adapter = HTTPAdapterWithSocketOptions(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
backoff_factor=0.05, # Very fast for testing
|
||||
status_forcelist=RETRY_STATUS_FORCELIST,
|
||||
allowed_methods=["POST"],
|
||||
),
|
||||
)
|
||||
session = requests.Session()
|
||||
session.mount("http://", adapter)
|
||||
|
||||
start = time.time()
|
||||
with self.assertRaises(requests.exceptions.ConnectionError):
|
||||
session.post(
|
||||
f"http://127.0.0.1:{port}/flags/?v=2",
|
||||
json={"distinct_id": "user123"},
|
||||
timeout=1,
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# With 3 attempts and backoff, should take more than instant
|
||||
# but less than timeout (confirms retries happened)
|
||||
self.assertGreater(elapsed, 0.05, "Should have some delay from retries")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
@@ -122,7 +123,9 @@ class TestUtils(unittest.TestCase):
|
||||
"bar": 2,
|
||||
"baz": None,
|
||||
}
|
||||
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
|
||||
# Pydantic V1 is not compatible with Python 3.14+
|
||||
if sys.version_info < (3, 14):
|
||||
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
|
||||
assert utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))) == {
|
||||
"foo": {"foo": "1", "bar": 2, "baz": "3"}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ class FlagsResponse(TypedDict, total=False):
|
||||
errorsWhileComputingFlags: bool
|
||||
requestId: str
|
||||
quotaLimit: Optional[List[str]]
|
||||
evaluatedAt: Optional[int]
|
||||
|
||||
|
||||
class FlagsAndPayloads(TypedDict, total=True):
|
||||
@@ -306,3 +307,42 @@ def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
|
||||
and value.enabled
|
||||
and value.metadata.payload is not None
|
||||
}
|
||||
|
||||
|
||||
class FeatureFlagError:
|
||||
"""Error type constants for the $feature_flag_error property.
|
||||
|
||||
These values are sent in analytics events to track flag evaluation failures.
|
||||
They should not be changed without considering impact on existing dashboards
|
||||
and queries that filter on these values.
|
||||
|
||||
Error values:
|
||||
ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true
|
||||
FLAG_MISSING: Requested flag not in API response
|
||||
QUOTA_LIMITED: Rate/quota limit exceeded
|
||||
TIMEOUT: Request timed out
|
||||
CONNECTION_ERROR: Network connectivity issue
|
||||
UNKNOWN_ERROR: Unexpected exceptions
|
||||
|
||||
For API errors with status codes, use the api_error() method which returns
|
||||
a string like "api_error_500".
|
||||
"""
|
||||
|
||||
ERRORS_WHILE_COMPUTING = "errors_while_computing_flags"
|
||||
FLAG_MISSING = "flag_missing"
|
||||
QUOTA_LIMITED = "quota_limited"
|
||||
TIMEOUT = "timeout"
|
||||
CONNECTION_ERROR = "connection_error"
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
|
||||
@staticmethod
|
||||
def api_error(status: Union[int, str]) -> str:
|
||||
"""Generate API error string with status code.
|
||||
|
||||
Args:
|
||||
status: HTTP status code from the API error
|
||||
|
||||
Returns:
|
||||
Error string like "api_error_500"
|
||||
"""
|
||||
return f"api_error_{status}"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
VERSION = "6.7.3"
|
||||
VERSION = "7.5.0"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
+11
-9
@@ -10,18 +10,18 @@ authors = [{ name = "PostHog", email = "hey@posthog.com" }]
|
||||
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
|
||||
license = { text = "MIT" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.10"
|
||||
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",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"requests>=2.7,<3.0",
|
||||
@@ -66,13 +66,13 @@ test = [
|
||||
"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",
|
||||
"openai>=2.0",
|
||||
"anthropic>=0.72",
|
||||
"langgraph>=1.0",
|
||||
"langchain-core>=1.0",
|
||||
"langchain-community>=0.4",
|
||||
"langchain-openai>=1.0",
|
||||
"langchain-anthropic>=1.0",
|
||||
"google-genai",
|
||||
"pydantic",
|
||||
"parameterized>=0.8.1",
|
||||
@@ -96,3 +96,5 @@ 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
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
@@ -14,7 +14,7 @@ long_description = """
|
||||
PostHog is developer-friendly, self-hosted product analytics.
|
||||
posthog-python is the python package.
|
||||
|
||||
This package requires Python 3.9 or higher.
|
||||
This package requires Python 3.10 or higher.
|
||||
"""
|
||||
|
||||
# Minimal setup.py for backward compatibility
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ long_description = """
|
||||
PostHog is developer-friendly, self-hosted product analytics.
|
||||
posthog-python is the python package.
|
||||
|
||||
This package requires Python 3.9 or higher.
|
||||
This package requires Python 3.10 or higher.
|
||||
"""
|
||||
|
||||
# Minimal setup.py for backward compatibility
|
||||
|
||||
Reference in New Issue
Block a user