Compare commits

..
Author SHA1 Message Date
Radu Raicea 2f1ac45f08 WIP 2025-10-31 15:12:27 -04:00
99 changed files with 3576 additions and 41465 deletions
+1 -62
View File
@@ -3,9 +3,6 @@ name: CI
on:
- pull_request
permissions:
contents: read
jobs:
code-quality:
name: Code quality checks
@@ -36,10 +33,6 @@ 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
@@ -49,7 +42,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
@@ -75,57 +68,3 @@ jobs:
- name: Run posthog tests
run: |
pytest --verbose --timeout=30
import-check:
name: Python ${{ matrix.python-version }} import check
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
fetch-depth: 1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: ${{ matrix.python-version }}
- name: Install posthog
run: pip install .
- name: Check import produces no warnings
run: python -W error -c "import posthog"
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
-45
View File
@@ -1,45 +0,0 @@
name: 'CodeQL Advanced'
on:
push:
branches: ['master']
pull_request:
branches: ['master']
schedule:
- cron: '32 13 * * 1'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: 'ubuntu-latest'
permissions:
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: python
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# Disable TRAP caching - it creates a new cache per commit SHA which
# is never reused, causing wasted cache space.
# See: https://github.com/github/codeql-action/issues/2030
trap-caching: false
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
category: '/language:${{matrix.language}}'
+1 -2
View File
@@ -7,13 +7,12 @@ 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
token: ${{ secrets.POSTHOG_BOT_PAT }}
- name: Set up Python
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
+32 -224
View File
@@ -1,249 +1,57 @@
name: "Release"
on:
pull_request:
types: [closed]
branches: [master]
push:
branches:
- master
paths:
- "posthog/version.py"
workflow_dispatch:
permissions:
contents: read
# Concurrency control: only one release process can run at a time
# This prevents race conditions if multiple PRs with 'release' label merge simultaneously
concurrency:
group: release
cancel-in-progress: false
jobs:
check-release-label:
name: Check for release label
runs-on: ubuntu-latest
# Run when PR with 'release' label is merged to master
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'release'))
outputs:
should-release: ${{ steps.check.outputs.should-release }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
- name: Check release conditions
id: check
run: |
changeset_count=$(find .sampo/changesets -name '*.md' 2>/dev/null | wc -l)
if [ "$changeset_count" -gt 0 ]; then
echo "should-release=true" >> "$GITHUB_OUTPUT"
echo "Found $changeset_count changeset(s), ready to release"
else
echo "should-release=false" >> "$GITHUB_OUTPUT"
echo "No changesets to release"
fi
notify-approval-needed:
name: Notify Slack - Approval Needed
needs: check-release-label
if: needs.check-release-label.outputs.should-release == 'true'
uses: posthog/.github/.github/workflows/notify-approval-needed.yml@main
with:
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
slack_user_group_id: ${{ vars.GROUP_CLIENT_LIBRARIES_SLACK_GROUP_ID }}
secrets:
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
posthog_project_api_key: ${{ secrets.POSTHOG_PROJECT_API_KEY }}
release:
name: Release and publish
needs: [check-release-label, notify-approval-needed]
name: Publish release
runs-on: ubuntu-latest
# Use `always()` to ensure the job runs even if notify-approval-needed is skipped,
# but still depend on it to access `needs.notify-approval-needed.outputs.slack_ts`
if: always() && needs.check-release-label.outputs.should-release == 'true'
environment: "Release" # This will require an approval from a maintainer, they are notified in Slack above
permissions:
contents: write
actions: write
id-token: write
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
steps:
- name: Notify Slack - Approved
if: needs.notify-approval-needed.outputs.slack_ts != ''
uses: posthog/.github/.github/actions/slack-thread-reply@main
- name: Checkout the repository
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }}
message: "✅ Release approved! Version bump in progress..."
emoji_reaction: "white_check_mark"
- name: Get GitHub App token
id: releaser
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_APP_POSTHOG_PYTHON_RELEASER_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_PYTHON_RELEASER_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
token: ${{ steps.releaser.outputs.token }}
token: ${{ secrets.POSTHOG_BOT_PAT }}
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: 3.11.11
- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
enable-cache: true
pyproject-file: "pyproject.toml"
enable-cache: true
pyproject-file: 'pyproject.toml'
- name: Detect version
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
- name: Install Rust
uses: dtolnay/rust-toolchain@0b1efabc08b657293548b77fb76cc02d26091c7e
with:
toolchain: 1.91.1
components: cargo
- name: Cache Sampo CLI
id: cache-sampo
uses: actions/cache@v3
with:
path: ~/.cargo/bin/sampo
key: sampo-${{ runner.os }}-${{ runner.arch }}
- name: Install Sampo CLI
if: steps.cache-sampo.outputs.cache-hit != 'true'
run: cargo install sampo
- name: Install dependencies
- name: Prepare for building release
run: uv sync --extra dev
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Push releases to PyPI
run: uv run make release && uv run make release_analytics
- name: Prepare release with Sampo
id: sampo-release
- name: Create GitHub release
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
env:
GITHUB_TOKEN: ${{ steps.releaser.outputs.token }}
run: |
sampo release
new_version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
echo "new_version=$new_version" >> "$GITHUB_OUTPUT"
- name: Sync version to posthog/version.py
run: |
echo 'VERSION = "${{ steps.sampo-release.outputs.new_version }}"' > posthog/version.py
- name: Commit release changes
id: commit-release
env:
GITHUB_TOKEN: ${{ steps.releaser.outputs.token }}
run: |
git add -A
if git diff --staged --quiet; then
echo "No changes to commit"
echo "committed=false" >> "$GITHUB_OUTPUT"
else
git commit -m "chore: Release v${{ steps.sampo-release.outputs.new_version }}"
git push origin master
echo "committed=true" >> "$GITHUB_OUTPUT"
fi
# Publishing is done manually (not via `sampo publish`) because we need to
# publish both `posthog` and `posthoganalytics` packages to PyPI.
# Sampo only knows about the `posthog` package, so we handle both here.
# Both packages use PyPI OIDC trusted publishing (no API tokens needed).
- name: Build posthog
if: steps.commit-release.outputs.committed == 'true'
run: uv run make build_release
- name: Publish posthog to PyPI
if: steps.commit-release.outputs.committed == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
# The `posthoganalytics` package is a mirror of `posthog` published under
# a different name for backwards compatibility. The make target handles
# copying, renaming imports, and building the dist automatically.
- name: Build posthoganalytics
if: steps.commit-release.outputs.committed == 'true'
run: uv run make build_release_analytics
- name: Publish posthoganalytics to PyPI
if: steps.commit-release.outputs.committed == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
# We skip `sampo publish` (which normally creates the tag) because we
# need to publish both posthog and posthoganalytics manually, so we
# create the tag ourselves.
- name: Tag release
if: steps.commit-release.outputs.committed == 'true'
run: git tag "v${{ steps.sampo-release.outputs.new_version }}"
- name: Push tags
if: steps.commit-release.outputs.committed == 'true'
run: git push origin --tags
- name: Create GitHub Release
if: steps.commit-release.outputs.committed == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release create "v${{ steps.sampo-release.outputs.new_version }}" --generate-notes
- name: Dispatch generate-references
if: steps.commit-release.outputs.committed == 'true'
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_PAT }}
with:
tag_name: v${{ env.REPO_VERSION }}
release_name: ${{ env.REPO_VERSION }}
- name: Dispatch generate-references for posthog-python
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run generate-references.yml --ref master
# Notify in case of a failure
- name: Send failure event to PostHog
if: ${{ failure() }}
uses: PostHog/posthog-github-action@v0.1
with:
posthog-token: "${{ secrets.POSTHOG_PROJECT_API_KEY }}"
event: "posthog-python-github-release-workflow-failure"
properties: >-
{
"commitSha": "${{ github.sha }}",
"jobStatus": "${{ job.status }}",
"ref": "${{ github.ref }}",
"version": "v${{ steps.sampo-release.outputs.new_version }}"
}
- name: Notify Slack - Failed
if: ${{ failure() && needs.notify-approval-needed.outputs.slack_ts != '' }}
uses: posthog/.github/.github/actions/slack-thread-reply@main
with:
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }}
message: "❌ Failed to release `posthog-python@v${{ steps.sampo-release.outputs.new_version }}`! <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>"
emoji_reaction: "x"
notify-released:
name: Notify Slack - Released
needs: [check-release-label, notify-approval-needed, release]
runs-on: ubuntu-latest
if: always() && needs.release.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != ''
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Notify Slack - Released
uses: posthog/.github/.github/actions/slack-thread-reply@main
with:
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }}
message: "🚀 posthog-python released successfully!"
emoji_reaction: "rocket"
run: |
gh workflow run generate-references.yml --ref master
-21
View File
@@ -1,21 +0,0 @@
name: SDK Compliance Tests
permissions:
contents: read
packages: read
pull-requests: write
on:
pull_request:
push:
branches:
- master
jobs:
compliance:
name: PostHog SDK compliance tests
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@main
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
adapter-context: "."
test-harness-version: "latest"
-1
View File
@@ -19,4 +19,3 @@ pyrightconfig.json
.env
.DS_Store
posthog-python-references.json
.claude/settings.local.json
-19
View File
@@ -1,19 +0,0 @@
# Sampo configuration
version = 1
[git]
default_branch = "master"
short_tags = "posthog" # Tag with v1.2.3 rather than posthog-v1.2.3
[github]
repository = "posthog/posthog-python"
[changelog]
# Options for release notes generation.
# show_commit_hash = true (default)
# show_acknowledgments = true (default)
[packages]
# Options for package discovery and filtering.
# ignore_unpublished = false (default)
# ignore = ["internal-*", "examples/*"]
+33 -207
View File
@@ -1,296 +1,122 @@
# posthog
## 7.9.4 — 2026-02-25
### Patch changes
- [a68a6a6](https://github.com/posthog/posthog-python/commit/a68a6a6d045072c88eeee7acac441536919b5954) feat(llma): add `$ai_tokens_source` property ("sdk" or "passthrough") to all `$ai_generation` events to detect when token values are externally overridden via `posthog_properties` — Thanks @carlos-marchal-ph!
## 7.9.3 — 2026-02-18
### Patch changes
- [9f9553a](https://github.com/posthog/posthog-python/commit/9f9553a420d22e5e6435b775993f61a059280c2a) Fix posthoganalytics release, previously broken — Thanks @rafaeelaudibert!
## 7.9.2 — 2026-02-18
### Patch changes
- [f1dc4d7](https://github.com/posthog/posthog-python/commit/f1dc4d73914712983a7f715ee4fe1b70e66e770a) Add sampo to the project — Thanks @rafaeelaudibert!
## 7.9.1 - 2026-02-17
fix(llma): make prompt fetches deterministic by requiring project_api_key and sending it as token query param
## 7.9.0 - 2026-02-17
feat: Support device_id as bucketing identifier for local evaluation
## 7.8.6 - 2026-02-09
fix: limit collections scanning in code variables
## 7.8.5 - 2026-02-09
fix: further optimize code variables pattern matching
## 7.8.4 - 2026-02-09
fix: do not pattern match long values in code variables
## 7.8.3 - 2026-02-06
fix: openAI input image sanitization
## 7.8.2 - 2026-02-04
fix(llma): fix prompts default url
## 7.8.1 - 2026-02-03
fix(llma): small fixes for prompt management
## 7.8.0 - 2026-01-28
feat(llma): add prompt management
Adds the Prompt Management feature. At the time of release, this feature is in a closed alpha.
## 7.7.0 - 2026-01-15
feat(ai): Add OpenAI Agents SDK integration
Automatic tracing for agent workflows, handoffs, tool calls, guardrails, and custom spans. Includes `$ai_total_tokens`, `$ai_error_type` categorization, and `$ai_framework` property.
## 7.6.0 - 2026-01-12
feat: add device_id to flags request payload
Add device_id parameter to all feature flag methods, allowing the server to track device identifiers for flag evaluation. The device_id can be passed explicitly or set via context using `set_context_device_id()`.
## 7.5.1 - 2026-01-07
fix: avoid return from finally block to fix Python 3.14 SyntaxWarning (#361) - thanks @jodal
## 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
# Unreleased
- 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
# 6.7.11 - 2025-10-28
- feat(ai): Add `$ai_framework` property for framework integrations (e.g. LangChain)
## 6.7.10 - 2025-10-24
# 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
# 6.7.9 - 2025-10-22
- fix(flags): multi-condition flags with static cohorts returning wrong variants
## 6.7.8 - 2025-10-16
# 6.7.8 - 2025-10-16
- fix(llma): missing async for OpenAI's streaming implementation
## 6.7.7 - 2025-10-14
# 6.7.7 - 2025-10-14
- fix: remove deprecated attribute $exception_personURL from exception events
## 6.7.6 - 2025-09-16
# 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
# 6.7.5 - 2025-09-16
- feat: Django middleware now supports async request handling.
## 6.7.4 - 2025-09-05
# 6.7.4 - 2025-09-05
- fix: Missing system prompts for some providers
## 6.7.3 - 2025-09-04
# 6.7.3 - 2025-09-04
- fix: missing usage tokens in Gemini
## 6.7.2 - 2025-09-03
# 6.7.2 - 2025-09-03
- fix: tool call results in streaming providers
## 6.7.1 - 2025-09-01
# 6.7.1 - 2025-09-01
- fix: Add base64 inline image sanitization
## 6.7.0 - 2025-08-26
# 6.7.0 - 2025-08-26
- feat: Add support for feature flag dependencies
## 6.6.1 - 2025-08-21
# 6.6.1 - 2025-08-21
- fix: Prevent `NoneType` error when `group_properties` is `None`
## 6.6.0 - 2025-08-15
# 6.6.0 - 2025-08-15
- feat: Add `flag_keys_to_evaluate` parameter to optimize feature flag evaluation performance by only evaluating specified flags
- feat: Add `flag_keys_filter` option to `send_feature_flags` for selective flag evaluation in capture events
## 6.5.0 - 2025-08-08
# 6.5.0 - 2025-08-08
- feat: Add `$context_tags` to an event to know which properties were included as tags
## 6.4.1 - 2025-08-06
# 6.4.1 - 2025-08-06
- fix: Always pass project API key in `remote_config` requests for deterministic project routing
## 6.4.0 - 2025-08-05
# 6.4.0 - 2025-08-05
- feat: support Vertex AI for Gemini
## 6.3.4 - 2025-08-04
# 6.3.4 - 2025-08-04
- fix: set `$ai_tools` for all providers and `$ai_output_choices` for all non-streaming provider flows properly
## 6.3.3 - 2025-08-01
# 6.3.3 - 2025-08-01
- fix: `get_feature_flag_result` now correctly returns FeatureFlagResult when payload is empty string instead of None
## 6.3.2 - 2025-07-31
# 6.3.2 - 2025-07-31
- fix: Anthropic's tool calls are now handled properly
## 6.3.0 - 2025-07-22
# 6.3.0 - 2025-07-22
- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties
## 6.2.1 - 2025-07-21
# 6.2.1 - 2025-07-21
- feat: make `posthog_client` an optional argument in PostHog AI providers wrappers (`posthog.ai.*`), intuitively using the default client as the default
## 6.1.1 - 2025-07-16
# 6.1.1 - 2025-07-16
- fix: correctly capture exceptions processed by Django from views or middleware
## 6.1.0 - 2025-07-10
# 6.1.0 - 2025-07-10
- feat: decouple feature flag local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller
## 6.0.4 - 2025-07-09
# 6.0.4 - 2025-07-09
- fix: add POSTHOG_MW_CLIENT setting to django middleware, to support custom clients for exception capture.
## 6.0.3 - 2025-07-07
# 6.0.3 - 2025-07-07
- feat: add a feature flag evaluation cache (local storage or redis) to support returning flag evaluations when the service is down
## 6.0.2 - 2025-07-02
# 6.0.2 - 2025-07-02
- fix: send_feature_flags changed to default to false in `Client::capture_exception`
## 6.0.1
# 6.0.1
- fix: response `$process_person_profile` property when passed to capture
## 6.0.0
# 6.0.0
This release contains a number of major breaking changes:
@@ -317,15 +143,15 @@ with posthog.new_context():
Generally, arguments are now appropriately typed, and docstrings have been updated. If something is unclear, please open an issue, or submit a PR!
## 5.4.0 - 2025-06-20
# 5.4.0 - 2025-06-20
- feat: add support to session_id context on page method
## 5.3.0 - 2025-06-19
# 5.3.0 - 2025-06-19
- fix: safely handle exception values
## 5.2.0 - 2025-06-19
# 5.2.0 - 2025-06-19
- feat: construct artificial stack traces if no traceback is available on a captured exception
+5 -18
View File
@@ -5,26 +5,12 @@ test:
coverage run -m pytest
coverage report
build_release:
release:
rm -rf dist/*
python setup.py sdist bdist_wheel
twine upload dist/*
# Builds the `posthoganalytics` PyPI package, which is a mirror of `posthog`
# published under a different name for internal use by posthog/posthog.
#
# The process works in three phases:
# 1. posthog -> posthoganalytics: Copy the source, rewrite all imports,
# remove the original posthog/ dir, and build the dist.
# 2. posthoganalytics -> posthog: Reverse the import rewrites, copy
# everything back into posthog/, and clean up.
# 3. Restore pyproject.toml from backup (setup_analytics.py modifies it).
#
# This ensures the working tree is left in the same state it started in.
#
# NOTE: This target clears dist/ before building. In the release workflow,
# `build_release` (posthog) must be published BEFORE running this target,
# otherwise the posthog dist artifacts will be lost.
build_release_analytics:
release_analytics:
rm -rf dist
rm -rf build
rm -rf posthoganalytics
@@ -35,6 +21,7 @@ build_release_analytics:
find ./posthoganalytics -name "*.bak" -delete
rm -rf posthog
python setup_analytics.py sdist bdist_wheel
twine upload dist/*
mkdir posthog
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from posthog /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from posthog\./g' {} \;
@@ -67,4 +54,4 @@ prep_local:
@echo "Local copy created at ../posthog-python-local"
@echo "Install with: pip install -e ../posthog-python-local"
.PHONY: test lint build_release build_release_analytics e2e_test prep_local
.PHONY: test lint release e2e_test prep_local
+12 -26
View File
@@ -12,14 +12,6 @@
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
## Python Version Support
| SDK Version | Python Versions Supported | Notes |
| ------------- | ---------------------------- | -------------------------- |
| 7.3.1+ | 3.10, 3.11, 3.12, 3.13, 3.14 | Added Python 3.14 support |
| 7.0.0 - 7.0.1 | 3.10, 3.11, 3.12, 3.13 | Dropped Python 3.9 support |
| 4.0.1 - 6.x | 3.9, 3.10, 3.11, 3.12, 3.13 | Python 3.9+ required |
## Development
### Testing Locally
@@ -27,19 +19,19 @@ Please see the [Python integration docs](https://posthog.com/docs/integrations/p
We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
1. Run `uv venv env` (creates virtual environment called "env")
- or `python3 -m venv env`
* or `python3 -m venv env`
2. Run `source env/bin/activate` (activates the virtual environment)
3. Run `uv sync --extra dev --extra test` (installs the package in develop mode, along with test dependencies)
- or `pip install -e ".[dev,test]"`
* or `pip install -e ".[dev,test]"`
4. you have to run `pre-commit install` to have auto linting pre commit
5. Run `make test`
6. To run a specific test do `pytest -k test_no_api_key`
1. To run a specific test do `pytest -k test_no_api_key`
## PostHog recommends `uv` so...
```bash
uv python install 3.12
uv python pin 3.12
uv python install 3.9.19
uv python pin 3.9.19
uv venv
source env/bin/activate
uv sync --extra dev --extra test
@@ -51,10 +43,16 @@ make test
Assuming you have a [local version of PostHog](https://posthog.com/docs/developing-locally) running, you can run `python3 example.py` to see the library in action.
### Releasing Versions
Updates are released automatically using GitHub Actions when `version.py` is updated on `master`. After bumping `version.py` in `master` and adding to `CHANGELOG.md`, the [release workflow](https://github.com/PostHog/posthog-python/blob/master/.github/workflows/release.yaml) will automatically trigger and deploy the new version.
If you need to check the latest runs or manually trigger a release, you can go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
### Testing changes locally with the PostHog app
You can run `make prep_local`, and it'll create a new folder alongside the SDK repo one called `posthog-python-local`, which you can then import into the posthog project by changing pyproject.toml to look like this:
```toml
dependencies = [
...
@@ -65,16 +63,4 @@ dependencies = [
[tools.uv.sources]
posthoganalytics = { path = "../posthog-python-local" }
```
This'll let you build and test SDK changes fully locally, incorporating them into your local posthog app stack. It mainly takes care of the `posthog -> posthoganalytics` module renaming. You'll need to re-run `make prep_local` each time you make a change, and re-run `uv sync --active` in the posthog app project.
## Releasing
This repository uses [Sampo](https://github.com/bruits/sampo) for versioning, changelogs, and publishing to crates.io.
1. When making changes, include a changeset: `sampo add`
2. Create a PR with your changes and the changeset file
3. Add the `release` label and merge to `main`
4. Approve the release in Slack when prompted — this triggers version bump, crates.io publish, git tag, and GitHub Release
You can also trigger a release manually via the workflow's `workflow_dispatch` trigger (still requires pending changesets).
-11
View File
@@ -1,11 +0,0 @@
# posthoganalytics
> **Do not use this package.** Use [`posthog`](https://pypi.org/project/posthog/) instead.
```bash
pip install posthog
```
This package exists solely for internal use by [posthog/posthog](https://github.com/posthog/posthog) to avoid import conflicts with the local `posthog` package in that repository. It is an automatically generated mirror of `posthog` — same code, same versions, just published under a different name.
If you are not working on the PostHog main repository, you should never need this package. All documentation, issues, and development happen in [`posthog-python`](https://github.com/posthog/posthog-python).
+60 -66
View File
@@ -35,40 +35,54 @@ project_key = os.getenv("POSTHOG_PROJECT_API_KEY", "")
personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "")
host = os.getenv("POSTHOG_HOST", "http://localhost:8000")
# Check if project key is provided (required)
if not project_key:
print("❌ Missing PostHog project API key!")
print(" Please set POSTHOG_PROJECT_API_KEY environment variable")
# Check if credentials are provided
if not project_key or not personal_api_key:
print("❌ Missing PostHog credentials!")
print(
" Please set POSTHOG_PROJECT_API_KEY and POSTHOG_PERSONAL_API_KEY environment variables"
)
print(" or copy .env.example to .env and fill in your values")
exit(1)
# 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
# Test authentication before proceeding
print("🔑 Testing PostHog authentication...")
# Check if personal API key is available for local evaluation
local_eval_available = bool(personal_api_key)
if personal_api_key:
try:
# Configure PostHog with credentials
posthog.debug = False # Keep quiet during auth test
posthog.api_key = project_key
posthog.project_api_key = project_key
posthog.personal_api_key = personal_api_key
posthog.host = host
posthog.poll_interval = 10
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")
# Test by attempting to get feature flags (this validates both keys)
# This will fail if credentials are invalid
test_flags = posthog.get_all_flags("test_user", only_evaluate_locally=True)
# If we get here without exception, credentials work
print("✅ Authentication successful!")
print(f" Project API Key: {project_key[:9]}...")
print(" Personal API Key: [REDACTED]")
print(f" Host: {host}\n\n")
except Exception as e:
print("❌ Authentication failed!")
print(f" Error: {e}")
print("\n Please check your credentials:")
print(" - POSTHOG_PROJECT_API_KEY: Project API key from PostHog settings")
print(
" - POSTHOG_PERSONAL_API_KEY: Personal API key (required for local evaluation)"
)
print(" - POSTHOG_HOST: Your PostHog instance URL")
exit(1)
# Display menu and get user choice
print("🚀 PostHog Python SDK Demo - Choose an example to run:\n")
print("1. Identify and capture examples")
local_eval_note = "" if local_eval_available else " [requires personal API key]"
print(f"2. Feature flag local evaluation examples{local_eval_note}")
print("2. Feature flag local evaluation examples")
print("3. Feature flag payload examples")
print(f"4. Flag dependencies examples{local_eval_note}")
print("4. Flag dependencies examples")
print("5. Context management and tagging examples")
print("6. Run all examples")
print("7. Exit")
@@ -134,14 +148,6 @@ 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)
@@ -209,14 +215,6 @@ 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)
@@ -431,8 +429,6 @@ 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}")
@@ -451,37 +447,35 @@ elif choice == "6":
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
)
# Run example 2 (requires local evaluation)
if local_eval_available:
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
print("🏁 Testing basic feature flags...")
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
print(
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
)
# Run example 2
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
print("🏁 Testing basic feature flags...")
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
print(
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
)
# Run example 3
print(f"\n{'🔸' * 20} PAYLOADS {'🔸' * 20}")
print("📦 Testing payloads...")
print(f"Payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}")
# Run example 4 (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 4
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
print("🔗 Testing flag dependencies...")
result1 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
result2 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user2",
person_properties={"email": "user@other.com"},
only_evaluate_locally=True,
)
print(f"✅ @example.com user: {result1}, regular user: {result2}")
# Run example 5
print(f"\n{'🔸' * 20} CONTEXT MANAGEMENT {'🔸' * 20}")
-144
View File
@@ -1,144 +0,0 @@
"""
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])
-4
View File
@@ -1,4 +0,0 @@
db.sqlite3
*.pyc
__pycache__/
.pytest_cache/
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "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()
-19
View File
@@ -1,19 +0,0 @@
[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 }
@@ -1,111 +0,0 @@
"""
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"]
@@ -1,170 +0,0 @@
"""
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
@@ -1,16 +0,0 @@
"""
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()
@@ -1,129 +0,0 @@
"""
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
@@ -1,28 +0,0 @@
"""
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),
]
@@ -1,50 +0,0 @@
"""
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")
@@ -1,16 +0,0 @@
"""
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()
-674
View File
@@ -1,674 +0,0 @@
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" },
]
+5
View File
@@ -26,9 +26,14 @@ 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]
+87 -126
View File
@@ -1,65 +1,29 @@
import datetime # noqa: F401
from typing import Any, Callable, Dict, Optional # noqa: F401
from typing import Callable, Dict, Optional, Any # noqa: F401
from typing_extensions import Unpack
from posthog.args import ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
from posthog.args import (
OptionalCaptureArgs,
OptionalSetArgs,
OptionalCaptureAIArgs,
AI_EVENT_TYPE,
ExceptionArg,
)
from posthog.client import Client
from posthog.contexts import (
identify_context as inner_identify_context,
)
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_device_id as inner_set_context_device_id,
)
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,
set_context_session as inner_set_context_session,
identify_context as inner_identify_context,
)
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
@@ -69,14 +33,13 @@ __version__ = VERSION
"""Context management."""
def new_context(fresh=False, capture_exceptions=True, client=None):
def new_context(fresh=False, capture_exceptions=True):
"""
Create a new context scope that will be active for the duration of the with block.
Args:
fresh: Whether to start with a fresh context (default: False)
capture_exceptions: Whether to capture exceptions raised within the context (default: True)
client: Optional Posthog client instance to use for this context (default: None)
Examples:
```python
@@ -89,9 +52,7 @@ def new_context(fresh=False, capture_exceptions=True, client=None):
Category:
Contexts
"""
return inner_new_context(
fresh=fresh, capture_exceptions=capture_exceptions, client=client
)
return inner_new_context(fresh=fresh, capture_exceptions=capture_exceptions)
def scoped(fresh=False, capture_exceptions=True):
@@ -136,26 +97,6 @@ def set_context_session(session_id: str):
return inner_set_context_session(session_id)
def set_context_device_id(device_id: str):
"""
Set the device ID for the current context, associating all feature flag requests
in this or child contexts with the given device ID.
Args:
device_id: The device ID to associate with the current context and its children
Examples:
```python
from posthog import set_context_device_id
set_context_device_id("device_123")
```
Category:
Contexts
"""
return inner_set_context_device_id(device_id)
def identify_context(distinct_id: str):
"""
Identify the current context with a distinct ID.
@@ -175,27 +116,6 @@ 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.
@@ -216,19 +136,6 @@ 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]
@@ -256,11 +163,6 @@ 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
@@ -497,6 +399,81 @@ def capture_exception(
return _proxy("capture_exception", exception=exception, **kwargs)
def capture_ai(
event: AI_EVENT_TYPE, **kwargs: Unpack[OptionalCaptureAIArgs]
) -> Optional[str]:
"""
Capture an AI event to the dedicated AI endpoint with support for large payloads.
This method sends AI events (like $ai_generation, $ai_trace, etc.) to PostHog's
specialized AI endpoint (/i/v0/ai) which supports large payloads through multipart/form-data
and blob storage in S3.
Args:
event: The AI event type. Must be one of: "$ai_generation", "$ai_trace", "$ai_span",
"$ai_embedding", "$ai_metric", "$ai_feedback"
distinct_id: The distinct ID of the user.
properties: A dictionary of AI event properties. Must include required properties based on event type:
- All events: "$ai_model" (required)
- $ai_generation: "$ai_provider", "$ai_trace_id" (required)
- $ai_trace: "$ai_trace_id" (required)
- $ai_span: "$ai_trace_id", "$ai_span_id" (required)
- $ai_embedding: "$ai_provider", "$ai_trace_id" (required)
blob_properties: List of property names to send as blobs (large data stored in S3).
Common blob properties: "$ai_input", "$ai_output_choices", "$ai_input_state", "$ai_output_state"
If not provided, defaults to common blob properties based on event type.
timestamp: The timestamp of the event.
uuid: A unique identifier for the event.
groups: A dictionary of group information.
disable_geoip: Whether to disable GeoIP for this event.
Examples:
```python
# $ai_generation event with blobs
from posthog import capture_ai
capture_ai(
"$ai_generation",
distinct_id="user_123",
properties={
"$ai_model": "gpt-4",
"$ai_provider": "openai",
"$ai_trace_id": "trace_abc123",
"$ai_input": {
"messages": [
{"role": "user", "content": "Hello!"}
]
},
"$ai_output_choices": {
"choices": [{"message": {"role": "assistant", "content": "Hi there!"}}]
},
"$ai_completion_tokens": 150,
"$ai_prompt_tokens": 50
},
blob_properties=["$ai_input", "$ai_output_choices"]
)
```
```python
# $ai_trace event
from posthog import capture_ai
capture_ai(
"$ai_trace",
distinct_id="user_123",
properties={
"$ai_model": "gpt-4",
"$ai_trace_id": "trace_abc123"
}
)
```
Category:
AI Events
Note: This method sends events synchronously to the AI endpoint, bypassing the queue system.
"""
return _proxy("capture_ai", event, **kwargs)
def feature_enabled(
key, # type: str
distinct_id, # type: str
@@ -506,7 +483,6 @@ def feature_enabled(
only_evaluate_locally=False, # type: bool
send_feature_flag_events=True, # type: bool
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
):
# type: (...) -> bool
"""
@@ -546,7 +522,6 @@ def feature_enabled(
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -559,7 +534,6 @@ def get_feature_flag(
only_evaluate_locally=False, # type: bool
send_feature_flag_events=True, # type: bool
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
) -> Optional[FeatureFlag]:
"""
Get feature flag variant for users. Used with experiments.
@@ -598,7 +572,6 @@ def get_feature_flag(
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -609,7 +582,6 @@ def get_all_flags(
group_properties=None, # type: Optional[dict]
only_evaluate_locally=False, # type: bool
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
) -> Optional[dict[str, FeatureFlag]]:
"""
Get all flags for a given user.
@@ -642,7 +614,6 @@ def get_all_flags(
group_properties=group_properties or {},
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -655,7 +626,6 @@ def get_feature_flag_result(
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
):
# type: (...) -> Optional[FeatureFlagResult]
"""
@@ -687,7 +657,6 @@ def get_feature_flag_result(
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -701,7 +670,6 @@ def get_feature_flag_payload(
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
) -> Optional[str]:
return _proxy(
"get_feature_flag_payload",
@@ -714,7 +682,6 @@ def get_feature_flag_payload(
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -745,7 +712,6 @@ def get_all_flags_and_payloads(
group_properties=None, # type: Optional[dict]
only_evaluate_locally=False,
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
) -> FlagsAndPayloads:
return _proxy(
"get_all_flags_and_payloads",
@@ -755,7 +721,6 @@ def get_all_flags_and_payloads(
group_properties=group_properties or {},
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -867,10 +832,6 @@ 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
-3
View File
@@ -1,3 +0,0 @@
from posthog.ai.prompts import Prompts
__all__ = ["Prompts"]
+64 -27
View File
@@ -14,9 +14,14 @@ 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,
@@ -215,34 +220,66 @@ class AsyncWrappedMessages(AsyncMessages):
content_blocks: List[StreamingContentBlock],
accumulated_content: str,
):
from posthog.ai.types import StreamingEventData
from posthog.ai.anthropic.anthropic_converter import (
format_anthropic_streaming_input,
format_anthropic_streaming_output_complete,
)
from posthog.ai.utils import capture_streaming_event
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
# Prepare standardized event data
formatted_input = format_anthropic_streaming_input(kwargs)
sanitized_input = sanitize_anthropic(formatted_input)
# Format output using converter
formatted_content = format_anthropic_streaming_content(content_blocks)
formatted_output = []
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
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")),
),
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,
)
"$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 {}),
}
# Use the common capture function
capture_streaming_event(self._client._ph_client, event_data)
# 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,
)
@@ -17,7 +17,6 @@ from posthog.ai.types import (
TokenUsage,
ToolInProgress,
)
from posthog.ai.utils import serialize_raw_usage
def format_anthropic_response(response: Any) -> List[FormattedMessage]:
@@ -164,32 +163,6 @@ 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).
@@ -218,16 +191,6 @@ 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
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(response.usage)
if serialized:
result["raw_usage"] = serialized
return result
@@ -254,32 +217,11 @@ def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
usage["cache_read_input_tokens"] = getattr(
event.message.usage, "cache_read_input_tokens", 0
)
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(event.message.usage)
if serialized:
usage["raw_usage"] = serialized
# Handle usage stats from message_delta event
if hasattr(event, "usage") and event.usage:
usage["output_tokens"] = getattr(event.usage, "output_tokens", 0)
# Extract web search count from usage
if hasattr(event.usage, "server_tool_use"):
server_tool_use = event.usage.server_tool_use
if hasattr(server_tool_use, "web_search_requests"):
web_search_count = int(
getattr(server_tool_use, "web_search_requests", 0)
)
if web_search_count > 0:
usage["web_search_count"] = web_search_count
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(event.usage)
if serialized:
usage["raw_usage"] = serialized
return usage
-3
View File
@@ -1,5 +1,4 @@
from .gemini import Client
from .gemini_async import AsyncClient
from .gemini_converter import (
format_gemini_input,
format_gemini_response,
@@ -10,14 +9,12 @@ 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",
+1 -1
View File
@@ -304,7 +304,7 @@ class Models:
def generator():
nonlocal usage_stats
nonlocal accumulated_content
nonlocal accumulated_content # noqa: F824
try:
for chunk in response:
# Extract usage stats from chunk
-423
View File
@@ -1,423 +0,0 @@
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,
)
+23 -166
View File
@@ -12,7 +12,6 @@ from posthog.ai.types import (
FormattedMessage,
TokenUsage,
)
from posthog.ai.utils import serialize_raw_usage
class GeminiPart(TypedDict, total=False):
@@ -30,76 +29,35 @@ class GeminiMessage(TypedDict, total=False):
text: str
def _format_parts_as_content_blocks(parts: List[Any]) -> List[FormattedContentItem]:
def _extract_text_from_parts(parts: List[Any]) -> str:
"""
Format Gemini parts array into structured content blocks.
Preserves structure for multimodal content (text + images) instead of
concatenating everything into a string.
Extract and concatenate text from a parts array.
Args:
parts: List of parts that may contain text, inline_data, etc.
parts: List of parts that may contain text content
Returns:
List of formatted content blocks
Concatenated text from all parts
"""
content_blocks: List[FormattedContentItem] = []
content_parts = []
for part in parts:
# Handle dict with text field
if isinstance(part, dict) and "text" in part:
content_blocks.append({"type": "text", "text": part["text"]})
content_parts.append(part["text"])
# Handle string parts
elif isinstance(part, str):
content_blocks.append({"type": "text", "text": part})
content_parts.append(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", "")
if text_value:
content_blocks.append({"type": "text", "text": text_value})
content_parts.append(text_value if text_value else 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"
else:
content_parts.append(str(part))
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
return "".join(content_parts)
def _format_dict_message(item: Dict[str, Any]) -> FormattedMessage:
@@ -115,17 +73,16 @@ 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_blocks = _format_parts_as_content_blocks(item["parts"])
return {"role": item.get("role", "user"), "content": content_blocks}
content = _extract_text_from_parts(item["parts"])
return {"role": item.get("role", "user"), "content": content}
# Handle dict with content field
if "content" in item:
content = item["content"]
if isinstance(content, list):
# If content is a list, format it as content blocks
content_blocks = _format_parts_as_content_blocks(content)
return {"role": item.get("role", "user"), "content": content_blocks}
# If content is a list, extract text from it
content = _extract_text_from_parts(content)
elif not isinstance(content, str):
content = str(content)
@@ -153,14 +110,14 @@ def _format_object_message(item: Any) -> FormattedMessage:
# Handle object with parts attribute
if hasattr(item, "parts") and hasattr(item.parts, "__iter__"):
content_blocks = _format_parts_as_content_blocks(list(item.parts))
content = _extract_text_from_parts(item.parts)
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
# Ensure role is a string
if not isinstance(role, str):
role = "user"
return {"role": role, "content": content_blocks}
return {"role": role, "content": content}
# Handle object with text attribute
if hasattr(item, "text"):
@@ -183,8 +140,7 @@ def _format_object_message(item: Any) -> FormattedMessage:
content = item.content
if isinstance(content, list):
content_blocks = _format_parts_as_content_blocks(content)
return {"role": role, "content": content_blocks}
content = _extract_text_from_parts(content)
elif not isinstance(content, str):
content = str(content)
@@ -237,29 +193,6 @@ 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(
{
@@ -405,61 +338,6 @@ 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.
@@ -488,12 +366,6 @@ def _extract_usage_from_metadata(metadata: Any) -> TokenUsage:
if reasoning_tokens and reasoning_tokens > 0:
usage["reasoning_tokens"] = reasoning_tokens
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(metadata)
if serialized:
usage["raw_usage"] = serialized
return usage
@@ -510,14 +382,7 @@ 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)
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
return _extract_usage_from_metadata(response.usage_metadata)
def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
@@ -533,19 +398,11 @@ 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
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)
# Use the shared helper to extract usage
usage = _extract_usage_from_metadata(chunk.usage_metadata)
return usage
+19 -98
View File
@@ -1,8 +1,8 @@
try:
import langchain_core # noqa: F401
import langchain # noqa: F401
except ImportError:
raise ModuleNotFoundError(
"Please install LangChain to use this feature: 'pip install langchain-core'"
"Please install LangChain to use this feature: 'pip install langchain'"
)
import json
@@ -20,14 +20,8 @@ from typing import (
)
from uuid import UUID
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.callbacks.base import BaseCallbackHandler
from langchain.schema.agent import AgentAction, AgentFinish
from langchain_core.documents import Document
from langchain_core.messages import (
AIMessage,
@@ -35,15 +29,15 @@ from langchain_core.messages import (
FunctionMessage,
HumanMessage,
SystemMessage,
ToolCall,
ToolMessage,
ToolCall,
)
from langchain_core.outputs import ChatGeneration, LLMResult
from pydantic import BaseModel
from posthog import setup
from posthog.ai.sanitization import sanitize_langchain
from posthog.ai.utils import get_model_params, with_privacy_mode
from posthog.ai.sanitization import sanitize_langchain
from posthog.client import Client
log = logging.getLogger("posthog")
@@ -79,8 +73,6 @@ 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]
@@ -422,8 +414,6 @@ 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:
@@ -506,14 +496,6 @@ 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
@@ -578,33 +560,16 @@ class CallbackHandler(BaseCallbackHandler):
"$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, run.provider, run.model)
usage = _parse_usage(output)
event_properties["$ai_input_tokens"] = usage.input_tokens
event_properties["$ai_output_tokens"] = usage.output_tokens
event_properties["$ai_cache_creation_input_tokens"] = (
@@ -629,6 +594,12 @@ 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",
@@ -719,8 +690,6 @@ 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__
@@ -783,38 +752,15 @@ def _parse_usage_model(
"cache_read": "cache_read_tokens",
"reasoning": "reasoning_tokens",
}
normalized_usage = ModelUsage(
return 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, provider: Optional[str] = None, model: Optional[str] = None
) -> ModelUsage:
def _parse_usage(response: LLMResult) -> ModelUsage:
# langchain-anthropic uses the usage field
llm_usage_keys = ["token_usage", "usage"]
llm_usage: ModelUsage = ModelUsage(
@@ -828,15 +774,13 @@ def _parse_usage(
if response.llm_output is not None:
for key in llm_usage_keys:
if response.llm_output.get(key):
llm_usage = _parse_usage_model(
response.llm_output[key], provider, model
)
llm_usage = _parse_usage_model(response.llm_output[key])
break
if hasattr(response, "generations"):
for generation in response.generations:
if "usage" in generation:
llm_usage = _parse_usage_model(generation["usage"], provider, model)
llm_usage = _parse_usage_model(generation["usage"])
break
for generation_chunk in generation:
@@ -844,9 +788,7 @@ def _parse_usage(
"usage_metadata" in generation_chunk.generation_info
):
llm_usage = _parse_usage_model(
generation_chunk.generation_info["usage_metadata"],
provider,
model,
generation_chunk.generation_info["usage_metadata"]
)
break
@@ -873,33 +815,12 @@ def _parse_usage(
bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
)
if chunk_usage:
llm_usage = _parse_usage_model(chunk_usage, provider, model)
llm_usage = _parse_usage_model(chunk_usage)
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
+2 -27
View File
@@ -124,23 +124,14 @@ 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")
@@ -170,7 +161,6 @@ class WrappedResponses:
latency,
output,
None, # Responses API doesn't have tools
model_from_response,
)
return generator()
@@ -187,7 +177,6 @@ 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 (
@@ -200,12 +189,9 @@ 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=model,
model=kwargs.get("model", "unknown"),
base_url=str(self._client.base_url),
kwargs=kwargs,
formatted_input=sanitized_input,
@@ -334,7 +320,6 @@ 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
@@ -344,14 +329,9 @@ 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")
@@ -396,7 +376,6 @@ class WrappedCompletions:
accumulated_content,
tool_calls_list,
extract_available_tool_calls("openai", kwargs),
model_from_response,
)
return generator()
@@ -414,7 +393,6 @@ 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 (
@@ -427,12 +405,9 @@ 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=model,
model=kwargs.get("model", "unknown"),
base_url=str(self._client.base_url),
kwargs=kwargs,
formatted_input=sanitized_input,
+2 -46
View File
@@ -128,23 +128,14 @@ class WrappedResponses:
start_time = time.time()
usage_stats: TokenUsage = TokenUsage()
final_content = []
model_from_response: Optional[str] = None
response = await self._original.create(**kwargs)
async def async_generator():
nonlocal usage_stats
nonlocal final_content # noqa: F824
nonlocal model_from_response
try:
async for chunk in response:
# Extract model from response object in chunk (for stored prompts)
if hasattr(chunk, "response") and chunk.response:
if model_from_response is None and hasattr(
chunk.response, "model"
):
model_from_response = chunk.response.model
# Extract usage stats from chunk
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
@@ -175,7 +166,6 @@ class WrappedResponses:
latency,
output,
extract_available_tool_calls("openai", kwargs),
model_from_response,
)
return async_generator()
@@ -192,17 +182,13 @@ 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": model,
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
@@ -227,15 +213,6 @@ 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
@@ -364,7 +341,6 @@ 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"] = {}
@@ -375,14 +351,9 @@ class WrappedCompletions:
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:
@@ -425,7 +396,6 @@ class WrappedCompletions:
accumulated_content,
tool_calls_list,
extract_available_tool_calls("openai", kwargs),
model_from_response,
)
return async_generator()
@@ -443,17 +413,13 @@ 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": model,
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
@@ -478,16 +444,6 @@ 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
-149
View File
@@ -16,7 +16,6 @@ from posthog.ai.types import (
FormattedTextContent,
TokenUsage,
)
from posthog.ai.utils import serialize_raw_usage
def format_openai_response(response: Any) -> List[FormattedMessage]:
@@ -68,12 +67,6 @@ 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(
{
@@ -262,113 +255,6 @@ 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).
@@ -426,16 +312,6 @@ 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
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(response.usage)
if serialized:
result["raw_usage"] = serialized
return result
@@ -458,13 +334,6 @@ 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
@@ -489,12 +358,6 @@ def extract_openai_usage_from_chunk(
chunk.usage.completion_tokens_details.reasoning_tokens
)
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(chunk.usage)
if serialized:
usage["raw_usage"] = serialized
elif provider_type == "responses":
# For Responses API, usage is only in chunk.response.usage for completed events
if hasattr(chunk, "type") and chunk.type == "response.completed":
@@ -523,18 +386,6 @@ 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
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(response_usage)
if serialized:
usage["raw_usage"] = serialized
return usage
-76
View File
@@ -1,76 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union
if TYPE_CHECKING:
from agents.tracing import Trace
from posthog.client import Client
try:
import agents # noqa: F401
except ImportError:
raise ModuleNotFoundError(
"Please install the OpenAI Agents SDK to use this feature: 'pip install openai-agents'"
)
from posthog.ai.openai_agents.processor import PostHogTracingProcessor
__all__ = ["PostHogTracingProcessor", "instrument"]
def instrument(
client: Optional[Client] = None,
distinct_id: Optional[Union[str, Callable[[Trace], Optional[str]]]] = None,
privacy_mode: bool = False,
groups: Optional[Dict[str, Any]] = None,
properties: Optional[Dict[str, Any]] = None,
) -> PostHogTracingProcessor:
"""
One-liner to instrument OpenAI Agents SDK with PostHog tracing.
This registers a PostHogTracingProcessor with the OpenAI Agents SDK,
automatically capturing traces, spans, and LLM generations.
Args:
client: Optional PostHog client instance. If not provided, uses the default client.
distinct_id: Optional distinct ID to associate with all traces.
Can also be a callable that takes a trace and returns a distinct ID.
privacy_mode: If True, redacts input/output content from events.
groups: Optional PostHog groups to associate with events.
properties: Optional additional properties to include with all events.
Returns:
PostHogTracingProcessor: The registered processor instance.
Example:
```python
from posthog.ai.openai_agents import instrument
# Simple setup
instrument(distinct_id="user@example.com")
# With custom properties
instrument(
distinct_id="user@example.com",
privacy_mode=True,
properties={"environment": "production"}
)
# Now run agents as normal - traces automatically sent to PostHog
from agents import Agent, Runner
agent = Agent(name="Assistant", instructions="You are helpful.")
result = Runner.run_sync(agent, "Hello!")
```
"""
from agents.tracing import add_trace_processor
processor = PostHogTracingProcessor(
client=client,
distinct_id=distinct_id,
privacy_mode=privacy_mode,
groups=groups,
properties=properties,
)
add_trace_processor(processor)
return processor
-863
View File
@@ -1,863 +0,0 @@
import json
import logging
import time
from datetime import datetime
from typing import Any, Callable, Dict, Optional, Union
from agents.tracing import Span, Trace
from agents.tracing.processor_interface import TracingProcessor
from agents.tracing.span_data import (
AgentSpanData,
CustomSpanData,
FunctionSpanData,
GenerationSpanData,
GuardrailSpanData,
HandoffSpanData,
MCPListToolsSpanData,
ResponseSpanData,
SpeechGroupSpanData,
SpeechSpanData,
TranscriptionSpanData,
)
from posthog import setup
from posthog.client import Client
log = logging.getLogger("posthog")
def _ensure_serializable(obj: Any) -> Any:
"""Ensure an object is JSON-serializable, converting to str as fallback.
Returns the original object if it's already serializable (dict, list, str,
int, etc.), or str(obj) for non-serializable types so that downstream
json.dumps() calls won't fail.
"""
if obj is None:
return None
try:
json.dumps(obj)
return obj
except (TypeError, ValueError):
return str(obj)
def _parse_iso_timestamp(iso_str: Optional[str]) -> Optional[float]:
"""Parse ISO timestamp to Unix timestamp."""
if not iso_str:
return None
try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
return dt.timestamp()
except (ValueError, AttributeError):
return None
class PostHogTracingProcessor(TracingProcessor):
"""
A tracing processor that sends OpenAI Agents SDK traces to PostHog.
This processor implements the TracingProcessor interface from the OpenAI Agents SDK
and maps agent traces, spans, and generations to PostHog's LLM analytics events.
Example:
```python
from agents import Agent, Runner
from agents.tracing import add_trace_processor
from posthog.ai.openai_agents import PostHogTracingProcessor
# Create and register the processor
processor = PostHogTracingProcessor(
distinct_id="user@example.com",
privacy_mode=False,
)
add_trace_processor(processor)
# Run agents as normal - traces automatically sent to PostHog
agent = Agent(name="Assistant", instructions="You are helpful.")
result = Runner.run_sync(agent, "Hello!")
```
"""
def __init__(
self,
client: Optional[Client] = None,
distinct_id: Optional[Union[str, Callable[[Trace], Optional[str]]]] = None,
privacy_mode: bool = False,
groups: Optional[Dict[str, Any]] = None,
properties: Optional[Dict[str, Any]] = None,
):
"""
Initialize the PostHog tracing processor.
Args:
client: Optional PostHog client instance. If not provided, uses the default client.
distinct_id: Either a string distinct ID or a callable that takes a Trace
and returns a distinct ID. If not provided, uses the trace_id.
privacy_mode: If True, redacts input/output content from events.
groups: Optional PostHog groups to associate with all events.
properties: Optional additional properties to include with all events.
"""
self._client = client or setup()
self._distinct_id = distinct_id
self._privacy_mode = privacy_mode
self._groups = groups or {}
self._properties = properties or {}
# Track span start times for latency calculation
self._span_start_times: Dict[str, float] = {}
# Track trace metadata for associating with spans
self._trace_metadata: Dict[str, Dict[str, Any]] = {}
# Max entries to prevent unbounded growth if on_span_end/on_trace_end
# is never called (e.g., due to an exception in the Agents SDK).
self._max_tracked_entries = 10000
def _get_distinct_id(self, trace: Optional[Trace]) -> Optional[str]:
"""Resolve the distinct ID for a trace.
Returns the user-provided distinct ID (string or callable result),
or None if no user-provided ID is available. Callers should treat
None as a signal to use a fallback ID in personless mode.
"""
if callable(self._distinct_id):
if trace:
result = self._distinct_id(trace)
if result:
return str(result)
return None
elif self._distinct_id:
return str(self._distinct_id)
return None
def _with_privacy_mode(self, value: Any) -> Any:
"""Apply privacy mode redaction if enabled."""
if self._privacy_mode or (
hasattr(self._client, "privacy_mode") and self._client.privacy_mode
):
return None
return value
def _evict_stale_entries(self) -> None:
"""Evict oldest entries if dicts exceed max size to prevent unbounded growth."""
if len(self._span_start_times) > self._max_tracked_entries:
# Remove oldest entries by start time
sorted_spans = sorted(self._span_start_times.items(), key=lambda x: x[1])
for span_id, _ in sorted_spans[: len(sorted_spans) // 2]:
del self._span_start_times[span_id]
log.debug(
"Evicted stale span start times (exceeded %d entries)",
self._max_tracked_entries,
)
if len(self._trace_metadata) > self._max_tracked_entries:
# Remove half the entries (oldest inserted via dict ordering in Python 3.7+)
keys = list(self._trace_metadata.keys())
for key in keys[: len(keys) // 2]:
del self._trace_metadata[key]
log.debug(
"Evicted stale trace metadata (exceeded %d entries)",
self._max_tracked_entries,
)
def _get_group_id(self, trace_id: str) -> Optional[str]:
"""Get the group_id for a trace from stored metadata."""
if trace_id in self._trace_metadata:
return self._trace_metadata[trace_id].get("group_id")
return None
def _capture_event(
self,
event: str,
properties: Dict[str, Any],
distinct_id: Optional[str] = None,
) -> None:
"""Capture an event to PostHog with error handling.
Args:
distinct_id: The resolved distinct ID. When the user didn't provide
one, callers should pass ``user_distinct_id or fallback_id``
(matching the langchain/openai pattern) and separately set
``$process_person_profile`` in properties.
"""
try:
if not hasattr(self._client, "capture") or not callable(
self._client.capture
):
return
final_properties = {
**properties,
**self._properties,
}
self._client.capture(
distinct_id=distinct_id or "unknown",
event=event,
properties=final_properties,
groups=self._groups,
)
except Exception as e:
log.debug(f"Failed to capture PostHog event: {e}")
def on_trace_start(self, trace: Trace) -> None:
"""Called when a new trace begins. Stores metadata for spans; the $ai_trace event is emitted in on_trace_end."""
try:
self._evict_stale_entries()
trace_id = trace.trace_id
trace_name = trace.name
group_id = getattr(trace, "group_id", None)
metadata = getattr(trace, "metadata", None)
distinct_id = self._get_distinct_id(trace)
# Store trace metadata for later (used by spans and on_trace_end)
self._trace_metadata[trace_id] = {
"name": trace_name,
"group_id": group_id,
"metadata": metadata,
"distinct_id": distinct_id,
"start_time": time.time(),
}
except Exception as e:
log.debug(f"Error in on_trace_start: {e}")
def on_trace_end(self, trace: Trace) -> None:
"""Called when a trace completes. Emits the $ai_trace event with full metadata."""
try:
trace_id = trace.trace_id
# Pop stored metadata (also cleans up)
trace_info = self._trace_metadata.pop(trace_id, {})
trace_name = trace_info.get("name") or trace.name
group_id = trace_info.get("group_id") or getattr(trace, "group_id", None)
metadata = trace_info.get("metadata") or getattr(trace, "metadata", None)
distinct_id = trace_info.get("distinct_id") or self._get_distinct_id(trace)
# Calculate trace-level latency
start_time = trace_info.get("start_time")
latency = (time.time() - start_time) if start_time else None
properties = {
"$ai_trace_id": trace_id,
"$ai_trace_name": trace_name,
"$ai_provider": "openai",
"$ai_framework": "openai-agents",
}
if latency is not None:
properties["$ai_latency"] = latency
# Include group_id for linking related traces (e.g., conversation threads)
if group_id:
properties["$ai_group_id"] = group_id
# Include trace metadata if present
if metadata:
properties["$ai_trace_metadata"] = _ensure_serializable(metadata)
if distinct_id is None:
properties["$process_person_profile"] = False
self._capture_event(
event="$ai_trace",
distinct_id=distinct_id or trace_id,
properties=properties,
)
except Exception as e:
log.debug(f"Error in on_trace_end: {e}")
def on_span_start(self, span: Span[Any]) -> None:
"""Called when a new span begins."""
try:
self._evict_stale_entries()
span_id = span.span_id
self._span_start_times[span_id] = time.time()
except Exception as e:
log.debug(f"Error in on_span_start: {e}")
def on_span_end(self, span: Span[Any]) -> None:
"""Called when a span completes."""
try:
span_id = span.span_id
trace_id = span.trace_id
parent_id = span.parent_id
span_data = span.span_data
# Calculate latency
start_time = self._span_start_times.pop(span_id, None)
if start_time:
latency = time.time() - start_time
else:
# Fall back to parsing timestamps
started = _parse_iso_timestamp(span.started_at)
ended = _parse_iso_timestamp(span.ended_at)
latency = (ended - started) if (started and ended) else 0
# Get user-provided distinct ID from trace metadata (resolved at trace start).
# None means no user-provided ID — use trace_id as fallback in personless mode,
# matching the langchain/openai pattern: `distinct_id or trace_id`.
trace_info = self._trace_metadata.get(trace_id, {})
distinct_id = trace_info.get("distinct_id") or self._get_distinct_id(None)
# Get group_id from trace metadata for linking
group_id = self._get_group_id(trace_id)
# Get error info if present
error_info = span.error
error_properties = {}
if error_info:
if isinstance(error_info, dict):
error_message = error_info.get("message", str(error_info))
error_type_raw = error_info.get("type", "")
else:
error_message = str(error_info)
error_type_raw = ""
# Categorize error type for cross-provider filtering/alerting
error_type = "unknown"
if (
"ModelBehaviorError" in error_type_raw
or "ModelBehaviorError" in error_message
):
error_type = "model_behavior_error"
elif "UserError" in error_type_raw or "UserError" in error_message:
error_type = "user_error"
elif (
"InputGuardrailTripwireTriggered" in error_type_raw
or "InputGuardrailTripwireTriggered" in error_message
):
error_type = "input_guardrail_triggered"
elif (
"OutputGuardrailTripwireTriggered" in error_type_raw
or "OutputGuardrailTripwireTriggered" in error_message
):
error_type = "output_guardrail_triggered"
elif (
"MaxTurnsExceeded" in error_type_raw
or "MaxTurnsExceeded" in error_message
):
error_type = "max_turns_exceeded"
error_properties = {
"$ai_is_error": True,
"$ai_error": error_message,
"$ai_error_type": error_type,
}
# Personless mode: no user-provided distinct_id, fallback to trace_id
if distinct_id is None:
error_properties["$process_person_profile"] = False
distinct_id = trace_id
# Dispatch based on span data type
if isinstance(span_data, GenerationSpanData):
self._handle_generation_span(
span_data,
trace_id,
span_id,
parent_id,
latency,
distinct_id,
group_id,
error_properties,
)
elif isinstance(span_data, FunctionSpanData):
self._handle_function_span(
span_data,
trace_id,
span_id,
parent_id,
latency,
distinct_id,
group_id,
error_properties,
)
elif isinstance(span_data, AgentSpanData):
self._handle_agent_span(
span_data,
trace_id,
span_id,
parent_id,
latency,
distinct_id,
group_id,
error_properties,
)
elif isinstance(span_data, HandoffSpanData):
self._handle_handoff_span(
span_data,
trace_id,
span_id,
parent_id,
latency,
distinct_id,
group_id,
error_properties,
)
elif isinstance(span_data, GuardrailSpanData):
self._handle_guardrail_span(
span_data,
trace_id,
span_id,
parent_id,
latency,
distinct_id,
group_id,
error_properties,
)
elif isinstance(span_data, ResponseSpanData):
self._handle_response_span(
span_data,
trace_id,
span_id,
parent_id,
latency,
distinct_id,
group_id,
error_properties,
)
elif isinstance(span_data, CustomSpanData):
self._handle_custom_span(
span_data,
trace_id,
span_id,
parent_id,
latency,
distinct_id,
group_id,
error_properties,
)
elif isinstance(
span_data, (TranscriptionSpanData, SpeechSpanData, SpeechGroupSpanData)
):
self._handle_audio_span(
span_data,
trace_id,
span_id,
parent_id,
latency,
distinct_id,
group_id,
error_properties,
)
elif isinstance(span_data, MCPListToolsSpanData):
self._handle_mcp_span(
span_data,
trace_id,
span_id,
parent_id,
latency,
distinct_id,
group_id,
error_properties,
)
else:
# Unknown span type - capture as generic span
self._handle_generic_span(
span_data,
trace_id,
span_id,
parent_id,
latency,
distinct_id,
group_id,
error_properties,
)
except Exception as e:
log.debug(f"Error in on_span_end: {e}")
def _base_properties(
self,
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> Dict[str, Any]:
"""Build the base properties dict shared by all span handlers."""
properties = {
"$ai_trace_id": trace_id,
"$ai_span_id": span_id,
"$ai_parent_id": parent_id,
"$ai_provider": "openai",
"$ai_framework": "openai-agents",
"$ai_latency": latency,
**error_properties,
}
if group_id:
properties["$ai_group_id"] = group_id
return properties
def _handle_generation_span(
self,
span_data: GenerationSpanData,
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
distinct_id: str,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> None:
"""Handle LLM generation spans - maps to $ai_generation event."""
# Extract token usage
usage = span_data.usage or {}
input_tokens = usage.get("input_tokens") or usage.get("prompt_tokens") or 0
output_tokens = (
usage.get("output_tokens") or usage.get("completion_tokens") or 0
)
# Extract model config parameters
model_config = span_data.model_config or {}
model_params = {}
for param in [
"temperature",
"max_tokens",
"top_p",
"frequency_penalty",
"presence_penalty",
]:
if param in model_config:
model_params[param] = model_config[param]
properties = {
**self._base_properties(
trace_id, span_id, parent_id, latency, group_id, error_properties
),
"$ai_model": span_data.model,
"$ai_model_parameters": model_params if model_params else None,
"$ai_input": self._with_privacy_mode(_ensure_serializable(span_data.input)),
"$ai_output_choices": self._with_privacy_mode(
_ensure_serializable(span_data.output)
),
"$ai_input_tokens": input_tokens,
"$ai_output_tokens": output_tokens,
"$ai_total_tokens": (input_tokens or 0) + (output_tokens or 0),
}
# Add optional token fields if present
if usage.get("reasoning_tokens"):
properties["$ai_reasoning_tokens"] = usage["reasoning_tokens"]
if usage.get("cache_read_input_tokens"):
properties["$ai_cache_read_input_tokens"] = usage["cache_read_input_tokens"]
if usage.get("cache_creation_input_tokens"):
properties["$ai_cache_creation_input_tokens"] = usage[
"cache_creation_input_tokens"
]
self._capture_event("$ai_generation", properties, distinct_id)
def _handle_function_span(
self,
span_data: FunctionSpanData,
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
distinct_id: str,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> None:
"""Handle function/tool call spans - maps to $ai_span event."""
properties = {
**self._base_properties(
trace_id, span_id, parent_id, latency, group_id, error_properties
),
"$ai_span_name": span_data.name,
"$ai_span_type": "tool",
"$ai_input_state": self._with_privacy_mode(
_ensure_serializable(span_data.input)
),
"$ai_output_state": self._with_privacy_mode(
_ensure_serializable(span_data.output)
),
}
if span_data.mcp_data:
properties["$ai_mcp_data"] = _ensure_serializable(span_data.mcp_data)
self._capture_event("$ai_span", properties, distinct_id)
def _handle_agent_span(
self,
span_data: AgentSpanData,
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
distinct_id: str,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> None:
"""Handle agent execution spans - maps to $ai_span event."""
properties = {
**self._base_properties(
trace_id, span_id, parent_id, latency, group_id, error_properties
),
"$ai_span_name": span_data.name,
"$ai_span_type": "agent",
}
if span_data.handoffs:
properties["$ai_agent_handoffs"] = span_data.handoffs
if span_data.tools:
properties["$ai_agent_tools"] = span_data.tools
if span_data.output_type:
properties["$ai_agent_output_type"] = span_data.output_type
self._capture_event("$ai_span", properties, distinct_id)
def _handle_handoff_span(
self,
span_data: HandoffSpanData,
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
distinct_id: str,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> None:
"""Handle agent handoff spans - maps to $ai_span event."""
properties = {
**self._base_properties(
trace_id, span_id, parent_id, latency, group_id, error_properties
),
"$ai_span_name": f"{span_data.from_agent} -> {span_data.to_agent}",
"$ai_span_type": "handoff",
"$ai_handoff_from_agent": span_data.from_agent,
"$ai_handoff_to_agent": span_data.to_agent,
}
self._capture_event("$ai_span", properties, distinct_id)
def _handle_guardrail_span(
self,
span_data: GuardrailSpanData,
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
distinct_id: str,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> None:
"""Handle guardrail execution spans - maps to $ai_span event."""
properties = {
**self._base_properties(
trace_id, span_id, parent_id, latency, group_id, error_properties
),
"$ai_span_name": span_data.name,
"$ai_span_type": "guardrail",
"$ai_guardrail_triggered": span_data.triggered,
}
self._capture_event("$ai_span", properties, distinct_id)
def _handle_response_span(
self,
span_data: ResponseSpanData,
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
distinct_id: str,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> None:
"""Handle OpenAI Response API spans - maps to $ai_generation event."""
response = span_data.response
response_id = response.id if response else None
# Try to extract usage from response
usage = getattr(response, "usage", None) if response else None
input_tokens = 0
output_tokens = 0
if usage:
input_tokens = getattr(usage, "input_tokens", 0) or 0
output_tokens = getattr(usage, "output_tokens", 0) or 0
# Try to extract model from response
model = getattr(response, "model", None) if response else None
properties = {
**self._base_properties(
trace_id, span_id, parent_id, latency, group_id, error_properties
),
"$ai_model": model,
"$ai_response_id": response_id,
"$ai_input": self._with_privacy_mode(_ensure_serializable(span_data.input)),
"$ai_input_tokens": input_tokens,
"$ai_output_tokens": output_tokens,
"$ai_total_tokens": input_tokens + output_tokens,
}
# Extract output content from response
if response:
output_items = getattr(response, "output", None)
if output_items:
properties["$ai_output_choices"] = self._with_privacy_mode(
_ensure_serializable(output_items)
)
self._capture_event("$ai_generation", properties, distinct_id)
def _handle_custom_span(
self,
span_data: CustomSpanData,
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
distinct_id: str,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> None:
"""Handle custom user-defined spans - maps to $ai_span event."""
properties = {
**self._base_properties(
trace_id, span_id, parent_id, latency, group_id, error_properties
),
"$ai_span_name": span_data.name,
"$ai_span_type": "custom",
"$ai_custom_data": self._with_privacy_mode(
_ensure_serializable(span_data.data)
),
}
self._capture_event("$ai_span", properties, distinct_id)
def _handle_audio_span(
self,
span_data: Union[TranscriptionSpanData, SpeechSpanData, SpeechGroupSpanData],
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
distinct_id: str,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> None:
"""Handle audio-related spans (transcription, speech) - maps to $ai_span event."""
span_type = span_data.type # "transcription", "speech", or "speech_group"
properties = {
**self._base_properties(
trace_id, span_id, parent_id, latency, group_id, error_properties
),
"$ai_span_name": span_type,
"$ai_span_type": span_type,
}
# Add model info if available
if hasattr(span_data, "model") and span_data.model:
properties["$ai_model"] = span_data.model
# Add model config if available (pass-through property)
if hasattr(span_data, "model_config") and span_data.model_config:
properties["model_config"] = _ensure_serializable(span_data.model_config)
# Add time to first audio byte for speech spans (pass-through property)
if hasattr(span_data, "first_content_at") and span_data.first_content_at:
properties["first_content_at"] = span_data.first_content_at
# Add audio format info (pass-through properties)
if hasattr(span_data, "input_format"):
properties["audio_input_format"] = span_data.input_format
if hasattr(span_data, "output_format"):
properties["audio_output_format"] = span_data.output_format
# Add text input for TTS
if (
hasattr(span_data, "input")
and span_data.input
and isinstance(span_data.input, str)
):
properties["$ai_input"] = self._with_privacy_mode(span_data.input)
# Don't include audio data (base64) - just metadata
if hasattr(span_data, "output") and isinstance(span_data.output, str):
# For transcription, output is the text
properties["$ai_output_state"] = self._with_privacy_mode(span_data.output)
self._capture_event("$ai_span", properties, distinct_id)
def _handle_mcp_span(
self,
span_data: MCPListToolsSpanData,
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
distinct_id: str,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> None:
"""Handle MCP (Model Context Protocol) spans - maps to $ai_span event."""
properties = {
**self._base_properties(
trace_id, span_id, parent_id, latency, group_id, error_properties
),
"$ai_span_name": f"mcp:{span_data.server}",
"$ai_span_type": "mcp_tools",
"$ai_mcp_server": span_data.server,
"$ai_mcp_tools": span_data.result,
}
self._capture_event("$ai_span", properties, distinct_id)
def _handle_generic_span(
self,
span_data: Any,
trace_id: str,
span_id: str,
parent_id: Optional[str],
latency: float,
distinct_id: str,
group_id: Optional[str],
error_properties: Dict[str, Any],
) -> None:
"""Handle unknown span types - maps to $ai_span event."""
span_type = getattr(span_data, "type", "unknown")
properties = {
**self._base_properties(
trace_id, span_id, parent_id, latency, group_id, error_properties
),
"$ai_span_name": span_type,
"$ai_span_type": span_type,
}
# Try to export span data
if hasattr(span_data, "export"):
try:
exported = span_data.export()
properties["$ai_span_data"] = _ensure_serializable(exported)
except Exception:
pass
self._capture_event("$ai_span", properties, distinct_id)
def shutdown(self) -> None:
"""Clean up resources when the application stops."""
try:
self._span_start_times.clear()
self._trace_metadata.clear()
# Flush the PostHog client if possible
if hasattr(self._client, "flush") and callable(self._client.flush):
self._client.flush()
except Exception as e:
log.debug(f"Error in shutdown: {e}")
def force_flush(self) -> None:
"""Force immediate processing of any queued events."""
try:
if hasattr(self._client, "flush") and callable(self._client.flush):
self._client.flush()
except Exception as e:
log.debug(f"Error in force_flush: {e}")
-286
View File
@@ -1,286 +0,0 @@
"""
Prompt management for PostHog AI SDK.
Fetch and compile LLM prompts from PostHog with caching and fallback support.
"""
import logging
import re
import time
import urllib.parse
from typing import Any, Dict, Optional, Union
from posthog.request import USER_AGENT, _get_session
from posthog.utils import remove_trailing_slash
log = logging.getLogger("posthog")
APP_ENDPOINT = "https://us.posthog.com"
DEFAULT_CACHE_TTL_SECONDS = 300 # 5 minutes
PromptVariables = Dict[str, Union[str, int, float, bool]]
class CachedPrompt:
"""Cached prompt with metadata."""
def __init__(self, prompt: str, fetched_at: float):
self.prompt = prompt
self.fetched_at = fetched_at
def _is_prompt_api_response(data: Any) -> bool:
"""Check if the response is a valid prompt API response."""
return (
isinstance(data, dict)
and "prompt" in data
and isinstance(data.get("prompt"), str)
)
class Prompts:
"""
Fetch and compile LLM prompts from PostHog.
Can be initialized with a PostHog client or with direct options.
Examples:
```python
from posthog import Posthog
from posthog.ai.prompts import Prompts
# With PostHog client
posthog = Posthog('phc_xxx', host='https://us.posthog.com', personal_api_key='phx_xxx')
prompts = Prompts(posthog)
# Or with direct options (no PostHog client needed)
prompts = Prompts(
personal_api_key='phx_xxx',
project_api_key='phc_xxx',
host='https://us.posthog.com',
)
# Fetch with caching and fallback
template = prompts.get('support-system-prompt', fallback='You are a helpful assistant.')
# Compile with variables
system_prompt = prompts.compile(template, {
'company': 'Acme Corp',
'tier': 'premium',
})
```
"""
def __init__(
self,
posthog: Optional[Any] = None,
*,
personal_api_key: Optional[str] = None,
project_api_key: Optional[str] = None,
host: Optional[str] = None,
default_cache_ttl_seconds: Optional[int] = None,
):
"""
Initialize Prompts.
Args:
posthog: PostHog client instance (optional if personal_api_key provided)
personal_api_key: Direct personal API key (optional if posthog provided)
project_api_key: Direct project API key (optional if posthog provided)
host: PostHog host (defaults to app endpoint)
default_cache_ttl_seconds: Default cache TTL (defaults to 300)
"""
self._default_cache_ttl_seconds = (
default_cache_ttl_seconds or DEFAULT_CACHE_TTL_SECONDS
)
self._cache: Dict[str, CachedPrompt] = {}
if posthog is not None:
self._personal_api_key = getattr(posthog, "personal_api_key", None) or ""
self._project_api_key = getattr(posthog, "api_key", None) or ""
self._host = remove_trailing_slash(
getattr(posthog, "raw_host", None) or APP_ENDPOINT
)
else:
self._personal_api_key = personal_api_key or ""
self._project_api_key = project_api_key or ""
self._host = remove_trailing_slash(host or APP_ENDPOINT)
def get(
self,
name: str,
*,
cache_ttl_seconds: Optional[int] = None,
fallback: Optional[str] = None,
) -> str:
"""
Fetch a prompt by name from the PostHog API.
Caching behavior:
1. If cache is fresh, return cached value
2. If fetch fails and cache exists (stale), return stale cache with warning
3. If fetch fails and fallback provided, return fallback with warning
4. If fetch fails with no cache/fallback, raise exception
Args:
name: The name of the prompt to fetch
cache_ttl_seconds: Cache TTL in seconds (defaults to instance default)
fallback: Fallback prompt to use if fetch fails and no cache available
Returns:
The prompt string
Raises:
Exception: If the prompt cannot be fetched and no fallback is available
"""
ttl = (
cache_ttl_seconds
if cache_ttl_seconds is not None
else self._default_cache_ttl_seconds
)
# Check cache first
cached = self._cache.get(name)
now = time.time()
if cached is not None:
is_fresh = (now - cached.fetched_at) < ttl
if is_fresh:
return cached.prompt
# Try to fetch from API
try:
prompt = self._fetch_prompt_from_api(name)
fetched_at = time.time()
# Update cache
self._cache[name] = CachedPrompt(prompt=prompt, fetched_at=fetched_at)
return prompt
except Exception as error:
# Fallback order:
# 1. Return stale cache (with warning)
if cached is not None:
log.warning(
'[PostHog Prompts] Failed to fetch prompt "%s", using stale cache: %s',
name,
error,
)
return cached.prompt
# 2. Return fallback (with warning)
if fallback is not None:
log.warning(
'[PostHog Prompts] Failed to fetch prompt "%s", using fallback: %s',
name,
error,
)
return fallback
# 3. Raise error
raise
def compile(self, prompt: str, variables: PromptVariables) -> str:
"""
Replace {{variableName}} placeholders with values.
Unmatched variables are left unchanged.
Supports variable names with hyphens and dots (e.g., user-id, company.name).
Args:
prompt: The prompt template string
variables: Object containing variable values
Returns:
The compiled prompt string
"""
def replace_variable(match: re.Match) -> str:
variable_name = match.group(1)
if variable_name in variables:
return str(variables[variable_name])
return match.group(0)
return re.sub(r"\{\{([\w.-]+)\}\}", replace_variable, prompt)
def clear_cache(self, name: Optional[str] = None) -> None:
"""
Clear cached prompts.
Args:
name: Specific prompt to clear. If None, clears all cached prompts.
"""
if name is not None:
self._cache.pop(name, None)
else:
self._cache.clear()
def _fetch_prompt_from_api(self, name: str) -> str:
"""
Fetch prompt from PostHog API.
Endpoint: {host}/api/environments/@current/llm_prompts/name/{encoded_name}/?token={encoded_project_api_key}
Auth: Bearer {personal_api_key}
Args:
name: The name of the prompt to fetch
Returns:
The prompt string
Raises:
Exception: If the prompt cannot be fetched
"""
if not self._personal_api_key:
raise Exception(
"[PostHog Prompts] personal_api_key is required to fetch prompts. "
"Please provide it when initializing the Prompts instance."
)
if not self._project_api_key:
raise Exception(
"[PostHog Prompts] project_api_key is required to fetch prompts. "
"Please provide it when initializing the Prompts instance."
)
encoded_name = urllib.parse.quote(name, safe="")
encoded_project_api_key = urllib.parse.quote(self._project_api_key, safe="")
url = f"{self._host}/api/environments/@current/llm_prompts/name/{encoded_name}/?token={encoded_project_api_key}"
headers = {
"Authorization": f"Bearer {self._personal_api_key}",
"User-Agent": USER_AGENT,
}
response = _get_session().get(url, headers=headers, timeout=10)
if not response.ok:
if response.status_code == 404:
raise Exception(f'[PostHog Prompts] Prompt "{name}" not found')
if response.status_code == 403:
raise Exception(
f'[PostHog Prompts] Access denied for prompt "{name}". '
"Check that your personal_api_key has the correct permissions and the LLM prompts feature is enabled."
)
raise Exception(
f'[PostHog Prompts] Failed to fetch prompt "{name}": HTTP {response.status_code}'
)
try:
data = response.json()
except Exception:
raise Exception(
f'[PostHog Prompts] Invalid response format for prompt "{name}"'
)
if not _is_prompt_api_response(data):
raise Exception(
f'[PostHog Prompts] Invalid response format for prompt "{name}"'
)
return data["prompt"]
+5 -33
View File
@@ -1,4 +1,3 @@
import os
import re
from typing import Any
from urllib.parse import urlparse
@@ -6,15 +5,6 @@ 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
@@ -37,9 +27,6 @@ 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,12 +70,6 @@ def sanitize_openai_image(item: Any) -> Any:
if not isinstance(item, dict):
return item
if item.get("type") == "input_image" and isinstance(item.get("image_url"), str):
return {
**item,
"image_url": redact_base64_data_url(item["image_url"]),
}
if (
item.get("type") == "image_url"
and isinstance(item.get("image_url"), dict)
@@ -102,11 +83,6 @@ 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
@@ -124,9 +100,6 @@ 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
@@ -136,6 +109,8 @@ 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": {
@@ -148,9 +123,6 @@ 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
@@ -159,6 +131,8 @@ 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": {
@@ -211,9 +185,7 @@ def sanitize_langchain_image(item: Any) -> Any:
and isinstance(item.get("source"), dict)
and "data" in item["source"]
):
if _is_multimodal_enabled():
return item
# Anthropic style - raw base64 in structured format, always redact
return {
**item,
"source": {
-2
View File
@@ -63,8 +63,6 @@ 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]
raw_usage: Optional[Any] # Raw provider usage metadata for backend processing
class ProviderResponse(TypedDict, total=False):
+151 -329
View File
@@ -2,85 +2,14 @@ import time
import uuid
from typing import Any, Callable, Dict, List, Optional, cast
from posthog import get_tags, identify_context, new_context, tag
from posthog.client import Client as PostHogClient
from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage
from posthog.ai.sanitization import (
sanitize_openai,
sanitize_anthropic,
sanitize_gemini,
sanitize_langchain,
sanitize_openai,
)
from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage
from posthog.client import Client as PostHogClient
_TOKEN_PROPERTY_KEYS = frozenset(
{
"$ai_input_tokens",
"$ai_output_tokens",
"$ai_cache_read_input_tokens",
"$ai_cache_creation_input_tokens",
"$ai_total_tokens",
"$ai_reasoning_tokens",
}
)
def _get_tokens_source(
sdk_tags: Dict[str, Any], posthog_properties: Optional[Dict[str, Any]]
) -> str:
if posthog_properties and any(
key in posthog_properties for key in _TOKEN_PROPERTY_KEYS
):
return "passthrough"
return "sdk"
def serialize_raw_usage(raw_usage: Any) -> Optional[Dict[str, Any]]:
"""
Convert raw provider usage objects to JSON-serializable dicts.
Handles Pydantic models (OpenAI/Anthropic) and protobuf-like objects (Gemini)
with a fallback chain to ensure we never pass unserializable objects to PostHog.
Args:
raw_usage: Raw usage object from provider SDK
Returns:
Plain dict or None if conversion fails
"""
if raw_usage is None:
return None
# Already a dict
if isinstance(raw_usage, dict):
return raw_usage
# Try Pydantic model_dump() (OpenAI/Anthropic)
if hasattr(raw_usage, "model_dump") and callable(raw_usage.model_dump):
try:
return raw_usage.model_dump()
except Exception:
pass
# Try to_dict() (some protobuf objects)
if hasattr(raw_usage, "to_dict") and callable(raw_usage.to_dict):
try:
return raw_usage.to_dict()
except Exception:
pass
# Try __dict__ / vars() for simple objects
try:
return vars(raw_usage)
except Exception:
pass
# Last resort: convert to string representation
# This ensures we always return something rather than failing
try:
return {"_raw": str(raw_usage)}
except Exception:
return None
def merge_usage_stats(
@@ -124,23 +53,6 @@ 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)
# Merge raw_usage to avoid losing data from earlier events
# For Anthropic streaming: message_start has input tokens, message_delta has output
# Note: raw_usage is already serialized by converters, so it's a dict
source_raw_usage = source.get("raw_usage")
if source_raw_usage is not None and isinstance(source_raw_usage, dict):
current_raw_value = target.get("raw_usage")
current_raw: Dict[str, Any] = (
current_raw_value if isinstance(current_raw_value, dict) else {}
)
target["raw_usage"] = {**current_raw, **source_raw_usage}
elif mode == "cumulative":
# Replace with latest values (already cumulative)
if source.get("input_tokens") is not None:
@@ -155,12 +67,6 @@ 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"]
# Note: raw_usage is already serialized by converters, so it's a dict
if source.get("raw_usage") is not None:
target["raw_usage"] = source["raw_usage"]
else:
raise ValueError(f"Invalid mode: {mode}. Must be 'incremental' or 'cumulative'")
@@ -341,118 +247,90 @@ def call_llm_and_track_usage(
usage: TokenUsage = TokenUsage()
error_params: Dict[str, Any] = {}
with new_context(client=ph_client, capture_exceptions=False):
if posthog_distinct_id:
identify_context(posthog_distinct_id)
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
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and (
hasattr(response, "usage")
or (provider == "gemini" and hasattr(response, "usage_metadata"))
):
usage = get_usage(response, provider)
if response and (
hasattr(response, "usage")
or (provider == "gemini" and hasattr(response, "usage_metadata"))
):
usage = get_usage(response, provider)
messages = merge_system_prompt(kwargs, provider)
sanitized_messages = sanitize_messages(messages, provider)
messages = merge_system_prompt(kwargs, provider)
sanitized_messages = sanitize_messages(messages, provider)
event_properties = {
"$ai_provider": provider,
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
ph_client, posthog_privacy_mode, 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 {}),
}
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),
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_output_choices",
with_privacy_mode(
ph_client, posthog_privacy_mode, format_response(response, provider)
),
# 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_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))
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)
raw_usage = usage.get("raw_usage")
if raw_usage is not None:
# Already serialized by converters
tag("$ai_usage", raw_usage)
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):
sdk_tags = get_tags()
merged_properties = {
**sdk_tags,
**(posthog_properties or {}),
**(error_params or {}),
}
merged_properties["$ai_tokens_source"] = _get_tokens_source(
sdk_tags, posthog_properties
)
ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=merged_properties,
groups=posthog_groups,
)
if error:
raise error
if error:
raise error
return response
@@ -476,118 +354,86 @@ async def call_llm_and_track_usage_async(
usage: TokenUsage = TokenUsage()
error_params: Dict[str, Any] = {}
with new_context(client=ph_client, capture_exceptions=False):
if posthog_distinct_id:
identify_context(posthog_distinct_id)
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
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and (
hasattr(response, "usage")
or (provider == "gemini" and hasattr(response, "usage_metadata"))
):
usage = get_usage(response, provider)
if response and (
hasattr(response, "usage")
or (provider == "gemini" and hasattr(response, "usage_metadata"))
):
usage = get_usage(response, provider)
messages = merge_system_prompt(kwargs, provider)
sanitized_messages = sanitize_messages(messages, provider)
messages = merge_system_prompt(kwargs, provider)
sanitized_messages = sanitize_messages(messages, provider)
event_properties = {
"$ai_provider": provider,
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
ph_client, posthog_privacy_mode, 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 {}),
}
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),
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_output_choices",
with_privacy_mode(
ph_client, posthog_privacy_mode, format_response(response, provider)
),
# 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_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))
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)
raw_usage = usage.get("raw_usage")
if raw_usage is not None:
# Already serialized by converters
tag("$ai_usage", raw_usage)
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):
sdk_tags = get_tags()
merged_properties = {
**sdk_tags,
**(posthog_properties or {}),
**(error_params or {}),
}
merged_properties["$ai_tokens_source"] = _get_tokens_source(
sdk_tags, posthog_properties
)
ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=merged_properties,
groups=posthog_groups,
)
if error:
raise error
if error:
raise error
return response
@@ -659,15 +505,6 @@ def capture_streaming_event(
**(event_data.get("properties") or {}),
}
# Determine token source: SDK-computed vs externally overridden
sdk_token_tags = {
"$ai_input_tokens": event_data["usage_stats"].get("input_tokens", 0),
"$ai_output_tokens": event_data["usage_stats"].get("output_tokens", 0),
}
event_properties["$ai_tokens_source"] = _get_tokens_source(
sdk_token_tags, event_data.get("properties")
)
# Extract and add tools based on provider
available_tools = extract_available_tool_calls(
event_data["provider"],
@@ -698,21 +535,6 @@ 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
# Add raw usage metadata if present (all providers)
raw_usage = event_data["usage_stats"].get("raw_usage")
if raw_usage is not None:
# Already serialized by converters
event_properties["$ai_usage"] = raw_usage
# Handle provider-specific fields
if (
event_data["provider"] == "openai"
+31 -1
View File
@@ -1,4 +1,4 @@
from typing import TypedDict, Optional, Any, Dict, Union, Tuple, Type
from typing import TypedDict, Optional, Any, Dict, List, Union, Tuple, Type
from types import TracebackType
from typing_extensions import NotRequired # For Python < 3.11 compatibility
from datetime import datetime
@@ -69,3 +69,33 @@ ExcInfo = Union[
]
ExceptionArg = Union[BaseException, ExcInfo]
# AI Event Types (literal strings to enforce valid event types)
AI_EVENT_TYPE = Union[
str, # Allow str for flexibility but document the expected values
] # "$ai_generation", "$ai_trace", "$ai_span", "$ai_embedding", "$ai_metric", "$ai_feedback"
class OptionalCaptureAIArgs(TypedDict):
"""Optional arguments for the capture_ai method.
Args:
distinct_id: Unique identifier for the person associated with this AI event. If not set, the context
distinct_id is used, if available, otherwise a UUID is generated.
properties: Dictionary of AI event properties to track. Must include required properties for the event type.
blob_properties: List of property names that should be sent as blobs (e.g., '$ai_input', '$ai_output_choices').
These properties will be extracted from `properties` and sent as multipart blobs.
timestamp: When the event occurred (defaults to current time)
uuid: Unique identifier for this specific event. If not provided, one is generated.
groups: Group identifiers to associate with this event (format: {group_type: group_key})
disable_geoip: Whether to disable GeoIP lookup for this event.
"""
distinct_id: NotRequired[Optional[ID_TYPES]]
properties: NotRequired[Optional[Dict[str, Any]]]
blob_properties: NotRequired[Optional[List[str]]]
timestamp: NotRequired[Optional[Union[datetime, str]]]
uuid: NotRequired[Optional[str]]
groups: NotRequired[Optional[Dict[str, str]]]
disable_geoip: NotRequired[Optional[bool]]
+401 -379
View File
File diff suppressed because it is too large Load Diff
+21 -35
View File
@@ -3,6 +3,8 @@ import logging
import time
from threading import Thread
import backoff
from posthog.request import APIError, DatetimeSerializer, batch_post
try:
@@ -82,16 +84,12 @@ class Consumer(Thread):
self.log.error("error uploading: %s", e)
success = False
if self.on_error:
try:
self.on_error(e, batch)
except Exception as e:
self.log.error("on_error handler failed: %s", e)
self.on_error(e, batch)
finally:
# mark items as acknowledged from queue
for item in batch:
self.queue.task_done()
return success
return success
def next(self):
"""Return the next batch of items to upload."""
@@ -126,41 +124,29 @@ class Consumer(Thread):
def request(self, batch):
"""Attempt to upload the batch and retry before raising an error"""
def is_retryable(exc):
def fatal_exception(exc):
if isinstance(exc, APIError):
# retry on server errors and client errors
# with 408 (request timeout) or 429 (rate limited),
# with 429 status code (rate limited),
# don't retry on other client errors
if exc.status == "N/A":
return False
return not ((400 <= exc.status < 500) and exc.status not in (408, 429))
return (400 <= exc.status < 500) and exc.status != 429
else:
# retry on all other errors (eg. network)
return True
return False
last_exc = None
for attempt in range(self.retries + 1):
try:
batch_post(
self.api_key,
self.host,
gzip=self.gzip,
timeout=self.timeout,
batch=batch,
historical_migration=self.historical_migration,
)
return
except Exception as e:
last_exc = e
if not is_retryable(e):
raise
if attempt < self.retries:
# Respect Retry-After header if present, otherwise use exponential backoff
retry_after = getattr(e, "retry_after", None)
if retry_after and retry_after > 0:
time.sleep(retry_after)
else:
time.sleep(min(2**attempt, 30))
@backoff.on_exception(
backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception
)
def send_request():
batch_post(
self.api_key,
self.host,
gzip=self.gzip,
timeout=self.timeout,
batch=batch,
historical_migration=self.historical_migration,
)
if last_exc:
raise last_exc
send_request()
+6 -130
View File
@@ -21,11 +21,7 @@ class ContextScope:
self.capture_exceptions = capture_exceptions
self.session_id: Optional[str] = None
self.distinct_id: Optional[str] = None
self.device_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
@@ -33,21 +29,9 @@ class ContextScope:
def set_distinct_id(self, distinct_id: str):
self.distinct_id = distinct_id
def set_device_id(self, device_id: str):
self.device_id = device_id
def add_tag(self, key: str, value: Any):
self.tags[key] = value
def set_capture_exception_code_variables(self, enabled: bool):
self.capture_exception_code_variables = enabled
def set_code_variables_mask_patterns(self, mask_patterns: list):
self.code_variables_mask_patterns = mask_patterns
def set_code_variables_ignore_patterns(self, ignore_patterns: list):
self.code_variables_ignore_patterns = ignore_patterns
def get_parent(self):
return self.parent
@@ -65,42 +49,15 @@ class ContextScope:
return self.parent.get_distinct_id()
return None
def get_device_id(self) -> Optional[str]:
if self.device_id is not None:
return self.device_id
if self.parent is not None and not self.fresh:
return self.parent.get_device_id()
return None
def collect_tags(self) -> Dict[str, Any]:
tags = self.tags.copy()
if self.parent and not self.fresh:
# We want child tags to take precedence over parent tags,
# so 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
# so we can't use a simple update here, instead collecting
# the parent tags and then updating with the child tags.
new_tags = self.parent.collect_tags()
tags.update(new_tags)
return tags
_context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar(
@@ -286,87 +243,6 @@ def get_context_distinct_id() -> Optional[str]:
return None
def set_context_device_id(device_id: str) -> None:
"""
Set the device ID for the current context, associating all feature flag requests in this or
child contexts with the given device ID (unless set_context_device_id is called again).
Entering a fresh context will clear the context-level device ID.
Args:
device_id: The device ID to associate with the current context and its children.
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
current_context.set_device_id(device_id)
def get_context_device_id() -> Optional[str]:
"""
Get the device ID for the current context.
Returns:
The device ID if set, None otherwise
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
return current_context.get_device_id()
return None
def set_capture_exception_code_variables_context(enabled: bool) -> None:
"""
Set whether code variables are captured for the current context.
"""
current_context = _get_current_context()
if current_context:
current_context.set_capture_exception_code_variables(enabled)
def set_code_variables_mask_patterns_context(mask_patterns: list) -> None:
"""
Variable names matching these patterns will be masked with *** when capturing code variables.
"""
current_context = _get_current_context()
if current_context:
current_context.set_code_variables_mask_patterns(mask_patterns)
def set_code_variables_ignore_patterns_context(ignore_patterns: list) -> None:
"""
Variable names matching these patterns will be ignored completely when capturing code variables.
"""
current_context = _get_current_context()
if current_context:
current_context.set_code_variables_ignore_patterns(ignore_patterns)
def get_capture_exception_code_variables_context() -> Optional[bool]:
current_context = _get_current_context()
if current_context:
return current_context.get_capture_exception_code_variables()
return None
def get_code_variables_mask_patterns_context() -> Optional[list]:
current_context = _get_current_context()
if current_context:
return current_context.get_code_variables_mask_patterns()
return None
def get_code_variables_ignore_patterns_context() -> Optional[list]:
current_context = _get_current_context()
if current_context:
return current_context.get_code_variables_ignore_patterns()
return None
F = TypeVar("F", bound=Callable[..., Any])
+2 -304
View File
@@ -5,7 +5,6 @@
# 💖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
@@ -14,23 +13,22 @@ 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 ExceptionArg, ExcInfo # noqa: F401
from posthog.args import ExcInfo, ExceptionArg # noqa: F401
try:
# Python 3.11
@@ -42,51 +40,6 @@ 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_$$"
CODE_VARIABLES_TOO_LONG_VALUE = "$$_posthog_value_too_long_$$"
_MAX_VALUE_LENGTH_FOR_PATTERN_MATCH = 5_000
_MAX_COLLECTION_ITEMS_TO_SCAN = 100
_REGEX_METACHARACTERS = frozenset(r"\.^$*+?{}[]|()")
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(
@@ -931,258 +884,3 @@ def strip_string(value, max_length=None):
"rem": [["!limit", "x", max_length - 3, max_length]],
},
)
def _extract_plain_substring(pattern):
# Matches inline flag groups like (?i), (?ai), (?ims), etc. that include the 'i' flag.
# Python regex flags: a=ASCII, i=IGNORECASE, L=LOCALE, m=MULTILINE, s=DOTALL, u=UNICODE, x=VERBOSE
inline_flags = re.match(r"^\(\?[aiLmsux]*i[aiLmsux]*\)", pattern)
if not inline_flags:
return None
remainder = pattern[inline_flags.end() :]
if not remainder or any(c in _REGEX_METACHARACTERS for c in remainder):
return None
return remainder.lower()
def _compile_patterns(patterns):
if not patterns:
return None
substrings = []
regexes = []
for pattern in patterns:
simple = _extract_plain_substring(pattern)
if simple is not None:
substrings.append(simple)
else:
try:
regexes.append(re.compile(pattern))
except Exception:
pass
if not substrings and not regexes:
return None
return (substrings, regexes)
def _pattern_matches(name, patterns):
if patterns is None:
return False
substrings, regexes = patterns
if substrings:
name_lower = name.lower()
for s in substrings:
if s in name_lower:
return True
for pattern in regexes:
if pattern.search(name):
return True
return False
def _mask_sensitive_data(value, compiled_mask, _seen=None):
if not compiled_mask:
return value
if isinstance(value, (dict, list, tuple)):
if _seen is None:
_seen = set()
obj_id = id(value)
if obj_id in _seen:
return "<circular ref>"
_seen.add(obj_id)
if isinstance(value, dict):
if len(value) > _MAX_COLLECTION_ITEMS_TO_SCAN:
return CODE_VARIABLES_TOO_LONG_VALUE
result = {}
for k, v in value.items():
key_str = str(k) if not isinstance(k, str) else k
if len(key_str) > _MAX_VALUE_LENGTH_FOR_PATTERN_MATCH:
result[k] = CODE_VARIABLES_TOO_LONG_VALUE
elif _pattern_matches(key_str, compiled_mask):
result[k] = CODE_VARIABLES_REDACTED_VALUE
else:
result[k] = _mask_sensitive_data(v, compiled_mask, _seen)
return result
elif isinstance(value, (list, tuple)):
if len(value) > _MAX_COLLECTION_ITEMS_TO_SCAN:
return CODE_VARIABLES_TOO_LONG_VALUE
masked_items = [
_mask_sensitive_data(item, compiled_mask, _seen) for item in value
]
return type(value)(masked_items)
elif isinstance(value, str):
if len(value) > _MAX_VALUE_LENGTH_FOR_PATTERN_MATCH:
return CODE_VARIABLES_TOO_LONG_VALUE
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 len(value) > _MAX_VALUE_LENGTH_FOR_PATTERN_MATCH:
result = CODE_VARIABLES_TOO_LONG_VALUE
elif 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
+19 -87
View File
@@ -2,7 +2,6 @@ import datetime
import hashlib
import logging
import re
import warnings
from typing import Optional
from dateutil import parser
@@ -35,18 +34,18 @@ class RequiresServerEvaluation(Exception):
pass
# This function takes a bucketing value and a feature flag key and returns a float between 0 and 1.
# Given the same bucketing value and key, it'll always return the same float. These floats are
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
# Given the same distinct_id and key, it'll always return the same float. These floats are
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
# we can do _hash(key, bucketing_value) < 0.2
def _hash(key: str, bucketing_value: str, salt: str = "") -> float:
hash_key = f"{key}.{bucketing_value}{salt}"
# we can do _hash(key, distinct_id) < 0.2
def _hash(key: str, distinct_id: str, salt: str = "") -> float:
hash_key = f"{key}.{distinct_id}{salt}"
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
return hash_val / __LONG_SCALE__
def get_matching_variant(flag, bucketing_value):
hash_value = _hash(flag["key"], bucketing_value, salt="variant")
def get_matching_variant(flag, distinct_id):
hash_value = _hash(flag["key"], distinct_id, salt="variant")
for variant in variant_lookup_table(flag):
if hash_value >= variant["value_min"] and hash_value < variant["value_max"]:
return variant["key"]
@@ -69,13 +68,7 @@ def variant_lookup_table(feature_flag):
def evaluate_flag_dependency(
property,
flags_by_key,
evaluation_cache,
distinct_id,
properties,
cohort_properties,
device_id=None,
property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties
):
"""
Evaluate a flag dependency property according to the dependency chain algorithm.
@@ -87,7 +80,6 @@ def evaluate_flag_dependency(
distinct_id: The distinct ID being evaluated
properties: Person properties for evaluation
cohort_properties: Cohort properties for evaluation
device_id: The device ID for bucketing (optional)
Returns:
bool: True if all dependencies in the chain evaluate to True, False otherwise
@@ -132,27 +124,13 @@ def evaluate_flag_dependency(
else:
# Recursively evaluate the dependency
try:
dep_flag_filters = dep_flag.get("filters") or {}
dep_aggregation_group_type_index = dep_flag_filters.get(
"aggregation_group_type_index"
)
if dep_aggregation_group_type_index is not None:
# Group flags should continue bucketing by the group key
# from the current evaluation context.
dep_bucketing_value = distinct_id
else:
dep_bucketing_value = resolve_bucketing_value(
dep_flag, distinct_id, device_id
)
dep_result = match_feature_flag_properties(
dep_flag,
distinct_id,
properties,
cohort_properties=cohort_properties,
flags_by_key=flags_by_key,
evaluation_cache=evaluation_cache,
device_id=device_id,
bucketing_value=dep_bucketing_value,
cohort_properties,
flags_by_key,
evaluation_cache,
)
evaluation_cache[dep_flag_key] = dep_result
except InconclusiveMatchError as e:
@@ -237,54 +215,21 @@ def matches_dependency_value(expected_value, actual_value):
return False
def resolve_bucketing_value(flag, distinct_id, device_id=None):
"""Resolve the bucketing value for a flag based on its bucketing_identifier setting.
Returns:
The appropriate identifier string to use for hashing/bucketing.
Raises:
InconclusiveMatchError: If the flag requires device_id but none was provided.
"""
flag_filters = flag.get("filters") or {}
bucketing_identifier = flag.get("bucketing_identifier") or flag_filters.get(
"bucketing_identifier"
)
if bucketing_identifier == "device_id":
if not device_id:
raise InconclusiveMatchError(
"Flag requires device_id for bucketing but none was provided"
)
return device_id
return distinct_id
def match_feature_flag_properties(
flag,
distinct_id,
properties,
*,
cohort_properties=None,
flags_by_key=None,
evaluation_cache=None,
device_id=None,
bucketing_value=None,
) -> FlagValue:
if bucketing_value is None:
warnings.warn(
"Calling match_feature_flag_properties() without bucketing_value is deprecated. "
"Pass bucketing_value explicitly. This fallback will be removed in a future major release.",
DeprecationWarning,
stacklevel=2,
)
bucketing_value = resolve_bucketing_value(flag, distinct_id, device_id)
flag_filters = flag.get("filters") or {}
flag_conditions = flag_filters.get("groups") or []
flag_conditions = (flag.get("filters") or {}).get("groups") or []
is_inconclusive = False
cohort_properties = cohort_properties or {}
# Some filters can be explicitly set to null, which require accessing variants like so
flag_variants = (flag_filters.get("multivariate") or {}).get("variants") or []
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get(
"variants"
) or []
valid_variant_keys = [variant["key"] for variant in flag_variants]
for condition in flag_conditions:
@@ -299,14 +244,12 @@ def match_feature_flag_properties(
cohort_properties,
flags_by_key,
evaluation_cache,
bucketing_value=bucketing_value,
device_id=device_id,
):
variant_override = condition.get("variant")
if variant_override and variant_override in valid_variant_keys:
variant = variant_override
else:
variant = get_matching_variant(flag, bucketing_value)
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
@@ -334,9 +277,6 @@ def is_condition_match(
cohort_properties,
flags_by_key=None,
evaluation_cache=None,
*,
bucketing_value,
device_id=None,
) -> bool:
rollout_percentage = condition.get("rollout_percentage")
if len(condition.get("properties") or []) > 0:
@@ -350,7 +290,6 @@ def is_condition_match(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
elif property_type == "flag":
matches = evaluate_flag_dependency(
@@ -360,7 +299,6 @@ def is_condition_match(
distinct_id,
properties,
cohort_properties,
device_id=device_id,
)
else:
matches = match_property(prop, properties)
@@ -370,9 +308,9 @@ def is_condition_match(
if rollout_percentage is None:
return True
if rollout_percentage is not None and _hash(
feature_flag["key"], bucketing_value
) > (rollout_percentage / 100):
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (
rollout_percentage / 100
):
return False
return True
@@ -516,7 +454,6 @@ def match_cohort(
flags_by_key=None,
evaluation_cache=None,
distinct_id=None,
device_id=None,
) -> bool:
# Cohort properties are in the form of property groups like this:
# {
@@ -541,7 +478,6 @@ def match_cohort(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
@@ -552,7 +488,6 @@ def match_property_group(
flags_by_key=None,
evaluation_cache=None,
distinct_id=None,
device_id=None,
) -> bool:
if not property_group:
return True
@@ -577,7 +512,6 @@ def match_property_group(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
if property_group_type == "AND":
if not matches:
@@ -611,7 +545,6 @@ def match_property_group(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
elif prop.get("type") == "flag":
matches = evaluate_flag_dependency(
@@ -621,7 +554,6 @@ def match_property_group(
distinct_id,
property_values,
cohort_properties,
device_id=device_id,
)
else:
matches = match_property(prop, property_values)
-127
View File
@@ -1,127 +0,0 @@
"""
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.
"""
...
+14 -82
View File
@@ -112,19 +112,10 @@ class PosthogContextMiddleware:
def extract_tags(self, request):
# type: (HttpRequest) -> Dict[str, Any]
"""Extract tags from request in sync context."""
user_id, user_email = self.extract_request_user(request)
return self._build_tags(request, user_id, user_email)
def _build_tags(self, request, user_id, user_email):
# type: (HttpRequest, Optional[str], Optional[str]) -> Dict[str, Any]
"""
Build tags dict from request and user info.
Centralized tag extraction logic used by both sync and async paths.
"""
tags = {}
(user_id, user_email) = self.extract_request_user(request)
# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
if session_id:
@@ -155,7 +146,7 @@ class PosthogContextMiddleware:
# Extract IP address
ip_address = request.headers.get("X-Forwarded-For")
if ip_address:
tags["$ip"] = ip_address
tags["$ip_address"] = ip_address
# Extract user agent
user_agent = request.headers.get("User-Agent")
@@ -175,78 +166,21 @@ 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
if user is None:
return user_id, email
user = getattr(request, "user", None)
# Handle is_authenticated (property in modern Django, method in legacy)
is_authenticated = getattr(user, "is_authenticated", False)
if callable(is_authenticated):
is_authenticated = is_authenticated()
if user and getattr(user, "is_authenticated", False):
try:
user_id = str(user.pk)
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)
try:
email = str(user.email)
except Exception:
pass
return user_id, email
@@ -277,14 +211,12 @@ class PosthogContextMiddleware:
Asynchronous entry point for async request handling.
This method is called when the middleware chain is async.
Uses aextract_tags() which calls request.auser() to avoid
SynchronousOnlyOperation when accessing user in async context.
"""
if self.request_filter and not self.request_filter(request):
return await self.get_response(request)
with contexts.new_context(self.capture_exceptions, client=self.client):
for k, v in (await self.aextract_tags(request)).items():
for k, v in self.extract_tags(request).items():
contexts.tag(k, v)
return await self.get_response(request)
+27 -229
View File
@@ -1,163 +1,28 @@
import json
import logging
import re
import socket
from dataclasses import dataclass
from datetime import date, datetime, timezone
from datetime import date, datetime
from gzip import GzipFile
from io import BytesIO
from typing import Any, List, Optional, Tuple, Union
from typing import Any, Optional, 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
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,
# Retry on both connect and read errors
# by default read errors will only retry idempotent HTTP methods (so not POST)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=2,
connect=2,
read=2,
)
session = requests.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
)
_session = requests.sessions.Session()
_session.mount("https://", adapter)
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
@@ -183,7 +48,6 @@ def post(
path=None,
gzip: bool = False,
timeout: int = 15,
session: Optional[requests.Session] = None,
**kwargs,
) -> requests.Response:
"""Post the `kwargs` to the API"""
@@ -204,9 +68,7 @@ def post(
gz.write(data.encode("utf-8"))
data = buf.getvalue()
res = (session or _get_session()).post(
url, data=data, headers=headers, timeout=timeout
)
res = _session.post(url, data=data, headers=headers, timeout=timeout)
if res.status_code == 200:
log.debug("data uploaded successfully")
@@ -235,31 +97,12 @@ def _process_response(
)
raise QuotaLimitError(res.status_code, "Feature flags quota limited")
return response
retry_after = None
retry_after_header = res.headers.get("Retry-After")
if retry_after_header:
try:
retry_after = float(retry_after_header)
except (ValueError, TypeError):
try:
from email.utils import parsedate_to_datetime
retry_after = max(
0.0,
(
parsedate_to_datetime(retry_after_header)
- datetime.now(timezone.utc)
).total_seconds(),
)
except (ValueError, TypeError):
pass
try:
payload = res.json()
log.debug("received response: %s", payload)
raise APIError(res.status_code, payload["detail"], retry_after=retry_after)
raise APIError(res.status_code, payload["detail"])
except (KeyError, ValueError):
raise APIError(res.status_code, res.text, retry_after=retry_after)
raise APIError(res.status_code, res.text)
def decide(
@@ -281,16 +124,8 @@ def flags(
timeout: int = 15,
**kwargs,
) -> Any:
"""Post the kwargs to the flags API endpoint with automatic retries."""
res = post(
api_key,
host,
"/flags/?v=2",
gzip,
timeout,
session=_get_flags_session(),
**kwargs,
)
"""Post the `kwargs to the flags API endpoint"""
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
return _process_response(
res, success_message="Feature flags evaluated successfully"
)
@@ -304,13 +139,12 @@ def remote_config(
timeout: int = 15,
) -> Any:
"""Get remote config flag value from remote_config API endpoint"""
response = get(
return 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(
@@ -328,51 +162,21 @@ def batch_post(
def get(
api_key: str,
url: str,
host: Optional[str] = None,
timeout: Optional[int] = None,
etag: Optional[str] = None,
) -> GetResponse:
"""
Make a GET request with optional ETag support.
If an etag is provided, sends If-None-Match header. Returns GetResponse with:
- not_modified=True and data=None if server returns 304
- not_modified=False and data=response if server returns 200
"""
log = logging.getLogger("posthog")
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
if etag:
headers["If-None-Match"] = etag
res = _get_session().get(full_url, headers=headers, timeout=timeout)
masked_url = _mask_tokens_in_url(full_url)
# Handle 304 Not Modified
if res.status_code == 304:
log.debug(f"GET {masked_url} returned 304 Not Modified")
response_etag = res.headers.get("ETag")
return GetResponse(data=None, etag=response_etag or etag, not_modified=True)
# Handle normal response
data = _process_response(
res, success_message=f"GET {masked_url} completed successfully"
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,
)
response_etag = res.headers.get("ETag")
return GetResponse(data=data, etag=response_etag, not_modified=False)
return _process_response(res, success_message=f"GET {url} completed successfully")
class APIError(Exception):
def __init__(
self, status: Union[int, str], message: str, retry_after: Optional[float] = None
):
def __init__(self, status: Union[int, str], message: str):
self.message = message
self.status = status
self.retry_after = retry_after
def __str__(self):
msg = "[PostHog] {0} ({1})"
@@ -383,12 +187,6 @@ 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 -269
View File
@@ -1,4 +1,4 @@
import json
import os
from unittest.mock import patch
import pytest
@@ -306,34 +306,7 @@ def test_basic_completion(mock_client, mock_anthropic_response):
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert props["$ai_tokens_source"] == "sdk"
assert isinstance(props["$ai_latency"], float)
# Verify raw usage metadata is passed for backend processing
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "input_tokens" in props["$ai_usage"]
assert "output_tokens" in props["$ai_usage"]
def test_tokens_source_passthrough(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_properties={"$ai_input_tokens": 99999},
)
props = mock_client.capture.call_args[1]["properties"]
assert props["$ai_tokens_source"] == "passthrough"
assert props["$ai_input_tokens"] == 99999
def test_groups(mock_client, mock_anthropic_response):
@@ -945,17 +918,6 @@ def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools
assert props["$ai_output_tokens"] == 25
assert props["$ai_cache_read_input_tokens"] == 5
assert props["$ai_cache_creation_input_tokens"] == 0
assert props["$ai_tokens_source"] == "sdk"
# Verify raw usage is captured in streaming mode (merged from events)
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure (merged from message_start and message_delta)
assert isinstance(props["$ai_usage"], dict)
assert "input_tokens" in props["$ai_usage"]
assert "output_tokens" in props["$ai_usage"]
def test_async_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools):
@@ -1072,233 +1034,3 @@ 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
+2 -326
View File
@@ -1,4 +1,3 @@
import json
from unittest.mock import MagicMock, patch
import pytest
@@ -35,13 +34,6 @@ def mock_gemini_response():
# Ensure cache and reasoning tokens are not present (not MagicMock)
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
mock_usage.model_dump.return_value = {
"prompt_token_count": 20,
"candidates_token_count": 10,
"cached_content_token_count": 0,
"thoughts_token_count": 0,
}
mock_response.usage_metadata = mock_usage
mock_candidate = MagicMock()
@@ -77,13 +69,6 @@ def mock_gemini_response_with_function_calls():
mock_usage.candidates_token_count = 15
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
mock_usage.model_dump.return_value = {
"prompt_token_count": 25,
"candidates_token_count": 15,
"cached_content_token_count": 0,
"thoughts_token_count": 0,
}
mock_response.usage_metadata = mock_usage
# Mock function call
@@ -132,13 +117,6 @@ def mock_gemini_response_function_calls_only():
mock_usage.candidates_token_count = 12
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
mock_usage.model_dump.return_value = {
"prompt_token_count": 30,
"candidates_token_count": 12,
"cached_content_token_count": 0,
"thoughts_token_count": 0,
}
mock_response.usage_metadata = mock_usage
# Mock function call
@@ -196,15 +174,6 @@ def test_new_client_basic_generation(
assert props["foo"] == "bar"
assert "$ai_trace_id" in props
assert props["$ai_latency"] > 0
# Verify raw usage metadata is passed for backend processing
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "prompt_token_count" in props["$ai_usage"]
assert "candidates_token_count" in props["$ai_usage"]
def test_new_client_streaming_with_generate_content_stream(
@@ -438,9 +407,7 @@ 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": [{"type": "text", "text": "hey"}]}
]
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
# Test multiple parts in the parts array
mock_client.reset_mock()
@@ -451,15 +418,7 @@ 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": [
{"type": "text", "text": "Hello "},
{"type": "text", "text": "world"},
],
}
]
assert props["$ai_input"] == [{"role": "user", "content": "Hello world"}]
# Test list input with string
mock_client.capture.reset_mock()
@@ -841,13 +800,6 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
chunk1_usage.candidates_token_count = 5
chunk1_usage.cached_content_token_count = 30 # Cache tokens
chunk1_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
chunk1_usage.model_dump.return_value = {
"prompt_token_count": 100,
"candidates_token_count": 5,
"cached_content_token_count": 30,
"thoughts_token_count": 0,
}
chunk1.usage_metadata = chunk1_usage
chunk2 = MagicMock()
@@ -857,13 +809,6 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
chunk2_usage.candidates_token_count = 10
chunk2_usage.cached_content_token_count = 30 # Same cache tokens
chunk2_usage.thoughts_token_count = 5 # Reasoning tokens
# Make model_dump() return a proper dict for serialization
chunk2_usage.model_dump.return_value = {
"prompt_token_count": 100,
"candidates_token_count": 10,
"cached_content_token_count": 30,
"thoughts_token_count": 5,
}
chunk2.usage_metadata = chunk2_usage
mock_stream = iter([chunk1, chunk2])
@@ -892,272 +837,3 @@ 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
# Verify raw usage is captured in streaming mode (merged from chunks)
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "prompt_token_count" in props["$ai_usage"]
assert "candidates_token_count" in props["$ai_usage"]
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
-853
View File
@@ -1,853 +0,0 @@
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 -1
View File
@@ -1,5 +1,5 @@
import pytest
pytest.importorskip("langchain_core")
pytest.importorskip("langchain")
pytest.importorskip("langchain_community")
pytest.importorskip("langgraph")
+13 -780
View File
@@ -113,7 +113,6 @@ 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):
@@ -1125,9 +1124,9 @@ def test_anthropic_chain(mock_client):
)
chain = prompt | ChatAnthropic(
api_key=ANTHROPIC_API_KEY,
model="claude-sonnet-4-5-20250929",
model="claude-3-opus-20240229",
temperature=0,
max_tokens=1024,
max_tokens=1,
)
callbacks = CallbackHandler(
mock_client,
@@ -1150,12 +1149,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-sonnet-4-5-20250929"
assert gen_props["$ai_model"] == "claude-3-opus-20240229"
assert gen_props["foo"] == "bar"
assert gen_props["$ai_model_parameters"] == {
"temperature": 0.0,
"max_tokens": 1024,
"max_tokens": 1,
"streaming": False,
}
assert gen_props["$ai_input"] == [
@@ -1171,7 +1170,7 @@ def test_anthropic_chain(mock_client):
<= approximate_latency
)
assert gen_props["$ai_input_tokens"] == 17
assert gen_props["$ai_output_tokens"] == 4
assert gen_props["$ai_output_tokens"] == 1
assert trace_args["event"] == "$ai_trace"
assert trace_props["$ai_input_state"] == {}
@@ -1188,9 +1187,9 @@ async def test_async_anthropic_streaming(mock_client):
)
chain = prompt | ChatAnthropic(
api_key=ANTHROPIC_API_KEY,
model="claude-sonnet-4-5-20250929",
model="claude-3-opus-20240229",
temperature=0,
max_tokens=1024,
max_tokens=1,
streaming=True,
stream_usage=True,
)
@@ -1270,7 +1269,6 @@ 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):
@@ -1567,9 +1565,9 @@ def test_anthropic_cache_write_and_read_tokens(mock_client):
AIMessage(
content="Using cached analysis to provide quick response.",
usage_metadata={
"input_tokens": 1200,
"input_tokens": 200,
"output_tokens": 30,
"total_tokens": 1230,
"total_tokens": 1030,
"cache_read_input_tokens": 800, # Anthropic cache read
},
)
@@ -1586,147 +1584,13 @@ 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"] == 1200
) # No provider metadata, no subtraction
assert generation_props["$ai_input_tokens"] == 200
assert generation_props["$ai_output_tokens"] == 30
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_cache_read_input_tokens"] == 800
assert generation_props["$ai_reasoning_tokens"] == 0
def test_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(
@@ -1762,7 +1626,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 # No subtraction for OpenAI
assert generation_props["$ai_input_tokens"] == 150
assert generation_props["$ai_output_tokens"] == 40
assert generation_props["$ai_cache_read_input_tokens"] == 100
assert generation_props["$ai_cache_creation_input_tokens"] == 0
@@ -1844,7 +1708,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 # No subtraction for OpenAI
assert generation_props["$ai_input_tokens"] == 500
assert generation_props["$ai_output_tokens"] == 100
assert generation_props["$ai_cache_read_input_tokens"] == 300
assert generation_props["$ai_cache_creation_input_tokens"] == 0
@@ -1852,7 +1716,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_o4_mini(mock_client):
def test_openai_reasoning_tokens(mock_client):
model = ChatOpenAI(
api_key=OPENAI_API_KEY, model="o4-mini", max_completion_tokens=10
)
@@ -2013,634 +1877,3 @@ 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
-813
View File
@@ -1,4 +1,3 @@
import json
import time
from unittest.mock import AsyncMock, patch
@@ -497,15 +496,6 @@ def test_basic_completion(mock_client, mock_openai_response):
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify raw usage metadata is passed for backend processing
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "prompt_tokens" in props["$ai_usage"]
assert "completion_tokens" in props["$ai_usage"]
def test_embeddings(mock_client, mock_embedding_response):
@@ -932,16 +922,6 @@ def test_streaming_with_tool_calls(mock_client, streaming_tool_call_chunks):
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
# Verify raw usage is captured in streaming mode
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure (merged from chunks)
assert isinstance(props["$ai_usage"], dict)
assert "prompt_tokens" in props["$ai_usage"]
assert "completion_tokens" in props["$ai_usage"]
# test responses api
def test_responses_api(mock_client, mock_openai_response_with_responses_api):
@@ -1359,796 +1339,3 @@ def test_tool_definition(mock_client, mock_openai_response):
assert isinstance(props["$ai_latency"], float)
# Verify that tools are captured in the $ai_tools property
assert props["$ai_tools"] == tools
def test_web_search_perplexity_style(mock_client):
"""Test web search detection via annotations (Perplexity-style)."""
class MockAnnotation:
def __init__(self):
self.type = "url_citation"
class MockMessage:
def __init__(self):
self.role = "assistant"
self.content = "Based on recent search results..."
self.annotations = [MockAnnotation(), MockAnnotation()]
class MockChoice:
def __init__(self):
self.message = MockMessage()
class MockUsage:
def __init__(self):
self.prompt_tokens = 50
self.completion_tokens = 30
class MockResponseWithAnnotations:
def __init__(self):
self.choices = [MockChoice()]
self.usage = MockUsage()
self.model = "gpt-4-turbo"
mock_response = MockResponseWithAnnotations()
with patch("openai.resources.chat.Completions.create", return_value=mock_response):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4-turbo",
messages=[{"role": "user", "content": "What's happening in tech?"}],
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 detection)
assert props["$ai_web_search_count"] == 1
def test_web_search_responses_api(mock_client):
"""Test exact web search count from Responses API."""
class MockWebSearchItem:
def __init__(self):
self.type = "web_search_call"
class MockMessageItem:
def __init__(self):
self.type = "message"
self.role = "assistant"
self.content = "Here are the results..."
class MockUsage:
def __init__(self):
self.input_tokens = 100
self.output_tokens = 75
class MockResponsesAPIResponse:
def __init__(self):
self.output = [MockWebSearchItem(), MockWebSearchItem(), MockMessageItem()]
self.usage = MockUsage()
self.model = "gpt-4o"
mock_response = MockResponsesAPIResponse()
with patch(
"openai.resources.responses.Responses.create", return_value=mock_response
):
# Manually call the tracking since we're testing the converter logic
from posthog.ai.utils import call_llm_and_track_usage
def mock_create_call(**kwargs):
return mock_response
result = call_llm_and_track_usage(
posthog_distinct_id="test-id",
ph_client=mock_client,
provider="openai",
posthog_trace_id=None,
posthog_properties=None,
posthog_privacy_mode=False,
posthog_groups=None,
base_url="https://api.openai.com/v1",
call_method=mock_create_call,
model="gpt-4o",
messages=[{"role": "user", "content": "Search query"}],
)
assert result == mock_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify exact web search count
assert props["$ai_web_search_count"] == 2
@pytest.fixture
def streaming_web_search_chunks():
"""Streaming chunks with web search indicators (Perplexity-style)."""
return [
ChatCompletionChunk(
id="chunk1",
model="gpt-4",
object="chat.completion.chunk",
created=1234567890,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(
role="assistant",
content="Based on my search, ",
),
finish_reason=None,
)
],
),
ChatCompletionChunk(
id="chunk2",
model="gpt-4",
object="chat.completion.chunk",
created=1234567891,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(
content="here are the latest news...",
),
finish_reason=None,
)
],
),
ChatCompletionChunk(
id="chunk3",
model="gpt-4",
object="chat.completion.chunk",
created=1234567892,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(),
finish_reason="stop",
)
],
usage=CompletionUsage(
prompt_tokens=20,
completion_tokens=15,
total_tokens=35,
),
),
]
def test_streaming_with_web_search(mock_client, streaming_web_search_chunks):
"""Test that web search count is properly captured in streaming mode."""
# Add citations attribute to the last chunk to indicate web search was used
streaming_web_search_chunks[-1].citations = ["https://example.com/news"]
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = streaming_web_search_chunks
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
posthog_distinct_id="test-id",
)
# Consume the generator to trigger the event capture
chunks = list(response_generator)
# Verify the chunks were returned correctly
assert len(chunks) == 3
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 (binary detection = 1)
assert props["$ai_web_search_count"] == 1
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
def test_streaming_with_web_search_on_non_usage_chunk(
mock_client, streaming_web_search_chunks
):
"""Test that web search count is captured even when citations appear on chunks without usage data."""
# Add citations attribute to the FIRST chunk (which has no usage data)
# This tests the fix for the bug where web search indicators on non-usage chunks were ignored
streaming_web_search_chunks[0].citations = ["https://example.com/news"]
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = streaming_web_search_chunks
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
posthog_distinct_id="test-id",
)
# Consume the generator to trigger the event capture
chunks = list(response_generator)
# Verify the chunks were returned correctly
assert len(chunks) == 3
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 even though citations were on first chunk
assert props["$ai_web_search_count"] == 1
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
@pytest.mark.asyncio
async def test_async_chat_with_web_search(mock_client):
"""Test that web search count is properly tracked in async non-streaming mode."""
# Create mock response with citations (Perplexity-style)
mock_response = ChatCompletion(
id="chatcmpl-test",
model="gpt-4",
object="chat.completion",
created=1234567890,
choices=[
Choice(
index=0,
message=ChatCompletionMessage(
role="assistant",
content="Here are the search results...",
),
finish_reason="stop",
)
],
usage=CompletionUsage(
prompt_tokens=20,
completion_tokens=15,
total_tokens=35,
),
)
# Add citations attribute to indicate web search
mock_response.citations = ["https://example.com/result1"]
async def mock_create(self, **kwargs):
return mock_response
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response = await client.chat.completions.create(
model="gpt-4",
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 (binary detection = 1)
assert props["$ai_web_search_count"] == 1
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
@pytest.mark.asyncio
async def test_async_chat_streaming_with_web_search(
mock_client, streaming_web_search_chunks
):
"""Test that web search count is properly captured in async streaming mode."""
# Add citations attribute to the last chunk to indicate web search was used
streaming_web_search_chunks[-1].citations = ["https://example.com/news"]
captured_kwargs = {}
async def mock_create(self, **kwargs):
captured_kwargs["kwargs"] = kwargs
async def chunk_iterable():
for chunk in streaming_web_search_chunks:
yield chunk
return chunk_iterable()
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
posthog_distinct_id="test-id",
)
chunks = []
async for chunk in response_stream:
chunks.append(chunk)
# Verify the chunks were returned correctly
assert len(chunks) == 3
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 (binary detection = 1)
assert props["$ai_web_search_count"] == 1
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
# Tests for model extraction fallback (stored prompts support)
def test_streaming_chat_extracts_model_from_chunk_when_not_in_kwargs(mock_client):
"""Test that model is extracted from streaming chunks when not provided in kwargs (stored prompts)."""
# Create streaming chunks with model field but we won't pass model in kwargs
chunks = [
ChatCompletionChunk(
id="chunk1",
model="gpt-4o-stored-prompt", # Model comes from response, not request
object="chat.completion.chunk",
created=1234567890,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(role="assistant", content="Hello"),
finish_reason=None,
)
],
),
ChatCompletionChunk(
id="chunk2",
model="gpt-4o-stored-prompt",
object="chat.completion.chunk",
created=1234567891,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(content=" world"),
finish_reason="stop",
)
],
usage=CompletionUsage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
),
),
]
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = chunks
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs - simulates stored prompt usage
response_generator = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
# Consume the generator
list(response_generator)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Model should be extracted from chunk, not kwargs
assert props["$ai_model"] == "gpt-4o-stored-prompt"
def test_streaming_chat_prefers_kwargs_model_over_chunk_model(mock_client):
"""Test that model from kwargs takes precedence over model from chunk."""
chunks = [
ChatCompletionChunk(
id="chunk1",
model="gpt-4o-from-response",
object="chat.completion.chunk",
created=1234567890,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(role="assistant", content="Hello"),
finish_reason="stop",
)
],
usage=CompletionUsage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
),
),
]
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = chunks
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
model="gpt-4o-from-kwargs", # Explicitly passed model
messages=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
list(response_generator)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# kwargs model should take precedence
assert props["$ai_model"] == "gpt-4o-from-kwargs"
def test_streaming_responses_api_extracts_model_from_response_object(mock_client):
"""Test that Responses API streaming extracts model from chunk.response.model (stored prompts)."""
from unittest.mock import MagicMock
from openai.types.responses import ResponseUsage
chunks = []
# Content chunk
chunk1 = MagicMock()
chunk1.type = "response.text.delta"
chunk1.text = "Test response"
# No response attribute on content chunks
del chunk1.response
chunks.append(chunk1)
# Final chunk with response object containing model
chunk2 = MagicMock()
chunk2.type = "response.completed"
chunk2.response = MagicMock()
chunk2.response.model = "gpt-4o-mini-stored" # Model from stored prompt
chunk2.response.usage = ResponseUsage(
input_tokens=20,
output_tokens=10,
total_tokens=30,
input_tokens_details={"prompt_tokens": 20, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 0},
)
chunk2.response.output = ["Test response"]
chunks.append(chunk2)
with patch("openai.resources.responses.Responses.create") as mock_create:
mock_create.return_value = iter(chunks)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model - simulates stored prompt
response_generator = client.responses.create(
input=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
list(response_generator)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Model should be extracted from chunk.response.model
assert props["$ai_model"] == "gpt-4o-mini-stored"
def test_non_streaming_extracts_model_from_response(mock_client):
"""Test that non-streaming calls extract model from response when not in kwargs."""
# Create a response with model but we won't pass model in kwargs
mock_response = ChatCompletion(
id="test",
model="gpt-4o-stored-prompt",
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="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(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs
response = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
)
assert response == mock_response
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Model should be extracted from response.model
assert props["$ai_model"] == "gpt-4o-stored-prompt"
def test_non_streaming_responses_api_extracts_model_from_response(mock_client):
"""Test that non-streaming Responses API extracts model from response when not in kwargs."""
mock_response = Response(
id="test",
model="gpt-4o-mini-stored",
object="response",
created_at=1741476542,
status="completed",
error=None,
incomplete_details=None,
instructions=None,
max_output_tokens=None,
tools=[],
tool_choice="auto",
output=[
ResponseOutputMessage(
id="msg_123",
type="message",
role="assistant",
status="completed",
content=[
ResponseOutputText(
type="output_text",
text="Test response",
annotations=[],
)
],
)
],
parallel_tool_calls=True,
previous_response_id=None,
usage=ResponseUsage(
input_tokens=10,
output_tokens=10,
input_tokens_details={"prompt_tokens": 10, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 0},
total_tokens=20,
),
user=None,
metadata={},
)
with patch(
"openai.resources.responses.Responses.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs
response = client.responses.create(
input="Hello",
posthog_distinct_id="test-id",
)
assert response == mock_response
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Model should be extracted from response.model
assert props["$ai_model"] == "gpt-4o-mini-stored"
def test_non_streaming_returns_none_when_no_model(mock_client):
"""Test that non-streaming returns None (not 'unknown') when model is not available anywhere."""
# Create a response without model attribute using real OpenAI types
mock_response = ChatCompletion(
id="test",
model="", # Will be removed below
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="Test response",
role="assistant",
),
)
],
usage=CompletionUsage(
completion_tokens=5,
prompt_tokens=10,
total_tokens=15,
),
)
# Remove model attribute to simulate missing model
object.__delattr__(mock_response, "model")
with patch(
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs and response has no model
client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Should be None, NOT "unknown" (to avoid incorrect cost matching)
assert props["$ai_model"] is None
def test_streaming_falls_back_to_unknown_when_no_model(mock_client):
"""Test that streaming falls back to 'unknown' when model is not available anywhere."""
from unittest.mock import MagicMock
# Create a chunk without model attribute
chunk = MagicMock()
chunk.choices = [MagicMock()]
chunk.choices[0].delta = MagicMock()
chunk.choices[0].delta.content = "Hello"
chunk.choices[0].delta.role = "assistant"
chunk.choices[0].delta.tool_calls = None
chunk.usage = CompletionUsage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
)
# Explicitly remove model attribute
del chunk.model
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = [chunk]
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
list(response_generator)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Should fall back to "unknown"
assert props["$ai_model"] == "unknown"
@pytest.mark.asyncio
async def test_async_streaming_chat_extracts_model_from_chunk(mock_client):
"""Test async streaming extracts model from chunk when not in kwargs."""
chunks = [
ChatCompletionChunk(
id="chunk1",
model="gpt-4o-async-stored",
object="chat.completion.chunk",
created=1234567890,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(role="assistant", content="Hello"),
finish_reason="stop",
)
],
usage=CompletionUsage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
),
),
]
async def mock_create(self, **kwargs):
async def chunk_iterable():
for chunk in chunks:
yield chunk
return chunk_iterable()
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model
response_stream = await client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
async for _ in response_stream:
pass
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_model"] == "gpt-4o-async-stored"
@pytest.mark.asyncio
async def test_async_streaming_responses_extracts_model_from_response(mock_client):
"""Test async Responses API streaming extracts model from chunk.response.model."""
from unittest.mock import MagicMock
from openai.types.responses import ResponseUsage
chunks = []
chunk1 = MagicMock()
chunk1.type = "response.text.delta"
chunk1.text = "Test"
del chunk1.response
chunks.append(chunk1)
chunk2 = MagicMock()
chunk2.type = "response.completed"
chunk2.response = MagicMock()
chunk2.response.model = "gpt-4o-mini-async-stored"
chunk2.response.usage = ResponseUsage(
input_tokens=20,
output_tokens=10,
total_tokens=30,
input_tokens_details={"prompt_tokens": 20, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 0},
)
chunk2.response.output = ["Test"]
chunks.append(chunk2)
async def mock_create(self, **kwargs):
async def chunk_iterable():
for chunk in chunks:
yield chunk
return chunk_iterable()
with patch("openai.resources.responses.AsyncResponses.create", new=mock_create):
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.responses.create(
input=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
async for _ in response_stream:
pass
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_model"] == "gpt-4o-mini-async-stored"
@@ -1 +0,0 @@
# Tests for OpenAI Agents SDK integration
@@ -1,810 +0,0 @@
import logging
from unittest.mock import MagicMock, patch
import pytest
try:
from agents.tracing.span_data import (
AgentSpanData,
CustomSpanData,
FunctionSpanData,
GenerationSpanData,
GuardrailSpanData,
HandoffSpanData,
ResponseSpanData,
SpeechSpanData,
TranscriptionSpanData,
)
from posthog.ai.openai_agents import PostHogTracingProcessor, instrument
OPENAI_AGENTS_AVAILABLE = True
except ImportError:
OPENAI_AGENTS_AVAILABLE = False
# Skip all tests if OpenAI Agents SDK is not available
pytestmark = pytest.mark.skipif(
not OPENAI_AGENTS_AVAILABLE, reason="OpenAI Agents SDK is not available"
)
@pytest.fixture(scope="function")
def mock_client():
client = MagicMock()
client.privacy_mode = False
logging.getLogger("posthog").setLevel(logging.DEBUG)
return client
@pytest.fixture(scope="function")
def processor(mock_client):
return PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
privacy_mode=False,
)
@pytest.fixture
def mock_trace():
trace = MagicMock()
trace.trace_id = "trace_123456789"
trace.name = "Test Workflow"
trace.group_id = "group_123"
trace.metadata = {"key": "value"}
return trace
@pytest.fixture
def mock_span():
span = MagicMock()
span.trace_id = "trace_123456789"
span.span_id = "span_987654321"
span.parent_id = None
span.started_at = "2024-01-01T00:00:00Z"
span.ended_at = "2024-01-01T00:00:01Z"
span.error = None
return span
class TestPostHogTracingProcessor:
"""Tests for the PostHogTracingProcessor class."""
def test_initialization(self, mock_client):
"""Test processor initializes correctly."""
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="user@example.com",
privacy_mode=True,
groups={"company": "acme"},
properties={"env": "test"},
)
assert processor._client == mock_client
assert processor._distinct_id == "user@example.com"
assert processor._privacy_mode is True
assert processor._groups == {"company": "acme"}
assert processor._properties == {"env": "test"}
def test_initialization_with_callable_distinct_id(self, mock_client, mock_trace):
"""Test processor with callable distinct_id resolver."""
def resolver(trace):
return trace.metadata.get("user_id", "default")
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id=resolver,
)
mock_trace.metadata = {"user_id": "resolved-user"}
distinct_id = processor._get_distinct_id(mock_trace)
assert distinct_id == "resolved-user"
def test_on_trace_start_stores_metadata(self, processor, mock_client, mock_trace):
"""Test that on_trace_start stores metadata but does not capture an event."""
processor.on_trace_start(mock_trace)
mock_client.capture.assert_not_called()
assert mock_trace.trace_id in processor._trace_metadata
def test_on_trace_end_captures_ai_trace(self, processor, mock_client, mock_trace):
"""Test that on_trace_end captures $ai_trace event."""
processor.on_trace_start(mock_trace)
processor.on_trace_end(mock_trace)
mock_client.capture.assert_called_once()
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_trace"
assert call_kwargs["distinct_id"] == "test-user"
assert call_kwargs["properties"]["$ai_trace_id"] == "trace_123456789"
assert call_kwargs["properties"]["$ai_trace_name"] == "Test Workflow"
assert call_kwargs["properties"]["$ai_provider"] == "openai"
assert call_kwargs["properties"]["$ai_framework"] == "openai-agents"
assert "$ai_latency" in call_kwargs["properties"]
def test_personless_mode_when_no_distinct_id(self, mock_client, mock_trace):
"""Test that trace events use personless mode when no distinct_id is provided."""
processor = PostHogTracingProcessor(
client=mock_client,
)
processor.on_trace_start(mock_trace)
processor.on_trace_end(mock_trace)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$process_person_profile"] is False
# Should fallback to trace_id as the distinct_id
assert call_kwargs["distinct_id"] == mock_trace.trace_id
def test_personless_mode_for_spans_when_no_distinct_id(
self, mock_client, mock_trace, mock_span
):
"""Test that span events use personless mode when no distinct_id is provided."""
processor = PostHogTracingProcessor(
client=mock_client,
)
processor.on_trace_start(mock_trace)
mock_client.capture.reset_mock()
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$process_person_profile"] is False
assert call_kwargs["distinct_id"] == mock_span.trace_id
def test_personless_mode_when_callable_returns_none(
self, mock_client, mock_trace, mock_span
):
"""Test personless mode when callable distinct_id returns None."""
def resolver(trace):
return None # Simulate no user ID available
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id=resolver,
)
processor.on_trace_start(mock_trace)
mock_client.capture.reset_mock()
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$process_person_profile"] is False
assert call_kwargs["distinct_id"] == mock_span.trace_id
def test_person_profile_when_distinct_id_provided(self, mock_client, mock_trace):
"""Test that events create person profiles when distinct_id is provided."""
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="real-user",
)
processor.on_trace_start(mock_trace)
processor.on_trace_end(mock_trace)
call_kwargs = mock_client.capture.call_args[1]
assert "$process_person_profile" not in call_kwargs["properties"]
def test_on_trace_end_clears_metadata(self, processor, mock_client, mock_trace):
"""Test that on_trace_end clears stored trace metadata."""
processor.on_trace_start(mock_trace)
assert mock_trace.trace_id in processor._trace_metadata
processor.on_trace_end(mock_trace)
assert mock_trace.trace_id not in processor._trace_metadata
# Also verify it captured the event
mock_client.capture.assert_called_once()
def test_on_span_start_tracks_time(self, processor, mock_span):
"""Test that on_span_start records start time."""
processor.on_span_start(mock_span)
assert mock_span.span_id in processor._span_start_times
def test_generation_span_mapping(self, processor, mock_client, mock_span):
"""Test GenerationSpanData maps to $ai_generation event."""
span_data = GenerationSpanData(
input=[{"role": "user", "content": "Hello"}],
output=[{"role": "assistant", "content": "Hi there!"}],
model="gpt-4o",
model_config={"temperature": 0.7, "max_tokens": 100},
usage={"input_tokens": 10, "output_tokens": 20},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
mock_client.capture.assert_called_once()
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_generation"
assert call_kwargs["properties"]["$ai_trace_id"] == "trace_123456789"
assert call_kwargs["properties"]["$ai_span_id"] == "span_987654321"
assert call_kwargs["properties"]["$ai_provider"] == "openai"
assert call_kwargs["properties"]["$ai_framework"] == "openai-agents"
assert call_kwargs["properties"]["$ai_model"] == "gpt-4o"
assert call_kwargs["properties"]["$ai_input_tokens"] == 10
assert call_kwargs["properties"]["$ai_output_tokens"] == 20
assert call_kwargs["properties"]["$ai_input"] == [
{"role": "user", "content": "Hello"}
]
assert call_kwargs["properties"]["$ai_output_choices"] == [
{"role": "assistant", "content": "Hi there!"}
]
def test_generation_span_with_reasoning_tokens(
self, processor, mock_client, mock_span
):
"""Test GenerationSpanData includes reasoning tokens when present."""
span_data = GenerationSpanData(
model="o1-preview",
usage={
"input_tokens": 100,
"output_tokens": 500,
"reasoning_tokens": 400,
},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_reasoning_tokens"] == 400
def test_function_span_mapping(self, processor, mock_client, mock_span):
"""Test FunctionSpanData maps to $ai_span event with type=tool."""
span_data = FunctionSpanData(
name="get_weather",
input='{"city": "San Francisco"}',
output="Sunny, 72F",
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_name"] == "get_weather"
assert call_kwargs["properties"]["$ai_span_type"] == "tool"
assert (
call_kwargs["properties"]["$ai_input_state"] == '{"city": "San Francisco"}'
)
assert call_kwargs["properties"]["$ai_output_state"] == "Sunny, 72F"
def test_agent_span_mapping(self, processor, mock_client, mock_span):
"""Test AgentSpanData maps to $ai_span event with type=agent."""
span_data = AgentSpanData(
name="CustomerServiceAgent",
handoffs=["TechnicalAgent", "BillingAgent"],
tools=["search", "get_order"],
output_type="str",
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_name"] == "CustomerServiceAgent"
assert call_kwargs["properties"]["$ai_span_type"] == "agent"
assert call_kwargs["properties"]["$ai_agent_handoffs"] == [
"TechnicalAgent",
"BillingAgent",
]
assert call_kwargs["properties"]["$ai_agent_tools"] == ["search", "get_order"]
def test_handoff_span_mapping(self, processor, mock_client, mock_span):
"""Test HandoffSpanData maps to $ai_span event with type=handoff."""
span_data = HandoffSpanData(
from_agent="TriageAgent",
to_agent="TechnicalAgent",
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_type"] == "handoff"
assert call_kwargs["properties"]["$ai_handoff_from_agent"] == "TriageAgent"
assert call_kwargs["properties"]["$ai_handoff_to_agent"] == "TechnicalAgent"
assert (
call_kwargs["properties"]["$ai_span_name"]
== "TriageAgent -> TechnicalAgent"
)
def test_guardrail_span_mapping(self, processor, mock_client, mock_span):
"""Test GuardrailSpanData maps to $ai_span event with type=guardrail."""
span_data = GuardrailSpanData(
name="ContentFilter",
triggered=True,
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_name"] == "ContentFilter"
assert call_kwargs["properties"]["$ai_span_type"] == "guardrail"
assert call_kwargs["properties"]["$ai_guardrail_triggered"] is True
def test_custom_span_mapping(self, processor, mock_client, mock_span):
"""Test CustomSpanData maps to $ai_span event with type=custom."""
span_data = CustomSpanData(
name="database_query",
data={"query": "SELECT * FROM users", "rows": 100},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_name"] == "database_query"
assert call_kwargs["properties"]["$ai_span_type"] == "custom"
assert call_kwargs["properties"]["$ai_custom_data"] == {
"query": "SELECT * FROM users",
"rows": 100,
}
def test_privacy_mode_redacts_content(self, mock_client, mock_span):
"""Test that privacy_mode redacts input/output content."""
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
privacy_mode=True,
)
span_data = GenerationSpanData(
input=[{"role": "user", "content": "Secret message"}],
output=[{"role": "assistant", "content": "Secret response"}],
model="gpt-4o",
usage={"input_tokens": 10, "output_tokens": 20},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
# Content should be redacted
assert call_kwargs["properties"]["$ai_input"] is None
assert call_kwargs["properties"]["$ai_output_choices"] is None
# Token counts should still be present
assert call_kwargs["properties"]["$ai_input_tokens"] == 10
assert call_kwargs["properties"]["$ai_output_tokens"] == 20
def test_error_handling_in_span(self, processor, mock_client, mock_span):
"""Test that span errors are captured correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {"message": "Rate limit exceeded", "data": {"code": 429}}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_is_error"] is True
assert call_kwargs["properties"]["$ai_error"] == "Rate limit exceeded"
def test_generation_span_includes_total_tokens(
self, processor, mock_client, mock_span
):
"""Test that $ai_total_tokens is calculated and included."""
span_data = GenerationSpanData(
model="gpt-4o",
usage={"input_tokens": 100, "output_tokens": 50},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_total_tokens"] == 150
def test_error_type_categorization_model_behavior(
self, processor, mock_client, mock_span
):
"""Test that ModelBehaviorError is categorized correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {
"message": "ModelBehaviorError: Invalid JSON output",
"type": "ModelBehaviorError",
}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_error_type"] == "model_behavior_error"
def test_error_type_categorization_user_error(
self, processor, mock_client, mock_span
):
"""Test that UserError is categorized correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {"message": "UserError: Tool failed", "type": "UserError"}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_error_type"] == "user_error"
def test_error_type_categorization_input_guardrail(
self, processor, mock_client, mock_span
):
"""Test that InputGuardrailTripwireTriggered is categorized correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {
"message": "InputGuardrailTripwireTriggered: Content blocked"
}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert (
call_kwargs["properties"]["$ai_error_type"] == "input_guardrail_triggered"
)
def test_error_type_categorization_output_guardrail(
self, processor, mock_client, mock_span
):
"""Test that OutputGuardrailTripwireTriggered is categorized correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {
"message": "OutputGuardrailTripwireTriggered: Response blocked"
}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert (
call_kwargs["properties"]["$ai_error_type"] == "output_guardrail_triggered"
)
def test_error_type_categorization_max_turns(
self, processor, mock_client, mock_span
):
"""Test that MaxTurnsExceeded is categorized correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {"message": "MaxTurnsExceeded: Agent exceeded maximum turns"}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_error_type"] == "max_turns_exceeded"
def test_error_type_categorization_unknown(self, processor, mock_client, mock_span):
"""Test that unknown errors are categorized as unknown."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {"message": "Some random error occurred"}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_error_type"] == "unknown"
def test_response_span_with_output_and_total_tokens(
self, processor, mock_client, mock_span
):
"""Test ResponseSpanData includes output choices and total tokens."""
# Create a mock response object
mock_response = MagicMock()
mock_response.id = "resp_123"
mock_response.model = "gpt-4o"
mock_response.output = [{"type": "message", "content": "Hello!"}]
mock_response.usage = MagicMock()
mock_response.usage.input_tokens = 25
mock_response.usage.output_tokens = 10
span_data = ResponseSpanData(
response=mock_response,
input="Hello, world!",
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_generation"
assert call_kwargs["properties"]["$ai_total_tokens"] == 35
assert call_kwargs["properties"]["$ai_output_choices"] == [
{"type": "message", "content": "Hello!"}
]
assert call_kwargs["properties"]["$ai_response_id"] == "resp_123"
def test_speech_span_with_pass_through_properties(
self, processor, mock_client, mock_span
):
"""Test SpeechSpanData includes pass-through properties."""
span_data = SpeechSpanData(
input="Hello, how can I help you?",
output="base64_audio_data",
output_format="pcm",
model="tts-1",
model_config={"voice": "alloy", "speed": 1.0},
first_content_at="2024-01-01T00:00:00.500Z",
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_type"] == "speech"
assert call_kwargs["properties"]["$ai_model"] == "tts-1"
# Pass-through properties (no $ai_ prefix)
assert (
call_kwargs["properties"]["first_content_at"] == "2024-01-01T00:00:00.500Z"
)
assert call_kwargs["properties"]["audio_output_format"] == "pcm"
assert call_kwargs["properties"]["model_config"] == {
"voice": "alloy",
"speed": 1.0,
}
# Text input should be captured
assert call_kwargs["properties"]["$ai_input"] == "Hello, how can I help you?"
def test_transcription_span_with_pass_through_properties(
self, processor, mock_client, mock_span
):
"""Test TranscriptionSpanData includes pass-through properties."""
span_data = TranscriptionSpanData(
input="base64_audio_data",
input_format="pcm",
output="This is the transcribed text.",
model="whisper-1",
model_config={"language": "en"},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_type"] == "transcription"
assert call_kwargs["properties"]["$ai_model"] == "whisper-1"
# Pass-through properties (no $ai_ prefix)
assert call_kwargs["properties"]["audio_input_format"] == "pcm"
assert call_kwargs["properties"]["model_config"] == {"language": "en"}
# Transcription output should be captured
assert (
call_kwargs["properties"]["$ai_output_state"]
== "This is the transcribed text."
)
def test_latency_calculation(self, processor, mock_client, mock_span):
"""Test that latency is calculated correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
with patch("time.time") as mock_time:
mock_time.return_value = 1000.0
processor.on_span_start(mock_span)
mock_time.return_value = 1001.5 # 1.5 seconds later
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_latency"] == pytest.approx(1.5, rel=0.01)
def test_groups_included_in_events(self, mock_client, mock_trace, mock_span):
"""Test that groups are included in captured events."""
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
groups={"company": "acme", "team": "engineering"},
)
processor.on_trace_start(mock_trace)
processor.on_trace_end(mock_trace)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["groups"] == {"company": "acme", "team": "engineering"}
def test_additional_properties_included(self, mock_client, mock_trace):
"""Test that additional properties are included in events."""
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
properties={"environment": "production", "version": "1.0"},
)
processor.on_trace_start(mock_trace)
processor.on_trace_end(mock_trace)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["environment"] == "production"
assert call_kwargs["properties"]["version"] == "1.0"
def test_shutdown_clears_state(self, processor):
"""Test that shutdown clears internal state."""
processor._span_start_times["span_1"] = 1000.0
processor._trace_metadata["trace_1"] = {"name": "test"}
processor.shutdown()
assert len(processor._span_start_times) == 0
assert len(processor._trace_metadata) == 0
def test_force_flush_calls_client_flush(self, processor, mock_client):
"""Test that force_flush calls client.flush()."""
processor.force_flush()
mock_client.flush.assert_called_once()
def test_generation_span_with_no_usage(self, processor, mock_client, mock_span):
"""Test GenerationSpanData with no usage data defaults to zero tokens."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_input_tokens"] == 0
assert call_kwargs["properties"]["$ai_output_tokens"] == 0
assert call_kwargs["properties"]["$ai_total_tokens"] == 0
def test_generation_span_with_partial_usage(
self, processor, mock_client, mock_span
):
"""Test GenerationSpanData with only input_tokens present."""
span_data = GenerationSpanData(
model="gpt-4o",
usage={"input_tokens": 42},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_input_tokens"] == 42
assert call_kwargs["properties"]["$ai_output_tokens"] == 0
assert call_kwargs["properties"]["$ai_total_tokens"] == 42
def test_error_type_categorization_by_type_field_only(
self, processor, mock_client, mock_span
):
"""Test error categorization works when only the type field matches."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {
"message": "Something went wrong",
"type": "ModelBehaviorError",
}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_error_type"] == "model_behavior_error"
def test_distinct_id_resolved_from_trace_for_spans(
self, mock_client, mock_trace, mock_span
):
"""Test that spans use the distinct_id resolved at trace start."""
def resolver(trace):
return f"user-{trace.name}"
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id=resolver,
)
# Start trace - this resolves and stores distinct_id
processor.on_trace_start(mock_trace)
mock_client.capture.reset_mock()
# End a span - should use the stored distinct_id from trace
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["distinct_id"] == "user-Test Workflow"
def test_eviction_of_stale_entries(self, mock_client):
"""Test that stale entries are evicted when max is exceeded."""
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
)
processor._max_tracked_entries = 10
# Fill beyond max
for i in range(15):
processor._span_start_times[f"span_{i}"] = float(i)
processor._trace_metadata[f"trace_{i}"] = {"name": f"trace_{i}"}
processor._evict_stale_entries()
# Should have evicted half
assert len(processor._span_start_times) <= 10
assert len(processor._trace_metadata) <= 10
class TestInstrumentHelper:
"""Tests for the instrument() convenience function."""
def test_instrument_registers_processor(self, mock_client):
"""Test that instrument() registers a processor."""
with patch("agents.tracing.add_trace_processor") as mock_add:
processor = instrument(
client=mock_client,
distinct_id="test-user",
)
mock_add.assert_called_once_with(processor)
assert isinstance(processor, PostHogTracingProcessor)
def test_instrument_with_privacy_mode(self, mock_client):
"""Test instrument() respects privacy_mode."""
with patch("agents.tracing.add_trace_processor"):
processor = instrument(
client=mock_client,
privacy_mode=True,
)
assert processor._privacy_mode is True
def test_instrument_with_groups_and_properties(self, mock_client):
"""Test instrument() accepts groups and properties."""
with patch("agents.tracing.add_trace_processor"):
processor = instrument(
client=mock_client,
groups={"company": "acme"},
properties={"env": "test"},
)
assert processor._groups == {"company": "acme"}
assert processor._properties == {"env": "test"}
-607
View File
@@ -1,607 +0,0 @@
import unittest
from unittest.mock import MagicMock, patch
from posthog.ai.prompts import Prompts
class MockResponse:
"""Mock HTTP response for testing."""
def __init__(self, json_data=None, status_code=200, ok=True):
self._json_data = json_data
self.status_code = status_code
self.ok = ok
def json(self):
if self._json_data is None:
raise ValueError("No JSON data")
return self._json_data
class TestPrompts(unittest.TestCase):
"""Tests for the Prompts class."""
mock_prompt_response = {
"id": 1,
"name": "test-prompt",
"prompt": "Hello, {{name}}! You are a helpful assistant for {{company}}.",
"version": 1,
"created_by": "user@example.com",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"deleted": False,
}
def create_mock_posthog(
self,
personal_api_key="phx_test_key",
project_api_key="phc_test_key",
host="https://us.posthog.com",
):
"""Create a mock PostHog client."""
mock = MagicMock()
mock.personal_api_key = personal_api_key
mock.api_key = project_api_key
mock.raw_host = host
return mock
class TestPromptsGet(TestPrompts):
"""Tests for the Prompts.get() method."""
@patch("posthog.ai.prompts._get_session")
def test_successfully_fetch_a_prompt(self, mock_get_session):
"""Should successfully fetch a prompt."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.get("test-prompt")
self.assertEqual(result, self.mock_prompt_response["prompt"])
mock_get.assert_called_once()
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://us.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key",
)
self.assertIn("Authorization", call_args[1]["headers"])
self.assertEqual(
call_args[1]["headers"]["Authorization"], "Bearer phx_test_key"
)
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_return_cached_prompt_when_fresh(self, mock_time, mock_get_session):
"""Should return cached prompt when fresh (no API call)."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call - fetches from API
result1 = prompts.get("test-prompt", cache_ttl_seconds=300)
self.assertEqual(result1, self.mock_prompt_response["prompt"])
self.assertEqual(mock_get.call_count, 1)
# Advance time by 60 seconds (still within TTL)
mock_time.return_value = 1060.0
# Second call - should use cache
result2 = prompts.get("test-prompt", cache_ttl_seconds=300)
self.assertEqual(result2, self.mock_prompt_response["prompt"])
self.assertEqual(mock_get.call_count, 1) # No additional fetch
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_refetch_when_cache_is_stale(self, mock_time, mock_get_session):
"""Should refetch when cache is stale."""
mock_get = mock_get_session.return_value.get
updated_prompt_response = {
**self.mock_prompt_response,
"prompt": "Updated prompt: Hello, {{name}}!",
}
mock_get.side_effect = [
MockResponse(json_data=self.mock_prompt_response),
MockResponse(json_data=updated_prompt_response),
]
mock_time.return_value = 1000.0
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call - fetches from API
result1 = prompts.get("test-prompt", cache_ttl_seconds=60)
self.assertEqual(result1, self.mock_prompt_response["prompt"])
self.assertEqual(mock_get.call_count, 1)
# Advance time past TTL
mock_time.return_value = 1061.0
# Second call - should refetch
result2 = prompts.get("test-prompt", cache_ttl_seconds=60)
self.assertEqual(result2, updated_prompt_response["prompt"])
self.assertEqual(mock_get.call_count, 2)
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
@patch("posthog.ai.prompts.log")
def test_use_stale_cache_on_fetch_failure_with_warning(
self, mock_log, mock_time, mock_get_session
):
"""Should use stale cache on fetch failure with warning."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = [
MockResponse(json_data=self.mock_prompt_response),
Exception("Network error"),
]
mock_time.return_value = 1000.0
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call - populates cache
result1 = prompts.get("test-prompt", cache_ttl_seconds=60)
self.assertEqual(result1, self.mock_prompt_response["prompt"])
# Advance time past TTL
mock_time.return_value = 1061.0
# Second call - should use stale cache
result2 = prompts.get("test-prompt", cache_ttl_seconds=60)
self.assertEqual(result2, self.mock_prompt_response["prompt"])
# Check warning was logged
mock_log.warning.assert_called()
warning_call = mock_log.warning.call_args
self.assertIn("using stale cache", warning_call[0][0])
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.log")
def test_use_fallback_when_no_cache_and_fetch_fails_with_warning(
self, mock_log, mock_get_session
):
"""Should use fallback when no cache and fetch fails with warning."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
fallback = "Default system prompt."
result = prompts.get("test-prompt", fallback=fallback)
self.assertEqual(result, fallback)
# Check warning was logged
mock_log.warning.assert_called()
warning_call = mock_log.warning.call_args
self.assertIn("using fallback", warning_call[0][0])
@patch("posthog.ai.prompts._get_session")
def test_throw_when_no_cache_no_fallback_and_fetch_fails(self, mock_get_session):
"""Should throw when no cache, no fallback, and fetch fails."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
self.assertIn("Network error", str(context.exception))
@patch("posthog.ai.prompts._get_session")
def test_handle_404_response(self, mock_get_session):
"""Should handle 404 response."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(status_code=404, ok=False)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("nonexistent-prompt")
self.assertIn('Prompt "nonexistent-prompt" not found', str(context.exception))
@patch("posthog.ai.prompts._get_session")
def test_handle_403_response(self, mock_get_session):
"""Should handle 403 response."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(status_code=403, ok=False)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("restricted-prompt")
self.assertIn(
'Access denied for prompt "restricted-prompt"', str(context.exception)
)
def test_throw_when_no_personal_api_key_configured(self):
"""Should throw when no personal_api_key is configured."""
posthog = self.create_mock_posthog(personal_api_key=None)
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
self.assertIn(
"personal_api_key is required to fetch prompts", str(context.exception)
)
def test_throw_when_no_project_api_key_configured(self):
"""Should throw when no project_api_key is configured."""
posthog = self.create_mock_posthog(project_api_key=None)
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
self.assertIn(
"project_api_key is required to fetch prompts", str(context.exception)
)
@patch("posthog.ai.prompts._get_session")
def test_throw_when_api_returns_invalid_response_format(self, mock_get_session):
"""Should throw when API returns invalid response format."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data={"invalid": "response"})
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
self.assertIn("Invalid response format", str(context.exception))
@patch("posthog.ai.prompts._get_session")
def test_use_custom_host_from_posthog_options(self, mock_get_session):
"""Should use custom host from PostHog options."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
posthog = self.create_mock_posthog(host="https://eu.posthog.com")
prompts = Prompts(posthog)
prompts.get("test-prompt")
call_args = mock_get.call_args
self.assertTrue(
call_args[0][0].startswith(
"https://eu.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key"
),
f"Expected URL to start with 'https://eu.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key', got {call_args[0][0]}",
)
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_use_default_cache_ttl_5_minutes(self, mock_time, mock_get_session):
"""Should use default cache TTL (5 minutes) when not specified."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 1)
# Advance time by 4 minutes (within default 5-minute TTL)
mock_time.return_value = 1000.0 + (4 * 60)
# Second call - should use cache
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 1)
# Advance time past 5-minute TTL
mock_time.return_value = 1000.0 + (6 * 60)
# Third call - should refetch
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 2)
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_use_custom_default_cache_ttl_from_constructor(
self, mock_time, mock_get_session
):
"""Should use custom default cache TTL from constructor."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
posthog = self.create_mock_posthog()
prompts = Prompts(posthog, default_cache_ttl_seconds=60)
# First call
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 1)
# Advance time past custom TTL
mock_time.return_value = 1061.0
# Second call - should refetch
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 2)
@patch("posthog.ai.prompts._get_session")
def test_url_encode_prompt_names_with_special_characters(self, mock_get_session):
"""Should URL-encode prompt names with special characters."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
prompts.get("prompt with spaces/and/slashes")
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://us.posthog.com/api/environments/@current/llm_prompts/name/prompt%20with%20spaces%2Fand%2Fslashes/?token=phc_test_key",
)
@patch("posthog.ai.prompts._get_session")
def test_work_with_direct_options_no_posthog_client(self, mock_get_session):
"""Should work with direct options (no PostHog client)."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
prompts = Prompts(
personal_api_key="phx_direct_key", project_api_key="phc_direct_key"
)
result = prompts.get("test-prompt")
self.assertEqual(result, self.mock_prompt_response["prompt"])
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://us.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key",
)
self.assertEqual(
call_args[1]["headers"]["Authorization"], "Bearer phx_direct_key"
)
@patch("posthog.ai.prompts._get_session")
def test_use_custom_host_from_direct_options(self, mock_get_session):
"""Should use custom host from direct options."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
prompts = Prompts(
personal_api_key="phx_direct_key",
project_api_key="phc_direct_key",
host="https://eu.posthog.com",
)
prompts.get("test-prompt")
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://eu.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key",
)
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_use_custom_default_cache_ttl_from_direct_options(
self, mock_time, mock_get_session
):
"""Should use custom default cache TTL from direct options."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
prompts = Prompts(
personal_api_key="phx_direct_key",
project_api_key="phc_direct_key",
default_cache_ttl_seconds=60,
)
# First call
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 1)
# Advance time past custom TTL
mock_time.return_value = 1061.0
# Second call - should refetch
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 2)
class TestPromptsCompile(TestPrompts):
"""Tests for the Prompts.compile() method."""
def test_replace_a_single_variable(self):
"""Should replace a single variable."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("Hello, {{name}}!", {"name": "World"})
self.assertEqual(result, "Hello, World!")
def test_replace_multiple_variables(self):
"""Should replace multiple variables."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile(
"Hello, {{name}}! Welcome to {{company}}. Your tier is {{tier}}.",
{"name": "John", "company": "Acme Corp", "tier": "premium"},
)
self.assertEqual(
result, "Hello, John! Welcome to Acme Corp. Your tier is premium."
)
def test_handle_numbers(self):
"""Should handle numbers."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("You have {{count}} items.", {"count": 42})
self.assertEqual(result, "You have 42 items.")
def test_handle_booleans(self):
"""Should handle booleans."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("Feature enabled: {{enabled}}", {"enabled": True})
self.assertEqual(result, "Feature enabled: True")
def test_leave_unmatched_variables_unchanged(self):
"""Should leave unmatched variables unchanged."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile(
"Hello, {{name}}! Your {{unknown}} is ready.", {"name": "World"}
)
self.assertEqual(result, "Hello, World! Your {{unknown}} is ready.")
def test_handle_prompts_with_no_variables(self):
"""Should handle prompts with no variables."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("You are a helpful assistant.", {})
self.assertEqual(result, "You are a helpful assistant.")
def test_handle_empty_variables_dict(self):
"""Should handle empty variables dict."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("Hello, {{name}}!", {})
self.assertEqual(result, "Hello, {{name}}!")
def test_handle_multiple_occurrences_of_same_variable(self):
"""Should handle multiple occurrences of the same variable."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile(
"Hello, {{name}}! Goodbye, {{name}}!", {"name": "World"}
)
self.assertEqual(result, "Hello, World! Goodbye, World!")
def test_work_with_direct_options_initialization(self):
"""Should work with direct options initialization."""
prompts = Prompts(
personal_api_key="phx_test_key", project_api_key="phc_test_key"
)
result = prompts.compile("Hello, {{name}}!", {"name": "World"})
self.assertEqual(result, "Hello, World!")
def test_handle_variables_with_hyphens(self):
"""Should handle variables with hyphens."""
prompts = Prompts(
personal_api_key="phx_test_key", project_api_key="phc_test_key"
)
result = prompts.compile("User ID: {{user-id}}", {"user-id": "12345"})
self.assertEqual(result, "User ID: 12345")
def test_handle_variables_with_dots(self):
"""Should handle variables with dots."""
prompts = Prompts(
personal_api_key="phx_test_key", project_api_key="phc_test_key"
)
result = prompts.compile("Company: {{company.name}}", {"company.name": "Acme"})
self.assertEqual(result, "Company: Acme")
class TestPromptsClearCache(TestPrompts):
"""Tests for the Prompts.clear_cache() method."""
@patch("posthog.ai.prompts._get_session")
def test_clear_a_specific_prompt_from_cache(self, mock_get_session):
"""Should clear a specific prompt from cache."""
mock_get = mock_get_session.return_value.get
other_prompt_response = {**self.mock_prompt_response, "name": "other-prompt"}
mock_get.side_effect = [
MockResponse(json_data=self.mock_prompt_response),
MockResponse(json_data=other_prompt_response),
MockResponse(json_data=self.mock_prompt_response),
]
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# Populate cache with two prompts
prompts.get("test-prompt")
prompts.get("other-prompt")
self.assertEqual(mock_get.call_count, 2)
# Clear only test-prompt
prompts.clear_cache("test-prompt")
# test-prompt should be refetched
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 3)
# other-prompt should still be cached
prompts.get("other-prompt")
self.assertEqual(mock_get.call_count, 3)
@patch("posthog.ai.prompts._get_session")
def test_clear_all_prompts_from_cache(self, mock_get_session):
"""Should clear all prompts from cache when no name is provided."""
mock_get = mock_get_session.return_value.get
other_prompt_response = {**self.mock_prompt_response, "name": "other-prompt"}
mock_get.side_effect = [
MockResponse(json_data=self.mock_prompt_response),
MockResponse(json_data=other_prompt_response),
MockResponse(json_data=self.mock_prompt_response),
MockResponse(json_data=other_prompt_response),
]
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# Populate cache with two prompts
prompts.get("test-prompt")
prompts.get("other-prompt")
self.assertEqual(mock_get.call_count, 2)
# Clear all cache
prompts.clear_cache()
# Both prompts should be refetched
prompts.get("test-prompt")
prompts.get("other-prompt")
self.assertEqual(mock_get.call_count, 4)
if __name__ == "__main__":
unittest.main()
-206
View File
@@ -1,4 +1,3 @@
import os
import unittest
from posthog.ai.sanitization import (
@@ -69,25 +68,6 @@ class TestSanitization(unittest.TestCase):
)
self.assertEqual(result[0]["content"][1]["image_url"]["detail"], "high")
def test_sanitize_openai_input_image(self):
input_data = [
{
"role": "user",
"content": [
{
"type": "input_image",
"image_url": self.sample_base64_image,
}
],
}
]
result = sanitize_openai(input_data)
self.assertEqual(
result[0]["content"][0]["image_url"], REDACTED_IMAGE_PLACEHOLDER
)
def test_sanitize_openai_preserves_regular_urls(self):
input_data = [
{
@@ -351,191 +331,5 @@ 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()
+22 -31
View File
@@ -11,10 +11,7 @@ regardless of how they're passed to the providers:
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
from unittest.mock import patch, MagicMock
class TestSystemPromptCapture(unittest.TestCase):
@@ -27,8 +24,7 @@ class TestSystemPromptCapture(unittest.TestCase):
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 = MagicMock()
self.client.privacy_mode = False
def _assert_system_prompt_captured(self, captured_input):
@@ -57,11 +53,10 @@ class TestSystemPromptCapture(unittest.TestCase):
def test_openai_messages_array_system_prompt(self):
"""Test OpenAI with system prompt in messages array."""
try:
from posthog.ai.openai import OpenAI
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.completion_usage import CompletionUsage
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
@@ -99,18 +94,17 @@ class TestSystemPromptCapture(unittest.TestCase):
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.assertEqual(len(self.client.capture.call_args_list), 1)
properties = self.client.capture.call_args_list[0][1]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
def test_openai_separate_system_parameter(self):
"""Test OpenAI with system prompt as separate parameter."""
try:
from posthog.ai.openai import OpenAI
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.completion_usage import CompletionUsage
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
@@ -148,21 +142,18 @@ class TestSystemPromptCapture(unittest.TestCase):
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.assertEqual(len(self.client.capture.call_args_list), 1)
properties = self.client.capture.call_args_list[0][1]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
def test_openai_streaming_system_parameter(self):
"""Test OpenAI streaming with system parameter."""
try:
from 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
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
from openai.types.chat.chat_completion_chunk import ChoiceDelta
from openai.types.completion_usage import CompletionUsage
except ImportError:
self.skipTest("OpenAI package not available")
@@ -215,8 +206,8 @@ class TestSystemPromptCapture(unittest.TestCase):
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.assertEqual(len(self.client.capture.call_args_list), 1)
properties = self.client.capture.call_args_list[0][1]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
# Anthropic Tests
@@ -248,8 +239,8 @@ class TestSystemPromptCapture(unittest.TestCase):
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.assertEqual(len(self.client.capture.call_args_list), 1)
properties = self.client.capture.call_args_list[0][1]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
def test_anthropic_separate_system_parameter(self):
@@ -278,8 +269,8 @@ class TestSystemPromptCapture(unittest.TestCase):
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.assertEqual(len(self.client.capture.call_args_list), 1)
properties = self.client.capture.call_args_list[0][1]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
# Gemini Tests
@@ -319,8 +310,8 @@ class TestSystemPromptCapture(unittest.TestCase):
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.assertEqual(len(self.client.capture.call_args_list), 1)
properties = self.client.capture.call_args_list[0][1]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
def test_gemini_system_instruction_parameter(self):
@@ -358,6 +349,6 @@ class TestSystemPromptCapture(unittest.TestCase):
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.assertEqual(len(self.client.capture.call_args_list), 1)
properties = self.client.capture.call_args_list[0][1]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
-62
View File
@@ -1,62 +0,0 @@
from parameterized import parameterized
from posthog.ai.utils import _get_tokens_source
@parameterized.expand(
[
("no_posthog_properties", {"$ai_input_tokens": 100}, None, "sdk"),
("empty_posthog_properties", {"$ai_input_tokens": 100}, {}, "sdk"),
(
"unrelated_posthog_properties",
{"$ai_input_tokens": 100},
{"foo": "bar"},
"sdk",
),
(
"override_input_tokens",
{"$ai_input_tokens": 100},
{"$ai_input_tokens": 999},
"passthrough",
),
(
"override_output_tokens",
{"$ai_output_tokens": 50},
{"$ai_output_tokens": 999},
"passthrough",
),
(
"override_total_tokens",
{"$ai_input_tokens": 100},
{"$ai_total_tokens": 999},
"passthrough",
),
(
"override_cache_read",
{"$ai_input_tokens": 100},
{"$ai_cache_read_input_tokens": 500},
"passthrough",
),
(
"override_cache_creation",
{"$ai_input_tokens": 100},
{"$ai_cache_creation_input_tokens": 200},
"passthrough",
),
(
"override_reasoning_tokens",
{"$ai_input_tokens": 100},
{"$ai_reasoning_tokens": 300},
"passthrough",
),
(
"mixed_override_and_custom",
{"$ai_input_tokens": 100},
{"$ai_input_tokens": 999, "custom_key": "value"},
"passthrough",
),
]
)
def test_get_tokens_source(name, sdk_tags, posthog_properties, expected):
result = _get_tokens_source(sdk_tags, posthog_properties)
assert result == expected
+1 -226
View File
@@ -315,9 +315,7 @@ class TestPosthogContextMiddlewareSync(unittest.TestCase):
get_response = Mock(return_value=mock_response)
# Create middleware with request filter that filters all requests
def request_filter(req):
return False
request_filter = lambda req: False
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
middleware.get_response = get_response
middleware._is_coroutine = False
@@ -501,229 +499,6 @@ class TestPosthogContextMiddlewareAsync(unittest.TestCase):
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"""
+22 -336
View File
@@ -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, GetResponse
from posthog.request import APIError
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import FeatureFlag, LegacyFlagMetadata
from posthog.version import VERSION
@@ -198,6 +198,12 @@ 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"
@@ -409,9 +415,7 @@ class TestClient(unittest.TestCase):
)
client.feature_flags = [multivariate_flag, basic_flag, false_flag]
msg_uuid = client.capture(
"python test event", distinct_id="distinct_id", send_feature_flags=True
)
msg_uuid = client.capture("python test event", distinct_id="distinct_id")
self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)
@@ -479,20 +483,6 @@ class TestClient(unittest.TestCase):
self.assertEqual(client.cohorts, {})
self.assertIn("PostHog feature flags quota limited", logs.output[0])
@mock.patch("posthog.client.get")
def test_load_feature_flags_unauthorized(self, patch_get):
patch_get.side_effect = APIError(401, "Unauthorized")
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
with self.assertLogs("posthog", level="ERROR") as logs:
client._load_feature_flags()
self.assertEqual(client.feature_flags, [])
self.assertEqual(client.feature_flags_by_key, {})
self.assertEqual(client.group_type_mapping, {})
self.assertEqual(client.cohorts, {})
self.assertIn("please set a valid personal_api_key", logs.output[0])
@mock.patch("posthog.client.flags")
def test_dont_override_capture_with_local_flags(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
@@ -581,7 +571,6 @@ 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)
@@ -662,7 +651,6 @@ class TestClient(unittest.TestCase):
person_properties={},
group_properties={},
geoip_disable=True,
device_id=None,
)
@mock.patch("posthog.client.flags")
@@ -727,7 +715,6 @@ class TestClient(unittest.TestCase):
person_properties={},
group_properties={},
geoip_disable=False,
device_id=None,
)
@mock.patch("posthog.client.flags")
@@ -765,178 +752,6 @@ 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
@@ -1927,7 +1742,6 @@ class TestClient(unittest.TestCase):
person_properties={"distinct_id": "some_id"},
group_properties={},
geoip_disable=True,
device_id=None,
flag_keys_to_evaluate=["random_key"],
)
patch_flags.reset_mock()
@@ -1943,7 +1757,6 @@ class TestClient(unittest.TestCase):
person_properties={"distinct_id": "feature_enabled_distinct_id"},
group_properties={},
geoip_disable=True,
device_id=None,
flag_keys_to_evaluate=["random_key"],
)
patch_flags.reset_mock()
@@ -1957,7 +1770,6 @@ class TestClient(unittest.TestCase):
person_properties={"distinct_id": "all_flags_payloads_id"},
group_properties={},
geoip_disable=False,
device_id=None,
)
@mock.patch("posthog.client.Poller")
@@ -2006,7 +1818,6 @@ class TestClient(unittest.TestCase):
"instance": {"$group_key": "app.posthog.com"},
},
geoip_disable=False,
device_id=None,
flag_keys_to_evaluate=["random_key"],
)
@@ -2034,7 +1845,6 @@ class TestClient(unittest.TestCase):
"instance": {"$group_key": "app.posthog.com"},
},
geoip_disable=False,
device_id=None,
flag_keys_to_evaluate=["random_key"],
)
@@ -2052,116 +1862,8 @@ class TestClient(unittest.TestCase):
person_properties={"distinct_id": "some_id"},
group_properties={},
geoip_disable=False,
device_id=None,
)
@parameterized.expand(
[
# method, method_args, expected_person_props, expected_flag_keys
(
"get_feature_flag",
["random_key", "some_id"],
{"distinct_id": "some_id"},
["random_key"],
),
(
"feature_enabled",
["random_key", "some_id"],
{"distinct_id": "some_id"},
["random_key"],
),
(
"get_all_flags_and_payloads",
["some_id"],
{"distinct_id": "some_id"},
None,
),
("get_all_flags", ["some_id"], {"distinct_id": "some_id"}, None),
("get_flags_decision", ["some_id"], {}, None),
]
)
@mock.patch("posthog.client.flags")
def test_device_id_is_passed_to_flags_request(
self,
method,
method_args,
expected_person_props,
expected_flag_keys,
patch_flags,
):
"""Test that device_id is properly passed to the flags request when provided."""
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
getattr(client, method)(*method_args, device_id="test-device-123")
expected_call = {
"distinct_id": "some_id",
"groups": {},
"person_properties": expected_person_props,
"group_properties": {},
"geoip_disable": True,
"device_id": "test-device-123",
}
if expected_flag_keys:
expected_call["flag_keys_to_evaluate"] = expected_flag_keys
patch_flags.assert_called_with(
"random_key", "https://us.i.posthog.com", timeout=3, **expected_call
)
@mock.patch("posthog.client.flags")
def test_device_id_from_context_is_used_in_flags_request(self, patch_flags):
"""Test that device_id from context is used in flags request when not explicitly provided."""
from posthog.contexts import new_context, set_context_device_id
patch_flags.return_value = {
"featureFlags": {
"beta-feature": "random-variant",
}
}
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
)
# Test that device_id from context is used
with new_context():
set_context_device_id("context-device-id")
client.get_feature_flag("random_key", "some_id")
patch_flags.assert_called_with(
"random_key",
"https://us.i.posthog.com",
timeout=3,
distinct_id="some_id",
groups={},
person_properties={"distinct_id": "some_id"},
group_properties={},
geoip_disable=True,
device_id="context-device-id",
flag_keys_to_evaluate=["random_key"],
)
# Test that explicit device_id overrides context
patch_flags.reset_mock()
with new_context():
set_context_device_id("context-device-id")
client.get_feature_flag(
"random_key", "some_id", device_id="explicit-device-id"
)
patch_flags.assert_called_with(
"random_key",
"https://us.i.posthog.com",
timeout=3,
distinct_id="some_id",
groups={},
person_properties={"distinct_id": "some_id"},
group_properties={},
geoip_disable=True,
device_id="explicit-device-id",
flag_keys_to_evaluate=["random_key"],
)
@parameterized.expand(
[
# name, sys_platform, version_info, expected_runtime, expected_version, expected_os, expected_os_version, platform_method, platform_return, distro_info
@@ -2393,21 +2095,13 @@ 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 = GetResponse(
data={
"flags": [
{
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"active": True,
}
],
"group_type_mapping": {},
"cohorts": {},
},
etag='"test-etag"',
)
patch_get.return_value = {
"flags": [
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
],
"group_type_mapping": {},
"cohorts": {},
}
client = Client(
FAKE_TEST_API_KEY,
@@ -2429,21 +2123,13 @@ 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 = GetResponse(
data={
"flags": [
{
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"active": True,
}
],
"group_type_mapping": {},
"cohorts": {},
},
etag='"test-etag"',
)
patch_get.return_value = {
"flags": [
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
],
"group_type_mapping": {},
"cohorts": {},
}
client = Client(
FAKE_TEST_API_KEY,
+79 -139
View File
@@ -1,10 +1,8 @@
import json
import time
import unittest
from typing import Any
import mock
from parameterized import parameterized
try:
from queue import Queue
@@ -16,19 +14,15 @@ from posthog.request import APIError
from posthog.test.test_utils import TEST_API_KEY
def _track_event(event_name: str = "python event") -> dict[str, str]:
return {"type": "track", "event": event_name, "distinct_id": "distinct_id"}
class TestConsumer(unittest.TestCase):
def test_next(self) -> None:
def test_next(self):
q = Queue()
consumer = Consumer(q, "")
q.put(1)
next = consumer.next()
self.assertEqual(next, [1])
def test_next_limit(self) -> None:
def test_next_limit(self):
q = Queue()
flush_at = 50
consumer = Consumer(q, "", flush_at)
@@ -37,7 +31,7 @@ class TestConsumer(unittest.TestCase):
next = consumer.next()
self.assertEqual(next, list(range(flush_at)))
def test_dropping_oversize_msg(self) -> None:
def test_dropping_oversize_msg(self):
q = Queue()
consumer = Consumer(q, "")
oversize_msg = {"m": "x" * MAX_MSG_SIZE}
@@ -46,14 +40,15 @@ class TestConsumer(unittest.TestCase):
self.assertEqual(next, [])
self.assertTrue(q.empty())
def test_upload(self) -> None:
def test_upload(self):
q = Queue()
consumer = Consumer(q, TEST_API_KEY)
q.put(_track_event())
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
q.put(track)
success = consumer.upload()
self.assertTrue(success)
def test_flush_interval(self) -> None:
def test_flush_interval(self):
# Put _n_ items in the queue, pausing a little bit more than
# _flush_interval_ after each one.
# The consumer should upload _n_ times.
@@ -62,12 +57,17 @@ class TestConsumer(unittest.TestCase):
consumer = Consumer(q, TEST_API_KEY, flush_at=10, flush_interval=flush_interval)
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(3):
q.put(_track_event("python event %d" % i))
for i in range(0, 3):
track = {
"type": "track",
"event": "python event %d" % i,
"distinct_id": "distinct_id",
}
q.put(track)
time.sleep(flush_interval * 1.1)
self.assertEqual(mock_post.call_count, 3)
def test_multiple_uploads_per_interval(self) -> None:
def test_multiple_uploads_per_interval(self):
# Put _flush_at*2_ items in the queue at once, then pause for
# _flush_interval_. The consumer should upload 2 times.
q = Queue()
@@ -78,60 +78,88 @@ class TestConsumer(unittest.TestCase):
)
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(flush_at * 2):
q.put(_track_event("python event %d" % i))
for i in range(0, flush_at * 2):
track = {
"type": "track",
"event": "python event %d" % i,
"distinct_id": "distinct_id",
}
q.put(track)
time.sleep(flush_interval * 1.1)
self.assertEqual(mock_post.call_count, 2)
def test_request(self) -> None:
def test_request(self):
consumer = Consumer(None, TEST_API_KEY)
consumer.request([_track_event()])
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
consumer.request([track])
def _run_retry_test(
self, exception: Exception, exception_count: int, retries: int = 10
) -> None:
call_count = [0]
def _test_request_retry(self, consumer, expected_exception, exception_count):
def mock_post(*args, **kwargs):
mock_post.call_count += 1
if mock_post.call_count <= exception_count:
raise expected_exception
def mock_post(*args: Any, **kwargs: Any) -> None:
call_count[0] += 1
if call_count[0] <= exception_count:
raise exception
mock_post.call_count = 0
consumer = Consumer(None, TEST_API_KEY, retries=retries)
with mock.patch(
"posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)
):
if exception_count <= retries:
consumer.request([_track_event()])
track = {
"type": "track",
"event": "python event",
"distinct_id": "distinct_id",
}
# request() should succeed if the number of exceptions raised is
# less than the retries paramater.
if exception_count <= consumer.retries:
consumer.request([track])
else:
with self.assertRaises(type(exception)):
consumer.request([_track_event()])
# if exceptions are raised more times than the retries
# parameter, we expect the exception to be returned to
# the caller.
try:
consumer.request([track])
except type(expected_exception) as exc:
self.assertEqual(exc, expected_exception)
else:
self.fail(
"request() should raise an exception if still failing after %d retries"
% consumer.retries
)
@parameterized.expand(
[
("general_errors", Exception("generic exception"), 2),
("server_errors", APIError(500, "Internal Server Error"), 2),
("rate_limit_errors", APIError(429, "Too Many Requests"), 2),
]
)
def test_request_retries_on_retriable_errors(
self, _name: str, exception: Exception, exception_count: int
) -> None:
self._run_retry_test(exception, exception_count)
def test_request_retry(self):
# we should retry on general errors
consumer = Consumer(None, TEST_API_KEY)
self._test_request_retry(consumer, Exception("generic exception"), 2)
def test_request_does_not_retry_client_errors(self) -> None:
with self.assertRaises(APIError):
self._run_retry_test(APIError(400, "Client Errors"), 1)
# we should retry on server errors
consumer = Consumer(None, TEST_API_KEY)
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 2)
def test_request_fails_when_exceptions_exceed_retries(self) -> None:
self._run_retry_test(APIError(500, "Internal Server Error"), 4, retries=3)
# we should retry on HTTP 429 errors
consumer = Consumer(None, TEST_API_KEY)
self._test_request_retry(consumer, APIError(429, "Too Many Requests"), 2)
def test_pause(self) -> None:
# we should NOT retry on other client errors
consumer = Consumer(None, TEST_API_KEY)
api_error = APIError(400, "Client Errors")
try:
self._test_request_retry(consumer, api_error, 1)
except APIError:
pass
else:
self.fail("request() should not retry on client errors")
# test for number of exceptions raise > retries value
consumer = Consumer(None, TEST_API_KEY, retries=3)
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 3)
def test_pause(self):
consumer = Consumer(None, TEST_API_KEY)
consumer.pause()
self.assertFalse(consumer.running)
def test_max_batch_size(self) -> None:
def test_max_batch_size(self):
q = Queue()
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
properties = {}
@@ -147,7 +175,7 @@ class TestConsumer(unittest.TestCase):
# Let's capture 8MB of data to trigger two batches
n_msgs = int(8_000_000 / msg_size)
def mock_post_fn(_: str, data: str, **kwargs: Any) -> mock.Mock:
def mock_post_fn(_, data, **kwargs):
res = mock.Mock()
res.status_code = 200
request_size = len(data.encode())
@@ -166,91 +194,3 @@ class TestConsumer(unittest.TestCase):
q.put(track)
q.join()
self.assertEqual(mock_post.call_count, 2)
def test_request_sleeps_with_retry_after(self) -> None:
error = APIError(429, "Too Many Requests", retry_after=5.0)
call_count = [0]
def mock_post(*args: Any, **kwargs: Any) -> None:
call_count[0] += 1
if call_count[0] <= 1:
raise error
consumer = Consumer(None, TEST_API_KEY, retries=3)
with (
mock.patch("posthog.consumer.batch_post", side_effect=mock_post),
mock.patch("posthog.consumer.time.sleep") as mock_sleep,
):
consumer.request([_track_event()])
mock_sleep.assert_called_once_with(5.0)
def test_request_uses_exponential_backoff_without_retry_after(self) -> None:
error = APIError(503, "Service Unavailable")
call_count = [0]
def mock_post(*args: Any, **kwargs: Any) -> None:
call_count[0] += 1
if call_count[0] <= 3:
raise error
consumer = Consumer(None, TEST_API_KEY, retries=3)
with (
mock.patch("posthog.consumer.batch_post", side_effect=mock_post),
mock.patch("posthog.consumer.time.sleep") as mock_sleep,
):
consumer.request([_track_event()])
self.assertEqual(
mock_sleep.call_args_list,
[
mock.call(1), # 2^0
mock.call(2), # 2^1
mock.call(4), # 2^2
],
)
def test_request_retries_on_408(self) -> None:
call_count = [0]
def mock_post(*args: Any, **kwargs: Any) -> None:
call_count[0] += 1
if call_count[0] <= 1:
raise APIError(408, "Request Timeout")
consumer = Consumer(None, TEST_API_KEY, retries=3)
with (
mock.patch("posthog.consumer.batch_post", side_effect=mock_post),
mock.patch("posthog.consumer.time.sleep"),
):
consumer.request([_track_event()])
self.assertEqual(call_count[0], 2)
@parameterized.expand(
[
("on_error_succeeds", False),
("on_error_raises", True),
]
)
def test_upload_exception_calls_on_error_and_does_not_raise(
self, _name: str, on_error_raises: bool
) -> None:
on_error_called: list[tuple[Exception, list[dict[str, str]]]] = []
def on_error(e: Exception, batch: list[dict[str, str]]) -> None:
on_error_called.append((e, batch))
if on_error_raises:
raise Exception("on_error failed")
q = Queue()
consumer = Consumer(q, TEST_API_KEY, on_error=on_error)
track = _track_event()
q.put(track)
with mock.patch.object(
consumer, "request", side_effect=Exception("request failed")
):
result = consumer.upload()
self.assertFalse(result)
self.assertEqual(len(on_error_called), 1)
self.assertEqual(str(on_error_called[0][0]), "request failed")
self.assertEqual(on_error_called[0][1], [track])
-26
View File
@@ -191,32 +191,6 @@ 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():
-655
View File
@@ -32,658 +32,3 @@ 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
def test_code_variables_too_long_string_value_replaced(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():
short_value = "I am short"
long_value = "x" * 20000
long_blob = "password_" + "a" * 20000
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 "'short_value': 'I am short'" in output
assert "$$_posthog_value_too_long_$$" in output
assert "'long_blob': '$$_posthog_value_too_long_$$'" in output
def test_code_variables_too_long_string_in_nested_dict(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():
my_data = {
"short_key": "short_val",
"long_key": "y" * 20000,
"nested": {
"deep_long": "z" * 20000,
"deep_short": "ok",
},
}
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 "short_val" in output
assert "ok" in output
assert "$$_posthog_value_too_long_$$" in output
assert "y" * 1000 not in output
assert "z" * 1000 not in output
def test_mask_sensitive_data_too_long_dict_key():
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
)
compiled_mask = _compile_patterns([r"(?i)password"])
result = _mask_sensitive_data(
{
"short": "visible",
"k" * 20000: "hidden_val",
"password": "secret",
},
compiled_mask,
)
assert result["short"] == "visible"
# This then gets shortened by the JSON truncation at 1024 chars anyways so no worries
assert result["k" * 20000] == CODE_VARIABLES_TOO_LONG_VALUE
assert result["password"] == "$$_posthog_redacted_based_on_masking_rules_$$"
def test_mask_sensitive_data_circular_ref():
from posthog.exception_utils import _compile_patterns, _mask_sensitive_data
compiled_mask = _compile_patterns([r"(?i)password"])
# Circular dict
circular_dict = {"key": "value"}
circular_dict["self"] = circular_dict
result = _mask_sensitive_data(circular_dict, compiled_mask)
assert result["key"] == "value"
assert result["self"] == "<circular ref>"
# Circular list
circular_list = ["item"]
circular_list.append(circular_list)
result = _mask_sensitive_data(circular_list, compiled_mask)
assert result[0] == "item"
assert result[1] == "<circular ref>"
def test_compile_patterns_fast_path_and_regex_fallback():
from posthog.exception_utils import _compile_patterns, _pattern_matches
# Simple case-insensitive patterns should become substrings
simple_only = _compile_patterns([r"(?i)password", r"(?i)token", r"(?i)jwt"])
substrings, regexes = simple_only
assert substrings == ["password", "token", "jwt"]
assert regexes == []
assert _pattern_matches("my_password_var", simple_only) is True
assert _pattern_matches("MY_TOKEN", simple_only) is True
assert _pattern_matches("safe_variable", simple_only) is False
# Complex regex patterns should stay as compiled regexes
complex_only = _compile_patterns([r"^__.*", r"\d{3,}", r"^sk_live_"])
substrings, regexes = complex_only
assert substrings == []
assert len(regexes) == 3
assert _pattern_matches("__dunder", complex_only) is True
assert _pattern_matches("has_999_numbers", complex_only) is True
assert _pattern_matches("sk_live_abc123", complex_only) is True
assert _pattern_matches("normal_var", complex_only) is False
# Mixed: simple substrings + complex regexes together
mixed = _compile_patterns(
[
r"(?i)secret", # simple
r"(?i)api_key", # simple
r"^__.*", # regex
r"\btoken_\w+", # regex
]
)
substrings, regexes = mixed
assert substrings == ["secret", "api_key"]
assert len(regexes) == 2
# Substring matches
assert _pattern_matches("my_secret", mixed) is True
assert _pattern_matches("API_KEY_VALUE", mixed) is True
# Regex matches
assert _pattern_matches("__private", mixed) is True
assert _pattern_matches("token_abc", mixed) is True
# No match
assert _pattern_matches("safe_var", mixed) is False
def test_mask_sensitive_data_large_dict_replaced():
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
)
compiled_mask = _compile_patterns([r"(?i)password"])
large_dict = {f"key_{i}": f"value_{i}" for i in range(300)}
result = _mask_sensitive_data(large_dict, compiled_mask)
assert result == CODE_VARIABLES_TOO_LONG_VALUE
def test_mask_sensitive_data_large_list_replaced():
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
)
compiled_mask = _compile_patterns([r"(?i)password"])
large_list = [f"item_{i}" for i in range(300)]
result = _mask_sensitive_data(large_list, compiled_mask)
assert result == CODE_VARIABLES_TOO_LONG_VALUE
def test_mask_sensitive_data_large_tuple_replaced():
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
)
compiled_mask = _compile_patterns([r"(?i)password"])
large_tuple = tuple(f"item_{i}" for i in range(300))
result = _mask_sensitive_data(large_tuple, compiled_mask)
assert result == CODE_VARIABLES_TOO_LONG_VALUE
+2 -441
View File
@@ -4,13 +4,7 @@ import mock
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import (
FeatureFlag,
FeatureFlagError,
FeatureFlagResult,
FlagMetadata,
FlagReason,
)
from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason
class TestFeatureFlagResult(unittest.TestCase):
@@ -195,6 +189,7 @@ 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):
@@ -246,9 +241,6 @@ 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):
@@ -303,9 +295,6 @@ 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"}
@@ -371,9 +360,6 @@ 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")
@@ -417,9 +403,6 @@ 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")
@@ -455,428 +438,6 @@ 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,
+75 -729
View File
@@ -11,7 +11,7 @@ from posthog.feature_flags import (
match_property,
relative_date_parse_for_feature_flag_matching,
)
from posthog.request import APIError, GetResponse
from posthog.request import APIError
from posthog.test.test_utils import FAKE_TEST_API_KEY
@@ -233,27 +233,6 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 1)
def test_group_flag_is_inconclusive_when_group_properties_missing(self):
feature_flag = {
"id": 1,
"name": "Group Flag Without Property Filters",
"key": "group-flag-no-props",
"active": True,
"filters": {
"aggregation_group_type_index": 0,
"groups": [{"properties": [], "rollout_percentage": 100}],
},
}
self.client.group_type_mapping = {"0": "company"}
with self.assertRaises(InconclusiveMatchError):
self.client._compute_flag_locally(
feature_flag,
"some-distinct-id",
groups={"company": "acme"},
group_properties={},
)
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_with_complex_definition(self, patch_get, patch_flags):
@@ -2369,27 +2348,23 @@ 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 = 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"',
)
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"},
}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
with freeze_time("2020-01-01T12:01:00.0000Z"):
client.load_feature_flags()
@@ -2400,144 +2375,8 @@ 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")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_wrong_key(self, patch_get, _patch_poll):
patch_get.side_effect = APIError(401, "Unauthorized")
def test_load_feature_flags_wrong_key(self):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
with self.assertLogs("posthog", level="ERROR") as logs:
@@ -3086,7 +2925,6 @@ class TestLocalEvaluation(unittest.TestCase):
"some-distinct-id",
match_value=True,
person_properties={"region": "USA"},
send_feature_flag_events=True,
),
300,
)
@@ -3244,541 +3082,6 @@ class TestLocalEvaluation(unittest.TestCase):
# Verify API was called (fallback occurred)
self.assertEqual(patch_flags.call_count, 1)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_uses_device_id_for_hash(self, patch_flags):
"""
When a flag has bucketing_identifier: "device_id", the device_id should be
used for hashing instead of distinct_id.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
# This flag uses device_id for bucketing
client.feature_flags = [
{
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
]
# Same distinct_id with different device_ids should produce different results
# (based on rollout percentage, we check consistency)
result1 = client.get_feature_flag(
"device-bucketed-flag", "user-123", device_id="device-A"
)
result2 = client.get_feature_flag(
"device-bucketed-flag", "user-123", device_id="device-A"
)
# Same device_id should give consistent results
self.assertEqual(result1, result2)
# No API fallback should occur
self.assertEqual(patch_flags.call_count, 0)
def test_match_feature_flag_properties_without_bucketing_value_is_deprecated(
self,
):
"""
match_feature_flag_properties should preserve backward compatibility when
bucketing_value is omitted, while warning about deprecation.
"""
from posthog.feature_flags import match_feature_flag_properties
flag = {
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
with self.assertWarnsRegex(
DeprecationWarning, "without bucketing_value is deprecated"
):
result = match_feature_flag_properties(
flag,
"user-123",
{},
device_id="device-123",
)
self.assertTrue(result)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_same_device_different_users_same_result(
self, patch_flags
):
"""
When a flag uses device_id bucketing, different distinct_ids with the same
device_id should get the same result.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 50,
}
],
},
}
]
# Different distinct_ids with the same device_id should get the same result
result1 = client.get_feature_flag(
"device-bucketed-flag", "user-A", device_id="shared-device"
)
result2 = client.get_feature_flag(
"device-bucketed-flag", "user-B", device_id="shared-device"
)
result3 = client.get_feature_flag(
"device-bucketed-flag", "user-C", device_id="shared-device"
)
# All should be the same since device_id is the same
self.assertEqual(result1, result2)
self.assertEqual(result2, result3)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_fallback_when_device_id_missing(self, patch_flags):
"""
When a flag requires device_id for bucketing but none is provided,
it should fallback to server evaluation.
"""
patch_flags.return_value = {"featureFlags": {"device-bucketed-flag": True}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
]
# No device_id provided - should fallback to API
result = client.get_feature_flag("device-bucketed-flag", "user-123")
self.assertTrue(result)
# API should have been called
self.assertEqual(patch_flags.call_count, 1)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_returns_none_when_only_evaluate_locally_and_no_device_id(
self, patch_flags
):
"""
When only_evaluate_locally=True and device_id is required but missing,
should return None instead of falling back to API.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
]
# No device_id + only_evaluate_locally should return None
result = client.get_feature_flag(
"device-bucketed-flag", "user-123", only_evaluate_locally=True
)
self.assertIsNone(result)
# API should NOT have been called
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_default_bucketing_identifier_uses_distinct_id(self, patch_flags):
"""
When bucketing_identifier is not set or is 'distinct_id', should use
distinct_id for hashing (default behavior).
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
# Flag without bucketing_identifier (defaults to distinct_id)
client.feature_flags = [
{
"id": 1,
"key": "normal-flag",
"active": True,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 50,
}
],
},
}
]
# Different distinct_ids should potentially produce different results
# but same distinct_id should produce same result
result1 = client.get_feature_flag("normal-flag", "user-A")
result2 = client.get_feature_flag("normal-flag", "user-A")
self.assertEqual(result1, result2)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_with_multivariate_flag(self, patch_flags):
"""
Multivariate flag variant selection should use device_id when
bucketing_identifier is set to device_id.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "multivariate-device-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
"multivariate": {
"variants": [
{"key": "control", "rollout_percentage": 50},
{"key": "test", "rollout_percentage": 50},
]
},
},
}
]
# Same device_id should give same variant
result1 = client.get_feature_flag(
"multivariate-device-flag", "user-A", device_id="device-1"
)
result2 = client.get_feature_flag(
"multivariate-device-flag", "user-B", device_id="device-1"
)
# Both should get the same variant because device_id is the same
self.assertEqual(result1, result2)
self.assertIn(result1, ["control", "test"])
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_from_context(self, patch_flags):
"""
When device_id is not passed as a parameter but is set in the context,
it should be resolved from context.
"""
from posthog.contexts import new_context, set_context_device_id
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
]
# Set device_id in context
with new_context():
set_context_device_id("context-device-id")
result = client.get_feature_flag("device-bucketed-flag", "user-123")
# Should evaluate locally using the context device_id
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_group_flags_ignore_bucketing_identifier(self, patch_flags):
"""
Group flags should continue to use the group identifier for hashing,
regardless of the bucketing_identifier setting.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "group-flag",
"active": True,
"filters": {
"aggregation_group_type_index": 0,
"bucketing_identifier": "device_id", # Should be ignored for group flags
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
]
client.group_type_mapping = {"0": "company"}
# Even with bucketing_identifier set to device_id, group flag should use group identifier
result = client.get_feature_flag(
"group-flag",
"user-123",
groups={"company": "acme-inc"},
device_id="some-device",
)
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_group_flag_dependency_receives_device_id(self, patch_flags):
"""
Group flag dependency evaluation should receive device_id so dependent
device_id-bucketed flags can be evaluated locally.
"""
patch_flags.return_value = {"featureFlags": {"group-parent-flag": "from-api"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "device-dependent-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
},
{
"id": 2,
"key": "group-parent-flag",
"active": True,
"filters": {
"aggregation_group_type_index": 0,
"groups": [
{
"properties": [
{
"key": "device-dependent-flag",
"operator": "flag_evaluates_to",
"value": True,
"type": "flag",
"dependency_chain": ["device-dependent-flag"],
}
],
"rollout_percentage": 100,
}
],
},
},
]
client.group_type_mapping = {"0": "company"}
result = client.get_feature_flag(
"group-parent-flag",
"user-123",
groups={"company": "acme-inc"},
device_id="device-123",
)
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_group_flag_dependency_ignores_device_id_bucketing_identifier(
self, patch_flags
):
"""
Group flag dependencies should keep bucketing by group key, even when
the dependent group flag has bucketing_identifier set to device_id.
"""
patch_flags.return_value = {"featureFlags": {"parent-group-flag": "from-api"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "child-group-flag",
"active": True,
"filters": {
"aggregation_group_type_index": 0,
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
},
{
"id": 2,
"key": "parent-group-flag",
"active": True,
"filters": {
"aggregation_group_type_index": 0,
"groups": [
{
"properties": [
{
"key": "child-group-flag",
"operator": "flag_evaluates_to",
"value": True,
"type": "flag",
"dependency_chain": ["child-group-flag"],
}
],
"rollout_percentage": 100,
}
],
},
},
]
client.group_type_mapping = {"0": "company"}
result = client.get_feature_flag(
"parent-group-flag",
"user-123",
groups={"company": "acme-inc"},
)
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_device_id_bucketing(self, patch_flags):
"""
get_all_flags_and_payloads should properly handle flags with device_id bucketing.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "normal-flag",
"active": True,
"filters": {
"groups": [{"properties": [], "rollout_percentage": 100}],
},
},
{
"id": 2,
"key": "device-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [{"properties": [], "rollout_percentage": 100}],
},
},
]
# With device_id provided, both flags should be evaluated locally
result = client.get_all_flags("user-123", device_id="my-device")
self.assertEqual(result["normal-flag"], True)
self.assertEqual(result["device-flag"], True)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_get_all_flags_fallback_when_device_id_missing_for_some_flags(
self, patch_flags
):
"""
When some flags require device_id but it's not provided, those flags
should trigger fallback while others can be evaluated locally.
"""
patch_flags.return_value = {
"featureFlags": {"normal-flag": True, "device-flag": "from-api"}
}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "normal-flag",
"active": True,
"filters": {
"groups": [{"properties": [], "rollout_percentage": 100}],
},
},
{
"id": 2,
"key": "device-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [{"properties": [], "rollout_percentage": 100}],
},
},
]
# Without device_id, device-flag can't be evaluated locally
client.get_all_flags("user-123")
# Should fallback to API for all flags when any can't be evaluated locally
self.assertEqual(patch_flags.call_count, 1)
class TestMatchProperties(unittest.TestCase):
def property(self, key, value, operator=None):
@@ -4556,7 +3859,6 @@ class TestCaptureCalls(unittest.TestCase):
},
},
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
"evaluatedAt": 1234567890,
}
client = Client(FAKE_TEST_API_KEY)
@@ -4576,7 +3878,6 @@ 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,
@@ -4611,9 +3912,7 @@ class TestCaptureCalls(unittest.TestCase):
self.assertEqual(
client.get_feature_flag_payload(
"decide-flag-with-payload",
"some-distinct-id",
send_feature_flag_events=True,
"decide-flag-with-payload", "some-distinct-id"
),
{"foo": "bar"},
)
@@ -4689,10 +3988,9 @@ class TestCaptureCalls(unittest.TestCase):
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.flags")
def test_get_feature_flag_payload_does_not_send_feature_flag_called_events(
def test_capture_is_called_in_get_feature_flag_payload(
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},
@@ -4714,18 +4012,68 @@ class TestCaptureCalls(unittest.TestCase):
"rollout_percentage": 100,
}
],
"payloads": {"true": '"payload"'},
},
}
]
payload = client.get_feature_flag_payload(
# Call get_feature_flag_payload with match_value=None to trigger get_feature_flag
client.get_feature_flag_payload(
key="person-flag",
distinct_id="some-distinct-id",
person_properties={"region": "USA", "name": "Aloha"},
)
self.assertIsNotNone(payload)
# 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.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"},
)
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,
)
patch_capture.reset_mock()
@mock.patch("posthog.client.flags")
def test_fallback_to_api_in_get_feature_flag_payload_when_flag_has_static_cohort(
@@ -4825,15 +4173,13 @@ class TestCaptureCalls(unittest.TestCase):
disable_geoip=False,
)
@mock.patch("posthog.client.MAX_DICT_SIZE", 100)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.flags")
def test_capture_multiple_users_doesnt_out_of_memory(
self, patch_flags, patch_capture
):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
# Set on the instance to avoid relying on module-constant patching behavior
# across Python/runtime implementations.
client.distinct_ids_feature_flags_reported.max_size = 100
client.feature_flags = [
{
"id": 1,
-612
View File
@@ -1,612 +0,0 @@
"""
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()
-536
View File
@@ -6,60 +6,16 @@ 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(
@@ -151,184 +107,6 @@ 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",
[
@@ -350,317 +128,3 @@ class TestGet(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 -4
View File
@@ -1,4 +1,3 @@
import sys
import time
import unittest
from dataclasses import dataclass
@@ -123,9 +122,7 @@ class TestUtils(unittest.TestCase):
"bar": 2,
"baz": None,
}
# Pydantic V1 is not compatible with Python 3.14+
if sys.version_info < (3, 14):
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
assert utils.clean(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"}
}
-40
View File
@@ -123,7 +123,6 @@ class FlagsResponse(TypedDict, total=False):
errorsWhileComputingFlags: bool
requestId: str
quotaLimit: Optional[List[str]]
evaluatedAt: Optional[int]
class FlagsAndPayloads(TypedDict, total=True):
@@ -307,42 +306,3 @@ 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}"
+4 -1
View File
@@ -1 +1,4 @@
VERSION = "7.9.4"
VERSION = "6.7.11"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+13 -15
View File
@@ -4,24 +4,24 @@ build-backend = "setuptools.build_meta"
[project]
name = "posthog"
version = "7.9.4"
dynamic = ["version"]
description = "Integrate PostHog into any python application."
authors = [{ name = "PostHog", email = "hey@posthog.com" }]
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.9"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"requests>=2.7,<3.0",
@@ -66,13 +66,13 @@ test = [
"pytest-timeout",
"pytest-asyncio",
"django",
"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",
"openai",
"anthropic",
"langgraph>=0.4.8",
"langchain-core>=0.3.65",
"langchain-community>=0.3.25",
"langchain-openai>=0.3.22",
"langchain-anthropic>=0.3.15",
"google-genai",
"pydantic",
"parameterized>=0.8.1",
@@ -84,17 +84,15 @@ packages = [
"posthog.ai",
"posthog.ai.langchain",
"posthog.ai.openai",
"posthog.ai.openai_agents",
"posthog.ai.anthropic",
"posthog.ai.gemini",
"posthog.test",
"posthog.test.ai",
"posthog.test.ai.openai_agents",
"posthog.integrations",
]
[tool.setuptools.dynamic]
version = { attr = "posthog.version.VERSION" }
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["posthog/test"]
norecursedirs = ["integration_tests"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
"id": "posthog-python",
"hogRef": "0.3",
"info": {
"version": "7.0.1",
"version": "6.7.11",
"id": "posthog-python",
"title": "PostHog Python SDK",
"description": "Integrate PostHog into any python application.",
@@ -503,24 +503,6 @@
"description": "",
"isOptional": false,
"type": "bool"
},
{
"name": "capture_exception_code_variables",
"description": "",
"isOptional": false,
"type": "bool"
},
{
"name": "code_variables_mask_patterns",
"description": "",
"isOptional": false,
"type": "any"
},
{
"name": "code_variables_ignore_patterns",
"description": "",
"isOptional": false,
"type": "any"
}
],
"showDocs": true,
@@ -1496,7 +1478,12 @@
{
"id": "example_1",
"name": "Set with distinct id",
"code": "# Set with distinct id\nposthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})"
"code": "# Set with distinct id\nposthog.capture(\n 'event_name',\n distinct_id='user-distinct-id',\n properties={\n '$set': {'name': 'Max Hedgehog'},\n '$set_once': {'initial_url': '/blog'}\n }\n)"
},
{
"id": "example_2",
"name": "Set using context",
"code": "# Set using context\nfrom posthog import new_context, identify_context\nwith new_context():\n identify_context('user-distinct-id')\n posthog.capture('event_name')"
}
]
},
@@ -2151,12 +2138,6 @@
"description": "Whether to capture exceptions raised within the context (default: True)",
"isOptional": false,
"type": "bool"
},
{
"name": "client",
"description": "Optional Posthog client instance to use for this context (default: None)",
"isOptional": false,
"type": "any"
}
],
"showDocs": true,
@@ -2235,69 +2216,6 @@
}
]
},
{
"id": "set_capture_exception_code_variables_context",
"title": "set_capture_exception_code_variables_context",
"description": "Set whether code variables are captured for the current context.",
"details": "",
"category": null,
"params": [
{
"name": "enabled",
"description": "",
"isOptional": true,
"type": "bool"
}
],
"showDocs": true,
"releaseTag": "public",
"returnType": {
"id": "return_type",
"name": "None"
}
},
{
"id": "set_code_variables_ignore_patterns_context",
"title": "set_code_variables_ignore_patterns_context",
"description": "Variable names matching these patterns will be ignored completely when capturing code variables.",
"details": "",
"category": null,
"params": [
{
"name": "ignore_patterns",
"description": "",
"isOptional": true,
"type": "list"
}
],
"showDocs": true,
"releaseTag": "public",
"returnType": {
"id": "return_type",
"name": "None"
}
},
{
"id": "set_code_variables_mask_patterns_context",
"title": "set_code_variables_mask_patterns_context",
"description": "Variable names matching these patterns will be masked with *** when capturing code variables.",
"details": "",
"category": null,
"params": [
{
"name": "mask_patterns",
"description": "",
"isOptional": true,
"type": "list"
}
],
"showDocs": true,
"releaseTag": "public",
"returnType": {
"id": "return_type",
"name": "None"
}
},
{
"id": "set_context_session",
"title": "set_context_session",
-22
View File
@@ -1,22 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
# Copy the SDK source code
COPY posthog/ /app/sdk/posthog/
COPY setup.py pyproject.toml README.md LICENSE /app/sdk/
# Install the SDK from source
RUN cd /app/sdk && pip install --no-cache-dir -e .
# Install adapter dependencies
RUN pip install --no-cache-dir flask python-dateutil
# Copy adapter code
COPY sdk_compliance_adapter/adapter.py /app/adapter.py
# Expose port 8080
EXPOSE 8080
# Run the adapter
CMD ["python", "/app/adapter.py"]
-76
View File
@@ -1,76 +0,0 @@
# PostHog Python SDK Test Adapter
This adapter wraps the posthog-python SDK for compliance testing with the [PostHog SDK Test Harness](https://github.com/PostHog/posthog-sdk-test-harness).
## What is This?
This is a simple Flask app that:
1. Wraps the posthog-python SDK
2. Exposes a REST API for the test harness to control
3. Tracks internal SDK state for test assertions
## Running Tests
Tests run automatically in CI via GitHub Actions. See the test harness repo for details.
### Locally with Docker Compose
```bash
# From the posthog-python/sdk_compliance_adapter directory
docker-compose up --build --abort-on-container-exit
```
This will:
1. Build the Python SDK adapter
2. Pull the test harness image
3. Run all compliance tests
4. Show results
### Manually with Docker
```bash
# Create network
docker network create test-network
# Build and run adapter
docker build -f sdk_compliance_adapter/Dockerfile -t posthog-python-adapter .
docker run -d --name sdk-adapter --network test-network -p 8080:8080 posthog-python-adapter
# Run test harness
docker run --rm \
--name test-harness \
--network test-network \
ghcr.io/posthog/sdk-test-harness:latest \
run --adapter-url http://sdk-adapter:8080 --mock-url http://test-harness:8081
# Cleanup
docker stop sdk-adapter && docker rm sdk-adapter
docker network rm test-network
```
## Adapter Implementation
See [adapter.py](adapter.py) for the implementation.
The adapter implements the standard SDK adapter interface defined in the [test harness CONTRACT](https://github.com/PostHog/posthog-sdk-test-harness/blob/main/CONTRACT.yaml):
- `GET /health` - Return SDK information
- `POST /init` - Initialize SDK with config
- `POST /capture` - Capture an event
- `POST /flush` - Flush pending events
- `GET /state` - Return internal state
- `POST /reset` - Reset SDK state
### Key Implementation Details
**Request Tracking**: The adapter monkey-patches `batch_post` to track all HTTP requests made by the SDK, including retries.
**State Management**: Thread-safe state tracking for events captured vs sent, retry attempts, and errors.
**UUID Tracking**: Extracts and tracks UUIDs from batches to verify deduplication.
## Documentation
For complete documentation on the test harness and how to implement adapters, see:
- [PostHog SDK Test Harness](https://github.com/PostHog/posthog-sdk-test-harness)
- [Adapter Implementation Guide](https://github.com/PostHog/posthog-sdk-test-harness/blob/main/ADAPTER_GUIDE.md)
-383
View File
@@ -1,383 +0,0 @@
"""
PostHog Python SDK Test Adapter
This adapter implements the SDK Test Adapter Interface defined in the PostHog Capture API Contract.
It wraps the posthog-python SDK and exposes a REST API for the test harness to exercise.
"""
import logging
import os
import threading
import time
from typing import Any, Dict, List, Optional
from flask import Flask, jsonify, request
from posthog import Client
from posthog.request import batch_post as original_batch_post
from posthog.version import VERSION
# Configure logging
logging.basicConfig(
level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
class RequestInfo:
"""Information about an HTTP request made by the SDK"""
def __init__(
self,
timestamp_ms: int,
status_code: int,
retry_attempt: int,
event_count: int,
uuid_list: List[str],
):
self.timestamp_ms = timestamp_ms
self.status_code = status_code
self.retry_attempt = retry_attempt
self.event_count = event_count
self.uuid_list = uuid_list
def to_dict(self) -> Dict[str, Any]:
return {
"timestamp_ms": self.timestamp_ms,
"status_code": self.status_code,
"retry_attempt": self.retry_attempt,
"event_count": self.event_count,
"uuid_list": self.uuid_list,
}
class SDKState:
"""Tracks SDK internal state for test assertions"""
def __init__(self):
self.lock = threading.Lock()
self.pending_events = 0
self.total_events_captured = 0
self.total_events_sent = 0
self.total_retries = 0
self.last_error: Optional[str] = None
self.requests_made: List[RequestInfo] = []
self.client: Optional[Client] = None
self.retry_attempts: Dict[str, int] = {} # Track retry attempts by batch ID
def reset(self):
"""Reset all state"""
with self.lock:
self.pending_events = 0
self.total_events_captured = 0
self.total_events_sent = 0
self.total_retries = 0
self.last_error = None
self.requests_made = []
self.retry_attempts = {}
if self.client:
# Flush and shutdown existing client
try:
self.client.shutdown()
except Exception as e:
logger.warning(f"Error shutting down client: {e}")
self.client = None
def increment_captured(self):
"""Increment total events captured"""
with self.lock:
self.total_events_captured += 1
self.pending_events += 1
def record_request(self, status_code: int, batch: List[Dict], batch_id: str):
"""Record an HTTP request made by the SDK"""
with self.lock:
# Determine retry attempt for this batch
retry_attempt = self.retry_attempts.get(batch_id, 0)
# Extract UUIDs from batch
uuid_list = [event.get("uuid", "") for event in batch]
request_info = RequestInfo(
timestamp_ms=int(time.time() * 1000),
status_code=status_code,
retry_attempt=retry_attempt,
event_count=len(batch),
uuid_list=uuid_list,
)
self.requests_made.append(request_info)
# Update counters
if status_code == 200:
# Success - clear pending events
self.total_events_sent += len(batch)
self.pending_events = max(0, self.pending_events - len(batch))
# Remove batch from retry tracking
self.retry_attempts.pop(batch_id, None)
else:
# Failure - increment retry count
self.retry_attempts[batch_id] = retry_attempt + 1
if retry_attempt > 0:
self.total_retries += 1
def record_error(self, error: str):
"""Record an error"""
with self.lock:
self.last_error = error
def get_state(self) -> Dict[str, Any]:
"""Get current state as dict"""
with self.lock:
return {
"pending_events": self.pending_events,
"total_events_captured": self.total_events_captured,
"total_events_sent": self.total_events_sent,
"total_retries": self.total_retries,
"last_error": self.last_error,
"requests_made": [r.to_dict() for r in self.requests_made],
}
# Global state
state = SDKState()
def create_batch_id(batch: List[Dict]) -> str:
"""Create a unique ID for a batch based on UUIDs"""
uuids = sorted([event.get("uuid", "") for event in batch])
return "-".join(uuids[:3]) # Use first 3 UUIDs as batch ID
def patched_batch_post(
api_key: str,
host: Optional[str] = None,
gzip: bool = False,
timeout: int = 15,
**kwargs,
):
"""Patched version of batch_post that tracks requests"""
batch = kwargs.get("batch", [])
batch_id = create_batch_id(batch)
try:
# Call original batch_post
response = original_batch_post(api_key, host, gzip, timeout, **kwargs)
# Record successful request
state.record_request(200, batch, batch_id)
return response
except Exception as e:
# Record failed request
status_code = (
getattr(e, "status_code", 500) if hasattr(e, "status_code") else 500
)
state.record_request(status_code, batch, batch_id)
state.record_error(str(e))
raise
# Monkey-patch the batch_post function
import posthog.request # noqa: E402
posthog.request.batch_post = patched_batch_post
# Also patch in consumer module
import posthog.consumer # noqa: E402
posthog.consumer.batch_post = patched_batch_post
@app.route("/health", methods=["GET"])
def health():
"""Health check endpoint"""
return jsonify(
{
"sdk_name": "posthog-python",
"sdk_version": VERSION,
"adapter_version": "1.0.0",
}
)
@app.route("/init", methods=["POST"])
def init():
"""Initialize the SDK client"""
try:
data = request.json or {}
# Reset state
state.reset()
# Extract config
api_key = data.get("api_key")
host = data.get("host")
flush_at = data.get("flush_at", 100)
flush_interval_ms = data.get("flush_interval_ms", 500)
max_retries = data.get("max_retries", 3)
enable_compression = data.get("enable_compression", False)
if not api_key:
return jsonify({"error": "api_key is required"}), 400
if not host:
return jsonify({"error": "host is required"}), 400
# Convert flush_interval from ms to seconds
flush_interval = flush_interval_ms / 1000.0
# Create client
client = Client(
project_api_key=api_key,
host=host,
flush_at=flush_at,
flush_interval=flush_interval,
gzip=enable_compression,
max_retries=max_retries,
debug=True,
)
state.client = client
logger.info(
f"Initialized SDK with api_key={api_key[:10]}..., host={host}, "
f"flush_at={flush_at}, flush_interval={flush_interval}, "
f"max_retries={max_retries}, gzip={enable_compression}"
)
return jsonify({"success": True})
except Exception as e:
logger.exception("Error initializing SDK")
return jsonify({"error": str(e)}), 500
@app.route("/capture", methods=["POST"])
def capture():
"""Capture a single event"""
try:
if not state.client:
return jsonify({"error": "SDK not initialized"}), 400
data = request.json or {}
distinct_id = data.get("distinct_id")
event = data.get("event")
properties = data.get("properties")
timestamp = data.get("timestamp")
if not distinct_id:
return jsonify({"error": "distinct_id is required"}), 400
if not event:
return jsonify({"error": "event is required"}), 400
# Capture event
kwargs = {"distinct_id": distinct_id, "properties": properties}
if timestamp:
# Parse ISO8601 timestamp
from dateutil.parser import parse # type: ignore[import-untyped]
kwargs["timestamp"] = parse(timestamp)
uuid = state.client.capture(event, **kwargs)
# Track that we captured an event
state.increment_captured()
logger.info(f"Captured event: {event} for {distinct_id}, uuid={uuid}")
return jsonify({"success": True, "uuid": uuid})
except Exception as e:
logger.exception("Error capturing event")
state.record_error(str(e))
return jsonify({"error": str(e)}), 500
@app.route("/identify", methods=["POST"])
def identify():
"""Identify a user"""
try:
if not state.client:
return jsonify({"error": "SDK not initialized"}), 400
data = request.json or {}
distinct_id = data.get("distinct_id")
properties = data.get("properties")
properties_set_once = data.get("properties_set_once")
if not distinct_id:
return jsonify({"error": "distinct_id is required"}), 400
# Use the identify pattern - set + set_once
if properties:
state.client.set(distinct_id=distinct_id, properties=properties)
state.increment_captured()
if properties_set_once:
state.client.set_once(
distinct_id=distinct_id, properties=properties_set_once
)
state.increment_captured()
logger.info(f"Identified user: {distinct_id}")
return jsonify({"success": True})
except Exception as e:
logger.exception("Error identifying user")
state.record_error(str(e))
return jsonify({"error": str(e)}), 500
@app.route("/flush", methods=["POST"])
def flush():
"""Force flush all pending events"""
try:
if not state.client:
return jsonify({"error": "SDK not initialized"}), 400
# Flush and wait
state.client.flush()
# Wait a bit for flush to complete
# The flush() method triggers queue.join() which blocks until all items are processed
time.sleep(0.5)
logger.info("Flushed pending events")
return jsonify({"success": True, "events_flushed": state.total_events_sent})
except Exception as e:
logger.exception("Error flushing events")
state.record_error(str(e))
return jsonify({"error": str(e), "errors": [str(e)]}, 500)
@app.route("/state", methods=["GET"])
def get_state():
"""Get internal SDK state"""
try:
return jsonify(state.get_state())
except Exception as e:
logger.exception("Error getting state")
return jsonify({"error": str(e)}), 500
@app.route("/reset", methods=["POST"])
def reset():
"""Reset SDK state"""
try:
state.reset()
logger.info("Reset SDK state")
return jsonify({"success": True})
except Exception as e:
logger.exception("Error resetting state")
return jsonify({"error": str(e)}), 500
def main():
"""Main entry point"""
port = int(os.environ.get("PORT", 8080))
logger.info(f"Starting SDK Test Adapter on port {port}")
app.run(host="0.0.0.0", port=port, debug=False)
if __name__ == "__main__":
main()
-25
View File
@@ -1,25 +0,0 @@
version: "3.8"
services:
# PostHog Python SDK adapter
sdk-adapter:
build:
context: ..
dockerfile: sdk_compliance_adapter/Dockerfile
ports:
- "8080:8080"
networks:
- test-network
# Test harness
test-harness:
image: ghcr.io/posthog/sdk-test-harness:latest
command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"]
networks:
- test-network
depends_on:
- sdk-adapter
networks:
test-network:
driver: bridge
-3
View File
@@ -1,3 +0,0 @@
# SDK Test Adapter dependencies
flask>=3.0.0
python-dateutil>=2.8.0
+1 -1
View File
@@ -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.10 or higher.
This package requires Python 3.9 or higher.
"""
# Minimal setup.py for backward compatibility
+4 -2
View File
@@ -23,7 +23,9 @@ with open("pyproject.toml", "rb") as f:
# Override specific values
config["project"]["name"] = "posthoganalytics"
config["project"]["readme"] = "README_ANALYTICS.md"
config["tool"]["setuptools"]["dynamic"]["version"] = {
"attr": "posthoganalytics.version.VERSION"
}
# Rename packages from posthog.* to posthoganalytics.*
if "packages" in config["tool"]["setuptools"]:
@@ -45,7 +47,7 @@ long_description = """
PostHog is developer-friendly, self-hosted product analytics.
posthog-python is the python package.
This package requires Python 3.10 or higher.
This package requires Python 3.9 or higher.
"""
# Minimal setup.py for backward compatibility
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""
Test script to send capture_ai events to localhost:8010.
This script tests the actual network request to a local PostHog instance.
"""
from posthog import Posthog
from uuid import uuid4
# Create a client pointing to localhost:8010
posthog = Posthog(
"test-api-key", # Use your actual project API key if needed
host="http://localhost:8010",
debug=True, # Enable debug mode to see detailed logs
)
print("Testing capture_ai with localhost:8010")
print("=" * 60)
# Test 1: $ai_generation event with blobs
print("\n1. Testing $ai_generation event with blobs...")
print("-" * 60)
trace_id = f"trace_{uuid4().hex[:8]}"
try:
event_uuid = posthog.capture_ai(
"$ai_generation",
distinct_id="test_user_123",
properties={
"$ai_model": "gpt-4",
"$ai_provider": "openai",
"$ai_trace_id": trace_id,
"$ai_input": {
"messages": [
{
"role": "system",
"content": "You are a helpful assistant that answers questions about Python.",
},
{
"role": "user",
"content": "What is the difference between a list and a tuple?",
},
],
"temperature": 0.7,
"max_tokens": 500,
},
"$ai_output_choices": {
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "A list is mutable (can be changed) while a tuple is immutable (cannot be changed after creation). Lists use square brackets [] and tuples use parentheses ().",
},
"finish_reason": "stop",
}
],
"model": "gpt-4",
"usage": {
"prompt_tokens": 45,
"completion_tokens": 32,
"total_tokens": 77,
},
},
"$ai_completion_tokens": 32,
"$ai_prompt_tokens": 45,
"$ai_total_tokens": 77,
"$ai_latency": 1.234,
},
blob_properties=["$ai_input", "$ai_output_choices"],
)
if event_uuid:
print("✓ SUCCESS: $ai_generation event sent")
print(f" UUID: {event_uuid}")
print(f" Trace ID: {trace_id}")
else:
print("✗ FAILED: No UUID returned")
except Exception as e:
print(f"✗ ERROR: {e}")
import traceback
traceback.print_exc()
# Test 2: $ai_trace event
print("\n2. Testing $ai_trace event...")
print("-" * 60)
try:
event_uuid = posthog.capture_ai(
"$ai_trace",
distinct_id="test_user_123",
properties={
"$ai_model": "gpt-4",
"$ai_trace_id": trace_id,
"$ai_trace_name": "python_qa_session",
"$ai_input_state": {
"session_id": "session_123",
"user_context": "learning Python",
},
"$ai_output_state": {"questions_answered": 1, "satisfaction_score": 5},
},
blob_properties=["$ai_input_state", "$ai_output_state"],
)
if event_uuid:
print("✓ SUCCESS: $ai_trace event sent")
print(f" UUID: {event_uuid}")
print(f" Trace ID: {trace_id}")
else:
print("✗ FAILED: No UUID returned")
except Exception as e:
print(f"✗ ERROR: {e}")
import traceback
traceback.print_exc()
# Test 3: $ai_span event
print("\n3. Testing $ai_span event...")
print("-" * 60)
span_id = f"span_{uuid4().hex[:8]}"
try:
event_uuid = posthog.capture_ai(
"$ai_span",
distinct_id="test_user_123",
properties={
"$ai_model": "gpt-4",
"$ai_trace_id": trace_id,
"$ai_span_id": span_id,
"$ai_span_name": "answer_generation",
"$ai_parent_id": trace_id,
"$ai_span_kind": "llm",
"$ai_latency": 0.8,
},
)
if event_uuid:
print("✓ SUCCESS: $ai_span event sent")
print(f" UUID: {event_uuid}")
print(f" Span ID: {span_id}")
else:
print("✗ FAILED: No UUID returned")
except Exception as e:
print(f"✗ ERROR: {e}")
import traceback
traceback.print_exc()
# Test 4: $ai_embedding event
print("\n4. Testing $ai_embedding event...")
print("-" * 60)
try:
event_uuid = posthog.capture_ai(
"$ai_embedding",
distinct_id="test_user_123",
properties={
"$ai_model": "text-embedding-ada-002",
"$ai_provider": "openai",
"$ai_trace_id": trace_id,
"$ai_input": {
"text": "What is the difference between a list and a tuple in Python?"
},
"$ai_embedding_dimension": 1536,
"$ai_latency": 0.123,
},
blob_properties=["$ai_input"],
)
if event_uuid:
print("✓ SUCCESS: $ai_embedding event sent")
print(f" UUID: {event_uuid}")
else:
print("✗ FAILED: No UUID returned")
except Exception as e:
print(f"✗ ERROR: {e}")
import traceback
traceback.print_exc()
# Test 5: $ai_metric event
print("\n5. Testing $ai_metric event...")
print("-" * 60)
try:
event_uuid = posthog.capture_ai(
"$ai_metric",
distinct_id="test_user_123",
properties={
"$ai_model": "gpt-4",
"$ai_trace_id": trace_id,
"$ai_metric_name": "response_quality",
"$ai_metric_value": "0.95",
},
)
if event_uuid:
print("✓ SUCCESS: $ai_metric event sent")
print(f" UUID: {event_uuid}")
else:
print("✗ FAILED: No UUID returned")
except Exception as e:
print(f"✗ ERROR: {e}")
import traceback
traceback.print_exc()
# Test 6: $ai_feedback event
print("\n6. Testing $ai_feedback event...")
print("-" * 60)
try:
event_uuid = posthog.capture_ai(
"$ai_feedback",
distinct_id="test_user_123",
properties={
"$ai_model": "gpt-4",
"$ai_trace_id": trace_id,
"$ai_feedback_text": "Great explanation! Very clear and helpful.",
"$ai_feedback_rating": 5,
},
)
if event_uuid:
print("✓ SUCCESS: $ai_feedback event sent")
print(f" UUID: {event_uuid}")
else:
print("✗ FAILED: No UUID returned")
except Exception as e:
print(f"✗ ERROR: {e}")
import traceback
traceback.print_exc()
# Test 7: Test with custom blob properties
print("\n7. Testing with custom blob properties...")
print("-" * 60)
try:
event_uuid = posthog.capture_ai(
"$ai_generation",
distinct_id="test_user_123",
properties={
"$ai_model": "claude-3-opus",
"$ai_provider": "anthropic",
"$ai_trace_id": trace_id,
"$ai_input": {
"messages": [{"role": "user", "content": "Write a haiku about Python"}]
},
"$ai_output_choices": {
"choices": [
{
"message": {
"role": "assistant",
"content": "Snake glides through code\nSimple syntax, powerful tools\nDevelopers smile",
}
}
]
},
"$ai_custom_data": {
"large_context": "This is some large custom data that should be sent as a blob"
},
"$ai_completion_tokens": 20,
"$ai_prompt_tokens": 10,
},
# Custom blob properties - including the default ones plus a custom one
blob_properties=["$ai_input", "$ai_output_choices", "$ai_custom_data"],
)
if event_uuid:
print("✓ SUCCESS: Event with custom blob properties sent")
print(f" UUID: {event_uuid}")
else:
print("✗ FAILED: No UUID returned")
except Exception as e:
print(f"✗ ERROR: {e}")
import traceback
traceback.print_exc()
# Test 8: Test with groups
print("\n8. Testing with groups...")
print("-" * 60)
try:
event_uuid = posthog.capture_ai(
"$ai_generation",
distinct_id="test_user_123",
groups={"company": "posthog_inc", "team": "engineering"},
properties={
"$ai_model": "gpt-4",
"$ai_provider": "openai",
"$ai_trace_id": trace_id,
"$ai_input": {"messages": [{"role": "user", "content": "test"}]},
"$ai_output_choices": {
"choices": [{"message": {"role": "assistant", "content": "response"}}]
},
},
)
if event_uuid:
print("✓ SUCCESS: Event with groups sent")
print(f" UUID: {event_uuid}")
else:
print("✗ FAILED: No UUID returned")
except Exception as e:
print(f"✗ ERROR: {e}")
import traceback
traceback.print_exc()
print("\n" + "=" * 60)
print("All tests completed!")
print("\nMake sure your local PostHog instance is running on http://localhost:8010")
print("and that the /i/v0/ai endpoint is available.")
Generated
+1978 -2242
View File
File diff suppressed because it is too large Load Diff