Compare commits
223
Commits
no-context
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53159f70b6 | ||
|
|
359e47de80 | ||
|
|
c8443d3a00 | ||
|
|
8cdd93b4aa | ||
|
|
a638777561 | ||
|
|
34b739cd6b | ||
|
|
9fb72596af | ||
|
|
7105552a05 | ||
|
|
98a2ca443c | ||
|
|
f4cbdf28c4 | ||
|
|
11466c625e | ||
|
|
ef5e1356ef | ||
|
|
a99c7d73b1 | ||
|
|
b206669bf6 | ||
|
|
16e180231f | ||
|
|
8d83315b67 | ||
|
|
1e1e566fa5 | ||
|
|
830244bd40 | ||
|
|
60001f1829 | ||
|
|
a68a6a6d04 | ||
|
|
150e24ba6a | ||
|
|
a8b5529baf | ||
|
|
d45c04646e | ||
|
|
9f9553a420 | ||
|
|
16bc87b646 | ||
|
|
f1dc4d7391 | ||
|
|
23dae56d68 | ||
|
|
73bec043cf | ||
|
|
603ed376dd | ||
|
|
bb0c7b4fa8 | ||
|
|
499194e0c4 | ||
|
|
ffb8e9b591 | ||
|
|
7780ca8390 | ||
|
|
bca175214d | ||
|
|
fe3a9bbf75 | ||
|
|
b6e66330e5 | ||
|
|
4f32fa4100 | ||
|
|
f5719f39da | ||
|
|
d4f2d6dfb0 | ||
|
|
72f448816c | ||
|
|
4350389f93 | ||
|
|
c32c78312f | ||
|
|
1875b712d2 | ||
|
|
661a0ec8ba | ||
|
|
d3609c2975 | ||
|
|
92d810e6b6 | ||
|
|
f9c2959fd0 | ||
|
|
2b3eb6782b | ||
|
|
50f675b849 | ||
|
|
7dd6229530 | ||
|
|
4e4cd18574 | ||
|
|
c1548c40ef | ||
|
|
f1c6da2da2 | ||
|
|
7ac63e1615 | ||
|
|
14d1d0b99c | ||
|
|
80e6e432b4 | ||
|
|
5d0bae1b22 | ||
|
|
b17928075a | ||
|
|
b6dbff1cb7 | ||
|
|
9f8faf70a1 | ||
|
|
440651d90d | ||
|
|
da8653305f | ||
|
|
88a7c5ec84 | ||
|
|
d72e89adab | ||
|
|
ce38fb2a49 | ||
|
|
103a7ad933 | ||
|
|
fff9992fe9 | ||
|
|
c253e418c3 | ||
|
|
285597740e | ||
|
|
7c7f5293af | ||
|
|
494c78675d | ||
|
|
f75c5efeec | ||
|
|
65785b892e | ||
|
|
6dde2bf9e5 | ||
|
|
48203364a9 | ||
|
|
805c308841 | ||
|
|
898654a174 | ||
|
|
499d54570c | ||
|
|
6c815dffc3 | ||
|
|
3a1b8e49cd | ||
|
|
f3e5d7132f | ||
|
|
88f606994c | ||
|
|
a155e1dfd6 | ||
|
|
f648a5dfd7 | ||
|
|
e309bd7149 | ||
|
|
0cfd678857 | ||
|
|
3d8825fc67 | ||
|
|
3e52e7feda | ||
|
|
98e322695d | ||
|
|
700c922baf | ||
|
|
69293f5198 | ||
|
|
46589f93d5 | ||
|
|
57546d29e6 | ||
|
|
50b0c7170a | ||
|
|
f719c3dadf | ||
|
|
105090a6ba | ||
|
|
edfadcc6a8 | ||
|
|
13184e2e16 | ||
|
|
1b8642331f | ||
|
|
6af129f414 | ||
|
|
02e82a6050 | ||
|
|
9a05db8b20 | ||
|
|
e06830e068 | ||
|
|
465baea6f8 | ||
|
|
2bd6e9eaf1 | ||
|
|
e6fe39a0dd | ||
|
|
67f68c00fe | ||
|
|
6156e51f8f | ||
|
|
461c45772a | ||
|
|
a221bffb52 | ||
|
|
26cfd818af | ||
|
|
e868e23dcb | ||
|
|
0bb6342472 | ||
|
|
d76bfe6e5b | ||
|
|
b3e21c1c0e | ||
|
|
08b11cbf9b | ||
|
|
cee26bb3dc | ||
|
|
9f370675d4 | ||
|
|
6e00d573f3 | ||
|
|
a91a20876e | ||
|
|
10472e721d | ||
|
|
fb38447869 | ||
|
|
ae97131107 | ||
|
|
675dea16a6 | ||
|
|
6a3e7ef3ad | ||
|
|
20b8825bd2 | ||
|
|
818edc2811 | ||
|
|
05074351a3 | ||
|
|
d25fae383c | ||
|
|
68e78c877d | ||
|
|
07cf32bb04 | ||
|
|
0076b66b75 | ||
|
|
09dad8117f | ||
|
|
09b9b5dc88 | ||
|
|
5a52af66a9 | ||
|
|
722c88701b | ||
|
|
6ab2856f8d | ||
|
|
7a8b09123c | ||
|
|
da09639428 | ||
|
|
6a271026d1 | ||
|
|
6d9247960f | ||
|
|
c4e09cdd40 | ||
|
|
c61236b26a | ||
|
|
b965332698 | ||
|
|
4739945a82 | ||
|
|
50ab10c858 | ||
|
|
37bd30194e | ||
|
|
b41dc8568e | ||
|
|
f0e1cdf870 | ||
|
|
e23ca94296 | ||
|
|
5a7f324a61 | ||
|
|
e13c428ff6 | ||
|
|
77190c23e1 | ||
|
|
b7753392f7 | ||
|
|
250bd424d0 | ||
|
|
579cc56787 | ||
|
|
3778eaef7b | ||
|
|
52df246a3e | ||
|
|
f1f9ecf7a4 | ||
|
|
9db1b7e9f3 | ||
|
|
01751d1205 | ||
|
|
4426dd9d27 | ||
|
|
bf0d7efbfe | ||
|
|
f17ebfa12b | ||
|
|
800527da43 | ||
|
|
0d29fb7be3 | ||
|
|
24d89806cb | ||
|
|
a2105f6e95 | ||
|
|
3171193d75 | ||
|
|
1db6e45258 | ||
|
|
1daa8a8053 | ||
|
|
5d58a53b36 | ||
|
|
7af8e886ee | ||
|
|
90d3fca27d | ||
|
|
243b98df11 | ||
|
|
7ab2080309 | ||
|
|
23e1d8e2a3 | ||
|
|
e2d8200cc6 | ||
|
|
da69b68f7d | ||
|
|
57c3cba200 | ||
|
|
9f4ef4f24f | ||
|
|
7aea6b72d3 | ||
|
|
7bb7c90a49 | ||
|
|
c1f668e8bb | ||
|
|
a1b81ee3d9 | ||
|
|
a6fb39902d | ||
|
|
a1583f6627 | ||
|
|
dfa7f70a04 | ||
|
|
d00d69e448 | ||
|
|
a833955ee0 | ||
|
|
58fbe05cb0 | ||
|
|
7a6e185902 | ||
|
|
e9c72e7f8c | ||
|
|
51380ac207 | ||
|
|
53ed80366b | ||
|
|
18729e33b8 | ||
|
|
334394bed2 | ||
|
|
14a2f80c6d | ||
|
|
2779ad194c | ||
|
|
5a4167d5ce | ||
|
|
332a6fffb6 | ||
|
|
28a7d351ba | ||
|
|
8331af7a42 | ||
|
|
f4c99714c3 | ||
|
|
7dc4cbb16b | ||
|
|
4cda646f03 | ||
|
|
ea4e7fa16d | ||
|
|
57a3e7470f | ||
|
|
5e0f9e35c1 | ||
|
|
337f7da7c5 | ||
|
|
31652d5ec3 | ||
|
|
6764c786a4 | ||
|
|
1b57a96509 | ||
|
|
38683e8550 | ||
|
|
a5c8f62a63 | ||
|
|
e480b88dce | ||
|
|
3ff2a8599d | ||
|
|
a3cf4ad5fb | ||
|
|
cec532f241 | ||
|
|
415508087f | ||
|
|
994003fc42 | ||
|
|
319b3807f3 | ||
|
|
5e7314f89d |
@@ -0,0 +1,11 @@
|
||||
# Hanzo Insights API Configuration
|
||||
# Copy this file to .env and update with your actual values
|
||||
|
||||
# Your project API key (found on the setup page in Insights)
|
||||
INSIGHTS_PROJECT_API_KEY=hi_your_project_api_key_here
|
||||
|
||||
# Your personal API key (for local evaluation and other advanced features)
|
||||
INSIGHTS_PERSONAL_API_KEY=phx_your_personal_api_key_here
|
||||
|
||||
# Insights host URL (remove this line if using insights.hanzo.ai)
|
||||
INSIGHTS_HOST=http://localhost:8000
|
||||
@@ -0,0 +1,36 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "10:00"
|
||||
timezone: "UTC"
|
||||
groups:
|
||||
ai-providers:
|
||||
patterns:
|
||||
- "openai"
|
||||
- "anthropic"
|
||||
- "google-genai"
|
||||
- "langchain-core"
|
||||
- "langchain-community"
|
||||
- "langchain-openai"
|
||||
- "langchain-anthropic"
|
||||
- "langgraph"
|
||||
allow:
|
||||
- dependency-name: "openai"
|
||||
- dependency-name: "anthropic"
|
||||
- dependency-name: "google-genai"
|
||||
- dependency-name: "langchain-core"
|
||||
- dependency-name: "langchain-community"
|
||||
- dependency-name: "langchain-openai"
|
||||
- dependency-name: "langchain-anthropic"
|
||||
- dependency-name: "langgraph"
|
||||
open-pull-requests-limit: 1
|
||||
reviewers:
|
||||
- "PostHog/team-llm-analytics"
|
||||
# Uncomment below to enable auto-merge for minor updates when CI passes
|
||||
# pull-request-branch-name:
|
||||
# separator: "/"
|
||||
# assignees:
|
||||
# - "PostHog/ai-team"
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="insights-python">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">insights-python</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Send usage data from your Python code to PostHog.</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,17 @@
|
||||
# This workflow is used to call the flags-project-board workflow when a pull request is opened, ready for review, review requested, synchronized, converted to draft, or reopened.
|
||||
# It is used to update the feature flags project board with the pull request information.
|
||||
|
||||
name: Call Feature Flags Project Workflow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, review_requested, synchronize, converted_to_draft, reopened]
|
||||
|
||||
jobs:
|
||||
call-flags-project:
|
||||
uses: PostHog/.github/.github/workflows/flags-project-board.yml@main
|
||||
with:
|
||||
pr_number: ${{ github.event.pull_request.number }}
|
||||
pr_node_id: ${{ github.event.pull_request.node_id }}
|
||||
is_draft: ${{ github.event.pull_request.draft }}
|
||||
secrets: inherit
|
||||
+92
-26
@@ -3,63 +3,129 @@ name: CI
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
code-quality:
|
||||
name: Code quality checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v2
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: 3.11.11
|
||||
|
||||
- uses: actions/cache@v3
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Install dev dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install -e .[dev]
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra dev
|
||||
|
||||
- name: Check formatting with black
|
||||
- name: Check formatting with ruff
|
||||
run: |
|
||||
black --check .
|
||||
ruff format --check .
|
||||
|
||||
- name: Lint with flake8
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
flake8 posthog --ignore E501,W503
|
||||
ruff check .
|
||||
|
||||
- name: Check import order with isort
|
||||
- name: Check types with mypy
|
||||
run: |
|
||||
isort --check-only .
|
||||
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
|
||||
|
||||
tests:
|
||||
name: Python tests
|
||||
runs-on: ubuntu-latest
|
||||
name: Python ${{ matrix.python-version }} tests
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install requirements.txt dependencies with pip
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Install test dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install -e .[test]
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test
|
||||
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
pytest --verbose --timeout=30
|
||||
|
||||
import-check:
|
||||
name: Python ${{ matrix.python-version }} import check
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
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: hanzo-build-linux-amd64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'integration_tests/django5/pyproject.toml'
|
||||
|
||||
- name: Install Django 5 test project dependencies
|
||||
shell: bash
|
||||
working-directory: integration_tests/django5
|
||||
run: |
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync
|
||||
|
||||
- name: Run Django 5 middleware integration tests
|
||||
working-directory: integration_tests/django5
|
||||
run: |
|
||||
uv run pytest test_middleware.py test_exception_capture.py --verbose
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: 'CodeQL Advanced'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['master']
|
||||
pull_request:
|
||||
branches: ['master']
|
||||
schedule:
|
||||
- cron: '32 13 * * 1'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
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}}'
|
||||
@@ -0,0 +1,49 @@
|
||||
name: "Generate References"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docs-generation:
|
||||
name: Generate references
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.11.11
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Generate references
|
||||
run: |
|
||||
uv run bin/docs generate-references
|
||||
|
||||
- name: Check for changes in references
|
||||
id: changes
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain references/)" ]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "New references generated in references directory:"
|
||||
git status --porcelain references/
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
echo "No new references generated in references directory"
|
||||
fi
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
with:
|
||||
commit_message: "Update generated references"
|
||||
file_pattern: references/
|
||||
@@ -1,38 +0,0 @@
|
||||
name: 'Release'
|
||||
|
||||
on:
|
||||
- workflow_dispatch
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-20.04
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
|
||||
- name: Detect version
|
||||
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare for building release
|
||||
run: pip install -U pip setuptools wheel twine
|
||||
|
||||
- name: Push release to PyPI
|
||||
run: make release && make release_analytics
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ env.REPO_VERSION }}
|
||||
release_name: ${{ env.REPO_VERSION }}
|
||||
@@ -0,0 +1,249 @@
|
||||
name: "Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [master]
|
||||
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: hanzo-build-linux-amd64
|
||||
# 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]
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
# 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
|
||||
steps:
|
||||
- name: Notify Slack - Approved
|
||||
if: 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: "✅ 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 }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11.11
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: "pyproject.toml"
|
||||
|
||||
- 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
|
||||
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: Prepare release with Sampo
|
||||
id: sampo-release
|
||||
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'
|
||||
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: hanzo-build-linux-amd64
|
||||
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"
|
||||
@@ -0,0 +1,21 @@
|
||||
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"
|
||||
@@ -17,3 +17,6 @@ posthog-analytics
|
||||
.coverage
|
||||
pyrightconfig.json
|
||||
.env
|
||||
.DS_Store
|
||||
posthog-python-references.json
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: stable
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.7.0
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.11.12
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff-check
|
||||
args: [ --fix ]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
pypi/posthog: patch
|
||||
---
|
||||
|
||||
feat(llma): support fetching versioned prompts from the prompts sdk
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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/*"]
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# Before Send Hook
|
||||
|
||||
The `before_send` parameter allows you to modify or filter events before they are sent to Insights. This is useful for:
|
||||
|
||||
- **Privacy**: Removing or masking sensitive data (PII)
|
||||
- **Filtering**: Dropping unwanted events (test events, internal users, etc.)
|
||||
- **Enhancement**: Adding custom properties to all events
|
||||
- **Transformation**: Modifying event names or property formats
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```python
|
||||
import hanzo_insights
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
def my_before_send(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Process event before sending to Insights.
|
||||
|
||||
Args:
|
||||
event: The event dictionary containing 'event', 'distinct_id', 'properties', etc.
|
||||
|
||||
Returns:
|
||||
Modified event dictionary to send, or None to drop the event
|
||||
"""
|
||||
# Your processing logic here
|
||||
return event
|
||||
|
||||
# Initialize client with before_send hook
|
||||
client = hanzo_insights.Client(
|
||||
api_key="your-project-api-key",
|
||||
before_send=my_before_send
|
||||
)
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### 1. Filter Out Events
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def filter_events_by_property_or_event_name(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Drop events from internal users or test environments."""
|
||||
properties = event.get("properties", {})
|
||||
|
||||
# Choose some property from your events
|
||||
event_source = properties.get("event_source", "")
|
||||
if event_source.endswith("internal"):
|
||||
return None # Drop the event
|
||||
|
||||
# Filter out test events
|
||||
if event.get("event") == "test_event":
|
||||
return None
|
||||
|
||||
return event
|
||||
```
|
||||
|
||||
### 2. Remove/Mask PII Data
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def scrub_pii(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Remove or mask personally identifiable information."""
|
||||
properties = event.get("properties", {})
|
||||
|
||||
# Mask email but keep domain for analytics
|
||||
if "email" in properties:
|
||||
email = properties["email"]
|
||||
if "@" in email:
|
||||
domain = email.split("@")[1]
|
||||
properties["email"] = f"***@{domain}"
|
||||
else:
|
||||
properties["email"] = "***"
|
||||
|
||||
# Remove sensitive fields entirely
|
||||
sensitive_fields = ["my_business_info", "secret_things"]
|
||||
for field in sensitive_fields:
|
||||
properties.pop(field, None)
|
||||
|
||||
return event
|
||||
```
|
||||
|
||||
### 3. Add Custom Properties
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
def add_context(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Add custom properties to all events."""
|
||||
if "properties" not in event:
|
||||
event["properties"] = {}
|
||||
|
||||
event["properties"].update({
|
||||
"app_version": "2.1.0",
|
||||
"environment": "production",
|
||||
"processed_at": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
return event
|
||||
```
|
||||
|
||||
### 4. Transform Event Names
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def normalize_event_names(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Convert event names to a consistent format."""
|
||||
original_event = event.get("event")
|
||||
if original_event:
|
||||
# Convert to snake_case
|
||||
normalized = original_event.lower().replace(" ", "_").replace("-", "_")
|
||||
event["event"] = f"app_{normalized}"
|
||||
|
||||
return event
|
||||
```
|
||||
|
||||
### 5. Log and drop in "dev" mode
|
||||
|
||||
When running in local dev often, you want to log but drop all events
|
||||
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def log_and_drop_all(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Convert event names to a consistent format."""
|
||||
print(event)
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 6. Combined Processing
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def comprehensive_processor(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Apply multiple transformations in sequence."""
|
||||
|
||||
# Step 1: Filter unwanted events
|
||||
if should_drop_event(event):
|
||||
return None
|
||||
|
||||
# Step 2: Scrub PII
|
||||
event = scrub_pii(event)
|
||||
|
||||
# Step 3: Add context
|
||||
event = add_context(event)
|
||||
|
||||
# Step 4: Normalize names
|
||||
event = normalize_event_names(event)
|
||||
|
||||
return event
|
||||
|
||||
def should_drop_event(event: dict[str, Any]) -> bool:
|
||||
"""Determine if event should be dropped."""
|
||||
# Your filtering logic
|
||||
return False
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
If your `before_send` function raises an exception, Insights will:
|
||||
|
||||
1. Log the error
|
||||
2. Continue with the original, unmodified event
|
||||
3. Not crash your application
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def risky_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
# If this raises an exception, the original event will be sent
|
||||
risky_operation()
|
||||
return event
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```python
|
||||
import hanzo_insights
|
||||
from typing import Optional, Any
|
||||
import re
|
||||
|
||||
def production_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
try:
|
||||
properties = event.get("properties", {})
|
||||
|
||||
# 1. Filter out bot traffic
|
||||
user_agent = properties.get("$user_agent", "")
|
||||
if re.search(r'bot|crawler|spider', user_agent, re.I):
|
||||
return None
|
||||
|
||||
# 2. Filter out internal traffic
|
||||
ip = properties.get("$ip", "")
|
||||
if ip.startswith("192.168.") or ip.startswith("10."):
|
||||
return None
|
||||
|
||||
# 3. Scrub email PII but keep domain
|
||||
if "email" in properties:
|
||||
email = properties["email"]
|
||||
if "@" in email:
|
||||
domain = email.split("@")[1]
|
||||
properties["email"] = f"***@{domain}"
|
||||
|
||||
# 4. Add custom context
|
||||
properties.update({
|
||||
"app_version": "1.0.0",
|
||||
"build_number": "123"
|
||||
})
|
||||
|
||||
# 5. Normalize event name
|
||||
if event.get("event"):
|
||||
event["event"] = event["event"].lower().replace(" ", "_")
|
||||
|
||||
return event
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't crash
|
||||
print(f"Error in before_send: {e}")
|
||||
return event # Return original event on error
|
||||
|
||||
# Usage
|
||||
client = hanzo_insights.Client(
|
||||
api_key="your-api-key",
|
||||
before_send=production_before_send
|
||||
)
|
||||
|
||||
# All events will now be processed by your before_send function
|
||||
client.capture("user_123", "Page View", {"url": "/home"})
|
||||
```
|
||||
+565
@@ -1,6 +1,571 @@
|
||||
# posthog
|
||||
|
||||
## 7.9.7 — 2026-03-05
|
||||
|
||||
### Patch changes
|
||||
|
||||
- [b206669](https://github.com/posthog/posthog-python/commit/b206669bf62c923346ad28881dc4694d933ca424) fix(llma): use distinct_id from outer context if not provided, fix $process_person_profile for context-based identity — Thanks @ethanporcaro for your first contribution 🎉!
|
||||
- [a99c7d7](https://github.com/posthog/posthog-python/commit/a99c7d73b1e0ef1f35d856c82ace21237ee253a3) Add warning log for local flag evaluation cold start — Thanks @dmarticus!
|
||||
|
||||
## 7.9.6 — 2026-03-02
|
||||
|
||||
### Patch changes
|
||||
|
||||
- [8d83315](https://github.com/posthog/posthog-python/commit/8d83315b67c21eb9e7d6c17bae27ada98ca2643d) add PROPERTY_OPERATORS constant for match_property — Thanks @dmarticus!
|
||||
|
||||
## 7.9.5 — 2026-03-02
|
||||
|
||||
### Patch changes
|
||||
|
||||
- [830244b](https://github.com/posthog/posthog-python/commit/830244bd409b1992ae2e49610f8f87d2cdfc8096) add semver targeting support to local evaluation — Thanks @dmarticus!
|
||||
|
||||
## 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
|
||||
|
||||
- fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329)
|
||||
- fix(ai/langchain): Add LangChain 1.0+ compatibility for CallbackHandler imports (fixes #362)
|
||||
|
||||
## 6.7.11 - 2025-10-28
|
||||
|
||||
- feat(ai): Add `$ai_framework` property for framework integrations (e.g. LangChain)
|
||||
|
||||
## 6.7.10 - 2025-10-24
|
||||
|
||||
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
|
||||
|
||||
## 6.7.9 - 2025-10-22
|
||||
|
||||
- fix(flags): multi-condition flags with static cohorts returning wrong variants
|
||||
|
||||
## 6.7.8 - 2025-10-16
|
||||
|
||||
- fix(llma): missing async for OpenAI's streaming implementation
|
||||
|
||||
## 6.7.7 - 2025-10-14
|
||||
|
||||
- fix: remove deprecated attribute $exception_personURL from exception events
|
||||
|
||||
## 6.7.6 - 2025-09-16
|
||||
|
||||
- fix: don't sort condition sets with variant overrides to the top
|
||||
- fix: Prevent core Client methods from raising exceptions
|
||||
|
||||
## 6.7.5 - 2025-09-16
|
||||
|
||||
- feat: Django middleware now supports async request handling.
|
||||
|
||||
## 6.7.4 - 2025-09-05
|
||||
|
||||
- fix: Missing system prompts for some providers
|
||||
|
||||
## 6.7.3 - 2025-09-04
|
||||
|
||||
- fix: missing usage tokens in Gemini
|
||||
|
||||
## 6.7.2 - 2025-09-03
|
||||
|
||||
- fix: tool call results in streaming providers
|
||||
|
||||
## 6.7.1 - 2025-09-01
|
||||
|
||||
- fix: Add base64 inline image sanitization
|
||||
|
||||
## 6.7.0 - 2025-08-26
|
||||
|
||||
- feat: Add support for feature flag dependencies
|
||||
|
||||
## 6.6.1 - 2025-08-21
|
||||
|
||||
- fix: Prevent `NoneType` error when `group_properties` is `None`
|
||||
|
||||
## 6.6.0 - 2025-08-15
|
||||
|
||||
- feat: Add `flag_keys_to_evaluate` parameter to optimize feature flag evaluation performance by only evaluating specified flags
|
||||
- feat: Add `flag_keys_filter` option to `send_feature_flags` for selective flag evaluation in capture events
|
||||
|
||||
## 6.5.0 - 2025-08-08
|
||||
|
||||
- feat: Add `$context_tags` to an event to know which properties were included as tags
|
||||
|
||||
## 6.4.1 - 2025-08-06
|
||||
|
||||
- fix: Always pass project API key in `remote_config` requests for deterministic project routing
|
||||
|
||||
## 6.4.0 - 2025-08-05
|
||||
|
||||
- feat: support Vertex AI for Gemini
|
||||
|
||||
## 6.3.4 - 2025-08-04
|
||||
|
||||
- fix: set `$ai_tools` for all providers and `$ai_output_choices` for all non-streaming provider flows properly
|
||||
|
||||
## 6.3.3 - 2025-08-01
|
||||
|
||||
- fix: `get_feature_flag_result` now correctly returns FeatureFlagResult when payload is empty string instead of None
|
||||
|
||||
## 6.3.2 - 2025-07-31
|
||||
|
||||
- fix: Anthropic's tool calls are now handled properly
|
||||
|
||||
## 6.3.0 - 2025-07-22
|
||||
|
||||
- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties
|
||||
|
||||
## 6.2.1 - 2025-07-21
|
||||
|
||||
- feat: make `posthog_client` an optional argument in PostHog AI providers wrappers (`posthog.ai.*`), intuitively using the default client as the default
|
||||
|
||||
## 6.1.1 - 2025-07-16
|
||||
|
||||
- fix: correctly capture exceptions processed by Django from views or middleware
|
||||
|
||||
## 6.1.0 - 2025-07-10
|
||||
|
||||
- feat: decouple feature flag local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller
|
||||
|
||||
## 6.0.4 - 2025-07-09
|
||||
|
||||
- fix: add POSTHOG_MW_CLIENT setting to django middleware, to support custom clients for exception capture.
|
||||
|
||||
## 6.0.3 - 2025-07-07
|
||||
|
||||
- feat: add a feature flag evaluation cache (local storage or redis) to support returning flag evaluations when the service is down
|
||||
|
||||
## 6.0.2 - 2025-07-02
|
||||
|
||||
- fix: send_feature_flags changed to default to false in `Client::capture_exception`
|
||||
|
||||
## 6.0.1
|
||||
|
||||
- fix: response `$process_person_profile` property when passed to capture
|
||||
|
||||
## 6.0.0
|
||||
|
||||
This release contains a number of major breaking changes:
|
||||
|
||||
- feat: make distinct_id an optional parameter in posthog.capture and related functions
|
||||
- feat: make capture and related functions return `Optional[str]`, which is the UUID of the sent event, if it was sent
|
||||
- fix: remove `identify` (prefer `posthog.set()`), and `page` and `screen` (prefer `posthog.capture()`)
|
||||
- fix: delete exception-capture specific integrations module. Prefer the general-purpose django middleware as a replacement for the django `Integration`.
|
||||
|
||||
To migrate to this version, you'll mostly just need to switch to using named keyword arguments, rather than positional ones. For example:
|
||||
|
||||
```python
|
||||
# Old calling convention
|
||||
posthog.capture("user123", "button_clicked", {"button_id": "123"})
|
||||
# New calling convention
|
||||
posthog.capture(distinct_id="user123", event="button_clicked", properties={"button_id": "123"})
|
||||
|
||||
# Better pattern
|
||||
with posthog.new_context():
|
||||
posthog.identify_context("user123")
|
||||
|
||||
# The event name is the first argument, and can be passed positionally, or as a keyword argument in a later position
|
||||
posthog.capture("button_pressed")
|
||||
```
|
||||
|
||||
Generally, arguments are now appropriately typed, and docstrings have been updated. If something is unclear, please open an issue, or submit a PR!
|
||||
|
||||
## 5.4.0 - 2025-06-20
|
||||
|
||||
- feat: add support to session_id context on page method
|
||||
|
||||
## 5.3.0 - 2025-06-19
|
||||
|
||||
- fix: safely handle exception values
|
||||
|
||||
## 5.2.0 - 2025-06-19
|
||||
|
||||
- feat: construct artificial stack traces if no traceback is available on a captured exception
|
||||
|
||||
## 5.1.0 - 2025-06-18
|
||||
|
||||
- feat: session and distinct ID's can now be associated with contexts, and are used as such
|
||||
- feat: django http request middleware
|
||||
|
||||
## 5.0.0 - 2025-06-16
|
||||
|
||||
- fix: removed deprecated sentry integration
|
||||
|
||||
## 4.10.0 - 2025-06-13
|
||||
|
||||
- fix: no longer fail in autocapture.
|
||||
|
||||
## 4.9.0 - 2025-06-13
|
||||
|
||||
- feat(ai): track reasoning and cache tokens in the LangChain callback
|
||||
|
||||
## 4.8.0 - 2025-06-10
|
||||
|
||||
- fix: export scoped, rather than tracked, decorator
|
||||
- feat: allow use of contexts without error tracking
|
||||
|
||||
## 4.7.0 - 2025-06-10
|
||||
|
||||
- feat: add support for parse endpoint in responses API (no longer beta)
|
||||
|
||||
## 4.6.2 - 2025-06-09
|
||||
|
||||
- fix: replace `import posthog` with direct method imports
|
||||
|
||||
## 4.6.1 - 2025-06-09
|
||||
|
||||
- fix: replace `import posthog` in `posthoganalytics` package
|
||||
|
||||
## 4.6.0 - 2025-06-09
|
||||
|
||||
- feat: add additional user and request context to captured exceptions via the Django integration
|
||||
- feat: Add `setup()` function to initialise default client
|
||||
|
||||
## 4.5.0 - 2025-06-09
|
||||
|
||||
- feat: add before_send callback (#249)
|
||||
|
||||
## 4.4.2- 2025-06-09
|
||||
|
||||
- empty point release to fix release automation
|
||||
|
||||
## 4.4.1 2025-06-09
|
||||
|
||||
- empty point release to fix release automation
|
||||
|
||||
## 4.4.0 - 2025-06-09
|
||||
|
||||
- Use the new `/flags` endpoint for all feature flag evaluations (don't fall back to `/decide` at all)
|
||||
|
||||
## 4.3.2 - 2025-06-06
|
||||
|
||||
1. Add context management:
|
||||
|
||||
- New context manager with `posthog.new_context()`
|
||||
- Tag functions: `posthog.tag()`, `posthog.get_tags()`, `posthog.clear_tags()`
|
||||
- Function decorator:
|
||||
- `@posthog.scoped` - Creates context and captures exceptions thrown within the function
|
||||
- Automatic deduplication of exceptions to ensure each exception is only captured once
|
||||
|
||||
2. fix: feature flag request use geoip_disable (#235)
|
||||
3. chore: pin actions versions (#210)
|
||||
4. fix: opinionated setup and clean fn fix (#240)
|
||||
5. fix: release action failed (#241)
|
||||
|
||||
## 4.2.0 - 2025-05-22
|
||||
|
||||
Add support for google gemini
|
||||
|
||||
## 4.1.0 - 2025-05-22
|
||||
|
||||
Moved ai openai package to a composition approach over inheritance.
|
||||
|
||||
## 4.0.1 – 2025-04-29
|
||||
|
||||
1. Remove deprecated `monotonic` library. Use Python's core `time.monotonic` function instead
|
||||
2. Clarify Python 3.9+ is required
|
||||
|
||||
## 4.0.0 - 2025-04-24
|
||||
|
||||
1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single API call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag).
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
result = posthog.get_feature_flag_result("my-flag", "distinct_id")
|
||||
print(result.enabled) # True or False
|
||||
print(result.variant) # 'the-variant-value' or None
|
||||
print(result.payload) # {'foo': 'bar'}
|
||||
print(result.get_value()) # 'the-variant-value' or True or False
|
||||
print(result.reason) # 'matched condition set 2' (Not available for local evaluation)
|
||||
```
|
||||
|
||||
Breaking change:
|
||||
|
||||
1. `get_feature_flag_payload` now deserializes payloads from JSON strings to `Any`. Previously, it returned the payload as a JSON encoded string.
|
||||
|
||||
Before:
|
||||
|
||||
```python
|
||||
payload = get_feature_flag_payload('key', 'distinct_id') # "{\"some\": \"payload\"}"
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```python
|
||||
payload = get_feature_flag_payload('key', 'distinct_id') # {"some": "payload"}
|
||||
```
|
||||
|
||||
## 3.25.0 – 2025-04-15
|
||||
|
||||
1. Roll out new `/flags` endpoint to 100% of `/decide` traffic, excluding the top 10 customers.
|
||||
|
||||
## 3.24.3 – 2025-04-15
|
||||
|
||||
1. Fix hash inclusion/exclusion for flag rollout
|
||||
|
||||
## 3.24.2 – 2025-04-15
|
||||
|
||||
1. Roll out new /flags endpoint to 10% of /decide traffic
|
||||
|
||||
## 3.24.1 – 2025-04-11
|
||||
|
||||
1. Add `log_captured_exceptions` option to proxy setup
|
||||
|
||||
## 3.24.0 – 2025-04-10
|
||||
|
||||
1. Add config option to `log_captured_exceptions`
|
||||
|
||||
## 3.23.0 – 2025-03-26
|
||||
|
||||
1. Expand automatic retries to include read errors (e.g. RemoteDisconnected)
|
||||
|
||||
## 3.22.0 – 2025-03-26
|
||||
|
||||
1. Add more information to `$feature_flag_called` events.
|
||||
2. Support for the `/decide?v=4` endpoint which contains more information about feature flags.
|
||||
|
||||
## 3.21.0 – 2025-03-17
|
||||
|
||||
1. Support serializing dataclasses.
|
||||
|
||||
## 3.20.0 – 2025-03-13
|
||||
|
||||
1. Add support for OpenAI Responses API.
|
||||
|
||||
## 3.19.2 – 2025-03-11
|
||||
|
||||
1. Fix install requirements for analytics package
|
||||
|
||||
## 3.19.1 – 2025-03-11
|
||||
|
||||
1. Fix bug where None is sent as delta in azure
|
||||
|
||||
## 3.19.0 – 2025-03-04
|
||||
|
||||
1. Add support for tool calls in OpenAI and Anthropic.
|
||||
2. Add support for cached tokens.
|
||||
|
||||
## 3.18.1 – 2025-03-03
|
||||
|
||||
1. Improve quota-limited feature flag logs
|
||||
|
||||
## 3.18.0 - 2025-02-28
|
||||
|
||||
1. Add support for Azure OpenAI.
|
||||
|
||||
## 3.17.0 - 2025-02-27
|
||||
|
||||
1. The LangChain handler now captures tools in `$ai_generation` events, in property `$ai_tools`. This allows for displaying tools provided to the LLM call in PostHog UI. Note that support for `$ai_tools` in OpenAI and Anthropic SDKs is coming soon.
|
||||
|
||||
## 3.16.0 - 2025-02-26
|
||||
|
||||
1. feat: add some platform info to events (#198)
|
||||
|
||||
## 3.15.1 - 2025-02-23
|
||||
|
||||
1. Fix async client support for OpenAI.
|
||||
|
||||
## 3.15.0 - 2025-02-19
|
||||
|
||||
1. Support quota-limited feature flags
|
||||
|
||||
## 3.14.2 - 2025-02-19
|
||||
|
||||
1. Evaluate feature flag payloads with case sensitivity correctly. Fixes <https://github.com/PostHog/posthog-python/issues/178>
|
||||
|
||||
## 3.14.1 - 2025-02-18
|
||||
|
||||
1. Add support for Bedrock Anthropic Usage
|
||||
|
||||
## 3.13.0 - 2025-02-12
|
||||
|
||||
1. Automatically retry connection errors
|
||||
|
||||
## 3.12.1 - 2025-02-11
|
||||
|
||||
1. Fix mypy support for 3.12.0
|
||||
2. Deprecate `is_simple_flag`
|
||||
|
||||
## 3.12.0 - 2025-02-11
|
||||
|
||||
1. Add support for OpenAI beta parse API.
|
||||
2. Deprecate `context` parameter
|
||||
|
||||
## 3.11.1 - 2025-02-06
|
||||
|
||||
1. Fix LangChain callback handler to capture parent run ID.
|
||||
|
||||
## 3.11.0 - 2025-01-28
|
||||
|
||||
1. Add the `$ai_span` event to the LangChain callback handler to capture the input and output of intermediary chains.
|
||||
|
||||
> LLM observability naming change: event property `$ai_trace_name` is now `$ai_span_name`.
|
||||
|
||||
2. Fix serialiazation of Pydantic models in methods.
|
||||
|
||||
@@ -20,3 +20,29 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
Some files in this codebase contain code from getsentry/sentry-javascript by Software, Inc. dba Sentry.
|
||||
In such cases it is explicitly stated in the file header. This license only applies to the relevant code in such cases.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2012 Functional Software, Inc. dba Sentry
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Hanzo Insights Python SDK
|
||||
|
||||
## Overview
|
||||
Integrate Hanzo Insights into any Python application. Package name: `hanzo-insights` on PyPI.
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: Python 3.10+
|
||||
- **Package**: `hanzo_insights` (import name), `hanzo-insights` (pip name)
|
||||
|
||||
## Build & Run
|
||||
```bash
|
||||
uv sync
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## Structure
|
||||
```
|
||||
posthog-python/
|
||||
hanzo_insights/ # Main package
|
||||
__init__.py # Module-level API, Insights class
|
||||
client.py # Client class
|
||||
ai/ # AI provider integrations (OpenAI, Anthropic, Gemini, LangChain)
|
||||
integrations/ # Framework integrations (Django middleware)
|
||||
test/ # Tests
|
||||
examples/
|
||||
integration_tests/
|
||||
pyproject.toml # Package config (name: hanzo-insights)
|
||||
setup.py # Legacy setup
|
||||
```
|
||||
|
||||
## Key Files
|
||||
- `pyproject.toml` -- Package config, dependencies, test config
|
||||
- `hanzo_insights/__init__.py` -- Public API surface
|
||||
- `hanzo_insights/client.py` -- Client implementation
|
||||
|
||||
## Rebrand Notes
|
||||
- Main class: `Insights` (no backward compat aliases)
|
||||
- Django middleware: `InsightsContextMiddleware` (no backward compat aliases)
|
||||
- OpenAI Agents: `InsightsTracingProcessor` (no backward compat aliases)
|
||||
- `$lib` protocol value: `insights-python`
|
||||
- Ingestion URLs: `us.i.insights.hanzo.ai` / `eu.i.insights.hanzo.ai`
|
||||
- AI wrapper kwargs: `insights_*` (e.g. `insights_distinct_id`, `insights_trace_id`)
|
||||
- Exception attrs: `__insights_exception_captured`, `__insights_exception_uuid`
|
||||
- Context var: `insights_context_stack`
|
||||
- Redis prefix: `insights:flags:`
|
||||
- Redaction sentinels: `$$_insights_redacted_*`, `$$_insights_value_too_long_*`
|
||||
- Django settings: `INSIGHTS_MW_*` only (no `POSTHOG_MW_*` fallback)
|
||||
- Django headers: `X-INSIGHTS-SESSION-ID`, `X-INSIGHTS-DISTINCT-ID` only
|
||||
@@ -1,38 +1,71 @@
|
||||
lint:
|
||||
pylint --rcfile=.pylintrc --reports=y --exit-zero analytics | tee pylint.out
|
||||
flake8 --max-complexity=10 --statistics analytics > flake8.out || true
|
||||
uvx ruff format
|
||||
|
||||
test:
|
||||
coverage run -m pytest
|
||||
coverage report
|
||||
|
||||
release:
|
||||
build_release:
|
||||
rm -rf dist/*
|
||||
python setup.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
|
||||
release_analytics:
|
||||
# Builds the `posthoganalytics` PyPI package, which is a mirror of `hanzo_insights`
|
||||
# published under a different name for backward compatibility with the upstream
|
||||
# posthog/posthog project.
|
||||
#
|
||||
# The process works in three phases:
|
||||
# 1. hanzo_insights -> posthoganalytics: Copy the source, rewrite all imports,
|
||||
# remove the original hanzo_insights/ dir, and build the dist.
|
||||
# 2. posthoganalytics -> hanzo_insights: Reverse the import rewrites, copy
|
||||
# everything back into hanzo_insights/, 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` (hanzo_insights) must be published BEFORE running this target,
|
||||
# otherwise the hanzo_insights dist artifacts will be lost.
|
||||
build_release_analytics:
|
||||
rm -rf dist
|
||||
rm -rf build
|
||||
rm -rf posthoganalytics
|
||||
mkdir posthoganalytics
|
||||
cp -r posthog/* posthoganalytics/
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog /from posthoganalytics /g' {} \;
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog\./from posthoganalytics\./g' {} \;
|
||||
rm -rf posthog
|
||||
cp -r hanzo_insights/* posthoganalytics/
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from hanzo_insights /from posthoganalytics /g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from hanzo_insights\./from posthoganalytics\./g' {} \;
|
||||
find ./posthoganalytics -name "*.bak" -delete
|
||||
rm -rf hanzo_insights
|
||||
python setup_analytics.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
mkdir posthog
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics /from posthog /g' {} \;
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics\./from posthog\./g' {} \;
|
||||
cp -r posthoganalytics/* posthog/
|
||||
mkdir hanzo_insights
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from hanzo_insights /g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from hanzo_insights\./g' {} \;
|
||||
find ./posthoganalytics -name "*.bak" -delete
|
||||
cp -r posthoganalytics/* hanzo_insights/
|
||||
rm -rf posthoganalytics
|
||||
rm -f pyproject.toml
|
||||
cp pyproject.toml.backup pyproject.toml
|
||||
rm -f pyproject.toml.backup
|
||||
|
||||
e2e_test:
|
||||
.buildscripts/e2e.sh
|
||||
|
||||
django_example:
|
||||
python -m pip install -e ".[sentry]"
|
||||
cd sentry_django_example && python manage.py runserver 8080
|
||||
prep_local:
|
||||
rm -rf ../posthog-python-local
|
||||
mkdir ../posthog-python-local
|
||||
cp -r . ../posthog-python-local/
|
||||
cd ../posthog-python-local && rm -rf dist build posthoganalytics .git
|
||||
cd ../posthog-python-local && mkdir posthoganalytics
|
||||
cd ../posthog-python-local && cp -r hanzo_insights/* posthoganalytics/
|
||||
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from hanzo_insights /from posthoganalytics /g' {} \;
|
||||
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from hanzo_insights\./from posthoganalytics\./g' {} \;
|
||||
cd ../posthog-python-local && find ./posthoganalytics -name "*.bak" -delete
|
||||
cd ../posthog-python-local && rm -rf hanzo_insights
|
||||
cd ../posthog-python-local && sed -i.bak 's/from version import VERSION/from posthoganalytics.version import VERSION/' setup_analytics.py
|
||||
cd ../posthog-python-local && rm setup_analytics.py.bak
|
||||
cd ../posthog-python-local && sed -i.bak 's/"hanzo_insights"/"posthoganalytics"/' setup.py
|
||||
cd ../posthog-python-local && rm setup.py.bak
|
||||
cd ../posthog-python-local && python -c "import setup_analytics" 2>/dev/null || true
|
||||
@echo "Local copy created at ../posthog-python-local"
|
||||
@echo "Install with: pip install -e ../posthog-python-local"
|
||||
|
||||
.PHONY: test lint release e2e_test
|
||||
.PHONY: test lint build_release build_release_analytics e2e_test prep_local
|
||||
|
||||
@@ -1,46 +1,72 @@
|
||||
# PostHog Python
|
||||
<p align="center"><img src=".github/hero.svg" alt="insights-python" width="880"></p>
|
||||
|
||||
[](https://pypi.org/project/posthog/)
|
||||
# Hanzo Insights Python SDK
|
||||
|
||||
Integrate [Hanzo Insights](https://insights.hanzo.ai) into any Python application.
|
||||
|
||||
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install hanzo-insights
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from hanzo_insights import Insights
|
||||
|
||||
client = Insights('<your_project_api_key>', host='https://insights.hanzo.ai')
|
||||
|
||||
# Capture an event
|
||||
client.capture('user_123', 'purchase', properties={'product': 'widget'})
|
||||
|
||||
# Feature flags
|
||||
if client.feature_enabled('new-checkout', 'user_123'):
|
||||
show_new_checkout()
|
||||
```
|
||||
|
||||
## Module-level usage
|
||||
|
||||
```python
|
||||
import hanzo_insights
|
||||
|
||||
hanzo_insights.api_key = '<your_project_api_key>'
|
||||
hanzo_insights.host = 'https://insights.hanzo.ai'
|
||||
|
||||
hanzo_insights.capture('movie_played', distinct_id='user_123', properties={'movie_id': '42'})
|
||||
hanzo_insights.shutdown()
|
||||
```
|
||||
|
||||
## Python Version Support
|
||||
|
||||
| SDK Version | Python Versions Supported |
|
||||
| -------------- | ----------------------------- |
|
||||
| 7.3.1+ | 3.10, 3.11, 3.12, 3.13, 3.14 |
|
||||
| 7.0.0 - 7.0.1 | 3.10, 3.11, 3.12, 3.13 |
|
||||
| 4.0.1 - 6.x | 3.9, 3.10, 3.11, 3.12, 3.13 |
|
||||
|
||||
## Development
|
||||
|
||||
### Testing Locally
|
||||
We use [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
1. Run `python3 -m venv env` (creates virtual environment called "env")
|
||||
2. Run `source env/bin/activate` (activates the virtual environment)
|
||||
3. Run `python3 -m pip install -e ".[test]"` (installs the package in develop mode, along with test dependencies)
|
||||
4. Run `make test`
|
||||
1. To run a specific test do `pytest -k test_no_api_key`
|
||||
```bash
|
||||
uv python install 3.12
|
||||
uv python pin 3.12
|
||||
uv venv
|
||||
source env/bin/activate
|
||||
uv sync --extra dev --extra test
|
||||
pre-commit install
|
||||
make test
|
||||
```
|
||||
|
||||
### Running Locally
|
||||
### Running Tests
|
||||
|
||||
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.
|
||||
```bash
|
||||
make test
|
||||
# or run a specific test:
|
||||
pytest -k test_no_api_key
|
||||
```
|
||||
|
||||
### Running the Django Sentry Integration Locally
|
||||
## License
|
||||
|
||||
There's a sample Django project included, called `sentry_django_example`, which explains how to use PostHog with Sentry.
|
||||
|
||||
There's 2 places of importance (Changes required are all marked with TODO in the sample project directory)
|
||||
|
||||
1. Settings.py
|
||||
1. Input your Sentry DSN
|
||||
2. Input your Sentry Org and ProjectID details into `PosthogIntegration()`
|
||||
3. Add `POSTHOG_DJANGO` to settings.py. This allows the `PosthogDistinctIdMiddleware` to get the distinct_ids
|
||||
|
||||
2. urls.py
|
||||
1. This includes the `sentry-debug/` endpoint, which generates an exception
|
||||
|
||||
To run things: `make django_example`. This installs the posthog-python library with the sentry-sdk add-on, and then runs the django app.
|
||||
Also start the PostHog app locally.
|
||||
Then navigate to `http://127.0.0.1:8080/sentry-debug/` and you should get an event in both Sentry and PostHog, with links to each other.
|
||||
|
||||
### Releasing Versions
|
||||
|
||||
Updated are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
|
||||
|
||||
## Questions?
|
||||
|
||||
### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/build
|
||||
#/ Description: Runs linter and mypy
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
|
||||
flake8 hanzo_insights --ignore E501,W503
|
||||
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/docs
|
||||
#/ Description: Generate documentation for the Insights Python SDK
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
ensure_virtual_env
|
||||
|
||||
exec python3 "$(dirname "$0")/docs_scripts/generate_json_schemas.py" "$@"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Constants for Insights Python SDK documentation generation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Union
|
||||
from hanzo_insights.version import VERSION
|
||||
|
||||
# Documentation generation metadata
|
||||
DOCUMENTATION_METADATA = {
|
||||
"hogRef": "0.3",
|
||||
"slugPrefix": "insights-python",
|
||||
"specUrl": "https://github.com/Insights/insights-python",
|
||||
}
|
||||
|
||||
# Docstring parsing patterns for new format
|
||||
DOCSTRING_PATTERNS = {
|
||||
"examples_section": r"Examples:\s*\n(.*?)(?=\n\s*\n\s*Category:|\Z)",
|
||||
"args_section": r"Args:\s*\n(.*?)(?=\n\s*\n\s*Examples:|\n\s*\n\s*Details:|\n\s*\n\s*Category:|\Z)",
|
||||
"details_section": r"Details:\s*\n(.*?)(?=\n\s*\n\s*Examples:|\n\s*\n\s*Category:|\Z)",
|
||||
"category_section": r"Category:\s*\n\s*(.+?)\s*(?:\n|$)",
|
||||
"code_block": r"```(?:python)?\n(.*?)```",
|
||||
"param_description": r"^\s*{param_name}:\s*(.+?)(?=\n\s*\w+:|\Z)",
|
||||
"args_marker": r"\n\s*Args:\s*\n",
|
||||
"examples_marker": r"\n\s*Examples:\s*\n",
|
||||
"details_marker": r"\n\s*Details:\s*\n",
|
||||
"category_marker": r"\n\s*Category:\s*\n",
|
||||
}
|
||||
|
||||
# Output file configuration
|
||||
OUTPUT_CONFIG: Dict[str, Union[str, int]] = {
|
||||
"output_dir": "./references",
|
||||
"filename": f"insights-python-references-{VERSION}.json",
|
||||
"filename_latest": "insights-python-references-latest.json",
|
||||
"indent": 2,
|
||||
}
|
||||
|
||||
# Documentation structure defaults
|
||||
DOC_DEFAULTS = {
|
||||
"showDocs": True,
|
||||
"releaseTag": "public",
|
||||
"return_type_void": "None",
|
||||
"max_optional_params": 3,
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate comprehensive SDK documentation JSON from Insights Python SDK.
|
||||
This script inspects the code and docstrings to create documentation in the specified format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import inspect
|
||||
import re
|
||||
from dataclasses import is_dataclass, fields
|
||||
from typing import get_origin, get_args, Union
|
||||
from textwrap import dedent
|
||||
from doc_constant import (
|
||||
DOCUMENTATION_METADATA,
|
||||
DOCSTRING_PATTERNS,
|
||||
OUTPUT_CONFIG,
|
||||
DOC_DEFAULTS,
|
||||
)
|
||||
import os
|
||||
|
||||
|
||||
def extract_examples_from_docstring(docstring: str) -> list:
|
||||
"""Extract code examples from docstring."""
|
||||
if not docstring:
|
||||
return []
|
||||
|
||||
examples = []
|
||||
|
||||
# Look for Examples section in the new format
|
||||
examples_section_match = re.search(
|
||||
DOCSTRING_PATTERNS["examples_section"], docstring, re.DOTALL
|
||||
)
|
||||
if examples_section_match:
|
||||
examples_content = examples_section_match.group(1).strip()
|
||||
# Extract code blocks from the Examples section
|
||||
code_blocks = re.findall(
|
||||
DOCSTRING_PATTERNS["code_block"], examples_content, re.DOTALL
|
||||
)
|
||||
for i, code_block in enumerate(code_blocks):
|
||||
# Remove common leading whitespace while preserving relative indentation
|
||||
code = dedent(code_block).strip()
|
||||
|
||||
# Extract name from first comment line if present
|
||||
lines = code.split("\n")
|
||||
name = f"Example {i + 1}" # Default fallback
|
||||
|
||||
if lines and lines[0].strip().startswith("#"):
|
||||
# Extract name from first comment, keep the comment in the code
|
||||
comment_text = lines[0].strip()[1:].strip()
|
||||
if comment_text:
|
||||
name = comment_text
|
||||
|
||||
examples.append({"id": f"example_{i + 1}", "name": name, "code": code})
|
||||
|
||||
return examples
|
||||
|
||||
|
||||
def extract_details_from_docstring(docstring: str) -> str:
|
||||
"""Extract details section from docstring."""
|
||||
if not docstring:
|
||||
return ""
|
||||
|
||||
# Look for Details section
|
||||
details_match = re.search(
|
||||
DOCSTRING_PATTERNS["details_section"], docstring, re.DOTALL
|
||||
)
|
||||
if details_match:
|
||||
details_content = details_match.group(1).strip()
|
||||
# Clean up formatting
|
||||
return details_content.replace("\n", " ")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def parse_docstring_tags(docstring: str) -> dict:
|
||||
"""Parse tags from docstring Category section."""
|
||||
if not docstring:
|
||||
return {}
|
||||
|
||||
tags = {}
|
||||
|
||||
# Extract Category section
|
||||
category_match = re.search(DOCSTRING_PATTERNS["category_section"], docstring)
|
||||
if category_match:
|
||||
category_value = category_match.group(1).strip()
|
||||
tags["category"] = category_value
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def extract_description_from_docstring(docstring: str) -> str:
|
||||
"""Extract main description from docstring."""
|
||||
if not docstring:
|
||||
return ""
|
||||
|
||||
# Clean up the docstring
|
||||
cleaned = dedent(docstring).strip()
|
||||
|
||||
# Find the end of the description by looking for first section marker
|
||||
# Check for Args:, Examples:, Details:, or Category: sections
|
||||
section_patterns = [
|
||||
DOCSTRING_PATTERNS["args_marker"],
|
||||
DOCSTRING_PATTERNS["examples_marker"],
|
||||
DOCSTRING_PATTERNS["details_marker"],
|
||||
DOCSTRING_PATTERNS["category_marker"],
|
||||
]
|
||||
|
||||
end_pos = len(cleaned)
|
||||
for pattern in section_patterns:
|
||||
match = re.search(pattern, cleaned)
|
||||
if match:
|
||||
end_pos = min(end_pos, match.start())
|
||||
|
||||
# Extract description up to the first section marker
|
||||
description = cleaned[:end_pos].strip()
|
||||
|
||||
# Remove one level of \n since it will be rendered as markdown
|
||||
# and \n will be padded in later steps
|
||||
description = description.replace("\n", " ")
|
||||
|
||||
return description
|
||||
|
||||
|
||||
def get_type_name(type_annotation) -> str:
|
||||
"""Convert type annotation to string name."""
|
||||
if type_annotation is None or type_annotation is type(None):
|
||||
return "any"
|
||||
|
||||
# Handle typing constructs
|
||||
origin = get_origin(type_annotation)
|
||||
if origin is not None:
|
||||
# Handle Union types (including Optional)
|
||||
if origin is Union:
|
||||
args = get_args(type_annotation)
|
||||
if len(args) == 2 and type(None) in args:
|
||||
# This is Optional[Type] - get the non-None type
|
||||
non_none_type = next(arg for arg in args if arg is not type(None))
|
||||
return f"Optional[{get_type_name(non_none_type)}]"
|
||||
else:
|
||||
# Regular Union - list all types
|
||||
type_names = [get_type_name(arg) for arg in args]
|
||||
return f"Union[{', '.join(type_names)}]"
|
||||
|
||||
# Handle other generic types (List, Dict, etc.)
|
||||
origin_name = getattr(origin, "__name__", str(origin))
|
||||
args = get_args(type_annotation)
|
||||
if args:
|
||||
arg_names = [get_type_name(arg) for arg in args]
|
||||
return f"{origin_name}[{', '.join(arg_names)}]"
|
||||
else:
|
||||
return origin_name
|
||||
|
||||
# Handle regular types
|
||||
elif hasattr(type_annotation, "__name__"):
|
||||
return type_annotation.__name__
|
||||
else:
|
||||
return str(type_annotation)
|
||||
|
||||
|
||||
def analyze_parameter(param: inspect.Parameter, docstring: str = "") -> dict:
|
||||
"""Analyze a function parameter and return its documentation."""
|
||||
# Determine if parameter is optional (has default value)
|
||||
is_optional = param.default == inspect.Parameter.empty
|
||||
|
||||
# Get the type annotation
|
||||
type_annotation = param.annotation
|
||||
param_type = "any"
|
||||
|
||||
if type_annotation != inspect.Parameter.empty:
|
||||
# Handle Union/Optional types first
|
||||
origin = get_origin(type_annotation)
|
||||
if origin is Union:
|
||||
args = get_args(type_annotation)
|
||||
if len(args) == 2 and type(None) in args:
|
||||
# This is Optional[Type]
|
||||
non_none_type = next(arg for arg in args if arg is not type(None))
|
||||
param_type = get_type_name(non_none_type)
|
||||
is_optional = True
|
||||
else:
|
||||
# Other Union types, use first type
|
||||
param_type = get_type_name(args[0]) if args else "any"
|
||||
else:
|
||||
param_type = get_type_name(type_annotation)
|
||||
elif param.default != inspect.Parameter.empty:
|
||||
# No type annotation, but has default value - infer type from default
|
||||
param_type = get_type_name(type(param.default))
|
||||
|
||||
# Extract parameter description from Args section
|
||||
param_description = ""
|
||||
if docstring:
|
||||
# Look for Args section and extract description for this parameter
|
||||
args_section_match = re.search(
|
||||
DOCSTRING_PATTERNS["args_section"], docstring, re.DOTALL
|
||||
)
|
||||
if args_section_match:
|
||||
args_content = args_section_match.group(1)
|
||||
# Look for the parameter description
|
||||
param_pattern = DOCSTRING_PATTERNS["param_description"].format(
|
||||
param_name=re.escape(param.name)
|
||||
)
|
||||
param_match = re.search(
|
||||
param_pattern, args_content, re.MULTILINE | re.DOTALL
|
||||
)
|
||||
if param_match:
|
||||
param_description = param_match.group(1).strip().replace("\n", " ")
|
||||
|
||||
param_info = {
|
||||
"name": param.name,
|
||||
"description": param_description,
|
||||
"isOptional": is_optional,
|
||||
"type": param_type,
|
||||
}
|
||||
|
||||
return param_info
|
||||
|
||||
|
||||
def analyze_function(func, name: str) -> dict:
|
||||
"""Analyze a function and return its documentation."""
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
docstring = inspect.getdoc(func) or ""
|
||||
|
||||
# Skip functions with empty docstrings
|
||||
if not docstring.strip():
|
||||
return {}
|
||||
|
||||
# Extract parameters (excluding 'self')
|
||||
params = []
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name != "self":
|
||||
params.append(analyze_parameter(param, docstring))
|
||||
|
||||
# Special handling for constructor
|
||||
display_name = name
|
||||
if name == "__init__":
|
||||
display_name = func.__qualname__.split(".")[0]
|
||||
|
||||
# Parse tags from docstring
|
||||
tags = parse_docstring_tags(docstring)
|
||||
|
||||
category = tags.get("category", None)
|
||||
|
||||
# Extract description
|
||||
description = extract_description_from_docstring(docstring)
|
||||
|
||||
# Skip if no meaningful description
|
||||
if not description.strip():
|
||||
return {}
|
||||
|
||||
# Extract details section (only if it exists)
|
||||
details = extract_details_from_docstring(docstring)
|
||||
|
||||
# Get examples from docstring, do not generate fallback examples
|
||||
examples = extract_examples_from_docstring(docstring)
|
||||
# If no examples, do not include the examples key or set to empty list
|
||||
|
||||
result = {
|
||||
"id": name,
|
||||
"title": display_name,
|
||||
"description": description,
|
||||
"details": details,
|
||||
"category": category,
|
||||
"params": params,
|
||||
"showDocs": DOC_DEFAULTS["showDocs"],
|
||||
"releaseTag": DOC_DEFAULTS["releaseTag"],
|
||||
"returnType": {
|
||||
"id": "return_type",
|
||||
"name": get_type_name(sig.return_annotation)
|
||||
if sig.return_annotation != inspect.Signature.empty
|
||||
else DOC_DEFAULTS["return_type_void"],
|
||||
},
|
||||
}
|
||||
if examples:
|
||||
result["examples"] = examples
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Error analyzing function {name}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def analyze_class(cls) -> dict:
|
||||
"""Analyze a class and return its documentation."""
|
||||
class_doc = inspect.getdoc(cls) or f"Class: {cls.__name__}"
|
||||
|
||||
# Get all public methods and constructor
|
||||
functions = []
|
||||
for method_name in dir(cls):
|
||||
if method_name.startswith("_") and method_name != "__init__":
|
||||
continue
|
||||
|
||||
method = getattr(cls, method_name)
|
||||
if callable(method):
|
||||
func_info = analyze_function(method, method_name)
|
||||
if func_info: # Only add if not None (empty docstring check)
|
||||
functions.append(func_info)
|
||||
|
||||
return {
|
||||
"id": cls.__name__,
|
||||
"title": cls.__name__,
|
||||
"description": extract_description_from_docstring(class_doc),
|
||||
"functions": functions,
|
||||
}
|
||||
|
||||
|
||||
def analyze_type(cls) -> dict:
|
||||
"""Analyze a type/dataclass and return its documentation."""
|
||||
type_info = {
|
||||
"id": cls.__name__,
|
||||
"name": cls.__name__,
|
||||
"path": f"{cls.__module__}.{cls.__name__}",
|
||||
"properties": [],
|
||||
"example": "",
|
||||
}
|
||||
|
||||
if is_dataclass(cls):
|
||||
# Handle dataclass
|
||||
for field in fields(cls):
|
||||
prop = {
|
||||
"name": field.name,
|
||||
"type": get_type_name(field.type),
|
||||
"description": f"Field: {field.name}",
|
||||
}
|
||||
type_info["properties"].append(prop)
|
||||
elif hasattr(cls, "__annotations__"):
|
||||
# Handle TypedDict or annotated class
|
||||
for field_name, field_type in cls.__annotations__.items():
|
||||
prop = {
|
||||
"name": field_name,
|
||||
"type": get_type_name(field_type),
|
||||
"description": f"Field: {field_name}",
|
||||
}
|
||||
type_info["properties"].append(prop)
|
||||
|
||||
return type_info
|
||||
|
||||
|
||||
def generate_sdk_documentation():
|
||||
"""Generate complete SDK documentation in the requested format."""
|
||||
|
||||
# Import Insights components
|
||||
import hanzo_insights
|
||||
from hanzo_insights.client import Client
|
||||
import hanzo_insights.types as types_module
|
||||
import hanzo_insights.args as args_module
|
||||
from hanzo_insights.version import VERSION
|
||||
|
||||
# Main SDK info
|
||||
sdk_info = {
|
||||
"version": VERSION,
|
||||
"id": "insights-python",
|
||||
"title": "Insights Python SDK",
|
||||
"description": "Integrate Insights into any python application.",
|
||||
"slugPrefix": DOCUMENTATION_METADATA["slugPrefix"],
|
||||
"specUrl": DOCUMENTATION_METADATA["specUrl"],
|
||||
}
|
||||
|
||||
# Collect types
|
||||
types_list = []
|
||||
|
||||
# Types from hanzo_insights.types
|
||||
for name in dir(types_module):
|
||||
obj = getattr(types_module, name)
|
||||
if inspect.isclass(obj) and not name.startswith("_"):
|
||||
try:
|
||||
type_info = analyze_type(obj)
|
||||
types_list.append(type_info)
|
||||
except Exception as e:
|
||||
print(f"Error analyzing type {name}: {e}")
|
||||
|
||||
# Types from hanzo_insights.args
|
||||
for name in dir(args_module):
|
||||
obj = getattr(args_module, name)
|
||||
if inspect.isclass(obj) and not name.startswith("_"):
|
||||
try:
|
||||
type_info = analyze_type(obj)
|
||||
types_list.append(type_info)
|
||||
except Exception as e:
|
||||
print(f"Error analyzing type {name}: {e}")
|
||||
|
||||
# Clean types of empty types
|
||||
|
||||
# Remove types that have no properties and no examples
|
||||
# Remove types that have no properties and no examples
|
||||
types_list = [
|
||||
t for t in types_list if len(t["properties"]) > 0 or t["example"] != ""
|
||||
]
|
||||
|
||||
# Collect classes
|
||||
classes_list = []
|
||||
|
||||
# Main Insights class (renamed from Client)
|
||||
client_class = analyze_class(Client)
|
||||
client_class["id"] = "Insights"
|
||||
client_class["title"] = "Insights"
|
||||
classes_list.append(client_class)
|
||||
|
||||
# Global module functions (functions callable as hanzo_insights.function_name)
|
||||
global_functions = []
|
||||
for func_name in dir(hanzo_insights):
|
||||
# Skip private functions and non-callables
|
||||
if func_name.startswith("_") or not callable(getattr(hanzo_insights, func_name)):
|
||||
continue
|
||||
|
||||
func = getattr(hanzo_insights, func_name)
|
||||
# Only include functions actually defined in the hanzo_insights module (not imported)
|
||||
# and exclude class references
|
||||
if (
|
||||
func_name not in ["Client", "Insights"]
|
||||
and hasattr(func, "__module__")
|
||||
and func.__module__ == "hanzo_insights"
|
||||
):
|
||||
try:
|
||||
func_info = analyze_function(func, func_name)
|
||||
if func_info: # Only add if not None (has proper docstring)
|
||||
global_functions.append(func_info)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Add global functions as a "class"
|
||||
if global_functions:
|
||||
classes_list.append(
|
||||
{
|
||||
"id": "PostHogModule",
|
||||
"title": "Insights Module Functions",
|
||||
"description": "Global functions available in the Insights module",
|
||||
"functions": global_functions,
|
||||
}
|
||||
)
|
||||
|
||||
# Collect categories from functions
|
||||
categories = ["Initialization", "Identification", "Capture"]
|
||||
seen_categories = set(categories)
|
||||
for class_info in classes_list:
|
||||
if "functions" in class_info:
|
||||
for func in class_info["functions"]:
|
||||
if (
|
||||
"category" in func
|
||||
and func["category"] not in seen_categories
|
||||
and func["category"]
|
||||
):
|
||||
categories.append(func["category"])
|
||||
seen_categories.add(func["category"])
|
||||
|
||||
# Create the final structure
|
||||
result = {
|
||||
"id": "insights-python",
|
||||
"hogRef": DOCUMENTATION_METADATA["hogRef"],
|
||||
"info": sdk_info,
|
||||
"types": types_list,
|
||||
"classes": classes_list,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Generating Insights Python SDK documentation...")
|
||||
|
||||
try:
|
||||
documentation = generate_sdk_documentation()
|
||||
|
||||
# Ensure output directory exists
|
||||
output_dir = str(OUTPUT_CONFIG["output_dir"])
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
output_file = os.path.join(
|
||||
str(OUTPUT_CONFIG["output_dir"]), str(OUTPUT_CONFIG["filename"])
|
||||
)
|
||||
output_file_latest = os.path.join(
|
||||
str(OUTPUT_CONFIG["output_dir"]), str(OUTPUT_CONFIG["filename_latest"])
|
||||
)
|
||||
|
||||
# Write to current version
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(documentation, f, indent=int(OUTPUT_CONFIG["indent"]))
|
||||
# Write to latest
|
||||
with open(output_file_latest, "w") as f:
|
||||
json.dump(documentation, f, indent=int(OUTPUT_CONFIG["indent"]))
|
||||
|
||||
print(f"✓ Generated {output_file}")
|
||||
|
||||
# Print summary
|
||||
types_count = len(documentation["types"])
|
||||
classes_count = len(documentation["classes"])
|
||||
|
||||
total_functions = sum(len(cls["functions"]) for cls in documentation["classes"])
|
||||
|
||||
print("📊 Documentation Summary:")
|
||||
print(f" • {types_count} types documented")
|
||||
print(f" • {classes_count} classes documented")
|
||||
print(f" • {total_functions} functions documented")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error generating documentation: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/fmt
|
||||
#/ Description: Formats and lints the code
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
ensure_virtual_env
|
||||
|
||||
if [[ "$1" == "--check" ]]; then
|
||||
ruff format --check .
|
||||
else
|
||||
ruff format .
|
||||
fi
|
||||
@@ -0,0 +1,26 @@
|
||||
error() {
|
||||
echo "$@" >&2
|
||||
}
|
||||
|
||||
fatal() {
|
||||
error "$@"
|
||||
exit 1
|
||||
}
|
||||
|
||||
set_source_and_root_dir() {
|
||||
{ set +x; } 2>/dev/null
|
||||
source_dir="$( cd -P "$( dirname "$0" )" >/dev/null 2>&1 && pwd )"
|
||||
root_dir=$(cd "$source_dir" && cd ../ && pwd)
|
||||
cd "$root_dir"
|
||||
}
|
||||
|
||||
ensure_virtual_env() {
|
||||
if [ -z "$VIRTUAL_ENV" ]; then
|
||||
echo "Virtual environment not activated. Activating now..."
|
||||
if [ ! -f env/bin/activate ]; then
|
||||
echo "Virtual environment not found. Please run 'python -m venv env' first."
|
||||
exit 1
|
||||
fi
|
||||
source env/bin/activate
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/setup
|
||||
#/ Description: Sets up the dependencies needed to develop this project
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
|
||||
if [ ! -d "env" ]; then
|
||||
python3 -m venv env
|
||||
fi
|
||||
|
||||
source env/bin/activate
|
||||
pip install -e ".[dev,test]"
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/test
|
||||
#/ Description: Runs all the unit tests for this project
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
|
||||
ensure_virtual_env
|
||||
|
||||
# Pass through all arguments to pytest
|
||||
pytest "$@"
|
||||
+493
-86
@@ -1,101 +1,508 @@
|
||||
# PostHog Python library example
|
||||
# Hanzo Insights Python library example
|
||||
#
|
||||
# This script demonstrates various Hanzo Insights Python SDK capabilities including:
|
||||
# - Basic event capture and user identification
|
||||
# - Feature flag local evaluation
|
||||
# - Feature flag payloads
|
||||
# - Context management and tagging
|
||||
#
|
||||
# Setup:
|
||||
# 1. Copy .env.example to .env and fill in your Insights credentials
|
||||
# 2. Run this script and choose from the interactive menu
|
||||
|
||||
# Import the library
|
||||
# import time
|
||||
import os
|
||||
|
||||
import posthog
|
||||
import hanzo_insights
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
# You can find this key on the /setup page in PostHog
|
||||
posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh"
|
||||
posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm"
|
||||
def load_env_file():
|
||||
"""Load environment variables from .env file if it exists."""
|
||||
env_path = os.path.join(os.path.dirname(__file__), ".env")
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
# Where you host PostHog, with no trailing /.
|
||||
# You can remove this line if you're using posthog.com
|
||||
posthog.host = "http://localhost:8000"
|
||||
posthog.poll_interval = 10
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
"person-on-events-enabled",
|
||||
"12345",
|
||||
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
|
||||
group_properties={
|
||||
"organization": {
|
||||
"id": "0182ee91-8ef7-0000-4cb9-fedc5f00926a",
|
||||
"created_at": "2022-06-30 11:44:52.984121+00:00",
|
||||
}
|
||||
},
|
||||
# Load .env file if it exists
|
||||
load_env_file()
|
||||
|
||||
# Get configuration
|
||||
project_key = os.getenv("INSIGHTS_PROJECT_API_KEY", "")
|
||||
personal_api_key = os.getenv("INSIGHTS_PERSONAL_API_KEY", "")
|
||||
host = os.getenv("INSIGHTS_HOST", "http://localhost:8000")
|
||||
|
||||
# Check if project key is provided (required)
|
||||
if not project_key:
|
||||
print("❌ Missing Insights project API key!")
|
||||
print(" Please set INSIGHTS_PROJECT_API_KEY environment variable")
|
||||
print(" or copy .env.example to .env and fill in your values")
|
||||
exit(1)
|
||||
|
||||
# Configure Insights with credentials
|
||||
hanzo_insights.debug = False
|
||||
hanzo_insights.api_key = project_key
|
||||
hanzo_insights.project_api_key = project_key
|
||||
hanzo_insights.host = host
|
||||
hanzo_insights.poll_interval = 10
|
||||
|
||||
# Check if personal API key is available for local evaluation
|
||||
local_eval_available = bool(personal_api_key)
|
||||
if personal_api_key:
|
||||
hanzo_insights.personal_api_key = personal_api_key
|
||||
|
||||
print("🔑 Insights Configuration:")
|
||||
print(f" Project API Key: {project_key[:9]}...")
|
||||
if local_eval_available:
|
||||
print(" Personal API Key: [SET]")
|
||||
else:
|
||||
print(" Personal API Key: [NOT SET] - Local evaluation examples will be skipped")
|
||||
print(f" Host: {host}\n")
|
||||
|
||||
# Display menu and get user choice
|
||||
print("🚀 Hanzo Insights Python SDK Demo - Choose an example to run:\n")
|
||||
print("1. Identify and capture examples")
|
||||
local_eval_note = "" if local_eval_available else " [requires personal API key]"
|
||||
print(f"2. Feature flag local evaluation examples{local_eval_note}")
|
||||
print("3. Feature flag payload examples")
|
||||
print(f"4. Flag dependencies examples{local_eval_note}")
|
||||
print("5. Context management and tagging examples")
|
||||
print("6. Run all examples")
|
||||
print("7. Exit")
|
||||
choice = input("\nEnter your choice (1-7): ").strip()
|
||||
|
||||
if choice == "1":
|
||||
print("\n" + "=" * 60)
|
||||
print("IDENTIFY AND CAPTURE EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
hanzo_insights.debug = True
|
||||
|
||||
# Capture an event
|
||||
print("📊 Capturing events...")
|
||||
hanzo_insights.capture(
|
||||
"event",
|
||||
distinct_id="distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
send_feature_flags=True,
|
||||
)
|
||||
|
||||
# Alias a previous distinct id with a new one
|
||||
print("🔗 Creating alias...")
|
||||
hanzo_insights.alias("distinct_id", "new_distinct_id")
|
||||
|
||||
hanzo_insights.capture(
|
||||
"event2",
|
||||
distinct_id="new_distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
)
|
||||
hanzo_insights.capture(
|
||||
"event-with-groups",
|
||||
distinct_id="new_distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
groups={"company": "id:5"},
|
||||
)
|
||||
|
||||
# Add properties to the person
|
||||
print("👤 Identifying user...")
|
||||
hanzo_insights.set(
|
||||
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
|
||||
)
|
||||
|
||||
# Add properties to a group
|
||||
print("🏢 Identifying group...")
|
||||
hanzo_insights.group_identify("company", "id:5", {"employees": 11})
|
||||
|
||||
# Properties set only once to the person
|
||||
print("🔒 Setting properties once...")
|
||||
hanzo_insights.set_once(
|
||||
distinct_id="new_distinct_id", properties={"self_serve_signup": True}
|
||||
)
|
||||
|
||||
# This will not change the property (because it was already set)
|
||||
hanzo_insights.set_once(
|
||||
distinct_id="new_distinct_id", properties={"self_serve_signup": False}
|
||||
)
|
||||
|
||||
print("🔄 Updating properties...")
|
||||
hanzo_insights.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
|
||||
hanzo_insights.set(
|
||||
distinct_id="new_distinct_id", properties={"current_browser": "Firefox"}
|
||||
)
|
||||
|
||||
elif choice == "2":
|
||||
if not local_eval_available:
|
||||
print("\n❌ This example requires a personal API key for local evaluation.")
|
||||
print(
|
||||
" Set INSIGHTS_PERSONAL_API_KEY environment variable to run this example."
|
||||
)
|
||||
hanzo_insights.shutdown()
|
||||
exit(1)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("FEATURE FLAG LOCAL EVALUATION EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
hanzo_insights.debug = True
|
||||
|
||||
print("🏁 Testing basic feature flags...")
|
||||
print(
|
||||
f"beta-feature for 'distinct_id': {hanzo_insights.feature_enabled('beta-feature', 'distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"beta-feature for 'new_distinct_id': {hanzo_insights.feature_enabled('beta-feature', 'new_distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"beta-feature with groups: {hanzo_insights.feature_enabled('beta-feature-groups', 'distinct_id', groups={'company': 'id:5'})}"
|
||||
)
|
||||
|
||||
print("\n🌍 Testing location-based flags...")
|
||||
# Assume test-flag has `City Name = Sydney` as a person property set
|
||||
print(
|
||||
f"Sydney user: {hanzo_insights.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
|
||||
)
|
||||
|
||||
print(
|
||||
f"Sydney user (local only): {hanzo_insights.feature_enabled('test-flag', 'distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
|
||||
)
|
||||
|
||||
print("\n📋 Getting all flags...")
|
||||
print(f"All flags: {hanzo_insights.get_all_flags('distinct_id_random_22')}")
|
||||
print(
|
||||
f"All flags (local): {hanzo_insights.get_all_flags('distinct_id_random_22', only_evaluate_locally=True)}"
|
||||
)
|
||||
print(
|
||||
f"All flags with properties: {hanzo_insights.get_all_flags('distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
|
||||
)
|
||||
|
||||
elif choice == "3":
|
||||
print("\n" + "=" * 60)
|
||||
print("FEATURE FLAG PAYLOAD EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
hanzo_insights.debug = True
|
||||
|
||||
print("📦 Testing feature flag payloads...")
|
||||
print(
|
||||
f"beta-feature payload: {hanzo_insights.get_feature_flag_payload('beta-feature', 'distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"All flags and payloads: {hanzo_insights.get_all_flags_and_payloads('distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"Remote config payload: {hanzo_insights.get_remote_config_payload('encrypted_payload_flag_key')}"
|
||||
)
|
||||
|
||||
# Get feature flag result with all details (enabled, variant, payload, key, reason)
|
||||
print("\n🔍 Getting detailed flag result...")
|
||||
result = hanzo_insights.get_feature_flag_result("beta-feature", "distinct_id")
|
||||
if result:
|
||||
print(f"Flag key: {result.key}")
|
||||
print(f"Flag enabled: {result.enabled}")
|
||||
print(f"Variant: {result.variant}")
|
||||
print(f"Payload: {result.payload}")
|
||||
print(f"Reason: {result.reason}")
|
||||
# get_value() returns the variant if it exists, otherwise the enabled value
|
||||
print(f"Value (variant or enabled): {result.get_value()}")
|
||||
|
||||
elif choice == "4":
|
||||
if not local_eval_available:
|
||||
print("\n❌ This example requires a personal API key for local evaluation.")
|
||||
print(
|
||||
" Set INSIGHTS_PERSONAL_API_KEY environment variable to run this example."
|
||||
)
|
||||
hanzo_insights.shutdown()
|
||||
exit(1)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("FLAG DEPENDENCIES EXAMPLES")
|
||||
print("=" * 60)
|
||||
print("🔗 Testing flag dependencies with local evaluation...")
|
||||
print(
|
||||
" Flag structure: 'test-flag-dependency' depends on 'beta-feature' being enabled"
|
||||
)
|
||||
print("")
|
||||
print("📋 Required setup (if 'test-flag-dependency' doesn't exist):")
|
||||
print(" 1. Create feature flag 'beta-feature':")
|
||||
print(" - Condition: email contains '@example.com'")
|
||||
print(" - Rollout: 100%")
|
||||
print(" 2. Create feature flag 'test-flag-dependency':")
|
||||
print(" - Condition: flag 'beta-feature' is enabled")
|
||||
print(" - Rollout: 100%")
|
||||
print("")
|
||||
|
||||
hanzo_insights.debug = True
|
||||
|
||||
# Test @example.com user (should satisfy dependency if flags exist)
|
||||
result1 = hanzo_insights.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"example_user",
|
||||
person_properties={"email": "user@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
)
|
||||
print(f"✅ @example.com user (test-flag-dependency): {result1}")
|
||||
|
||||
|
||||
# Capture an event
|
||||
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"}, send_feature_flags=True)
|
||||
|
||||
print(posthog.feature_enabled("beta-feature", "distinct_id"))
|
||||
print(posthog.feature_enabled("beta-feature-groups", "distinct_id", groups={"company": "id:5"}))
|
||||
|
||||
print(posthog.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
# get payload
|
||||
print(posthog.get_feature_flag_payload("beta-feature", "distinct_id"))
|
||||
print(posthog.get_all_flags_and_payloads("distinct_id"))
|
||||
exit()
|
||||
# # Alias a previous distinct id with a new one
|
||||
|
||||
posthog.alias("distinct_id", "new_distinct_id")
|
||||
|
||||
posthog.capture("new_distinct_id", "event2", {"property1": "value", "property2": "value"})
|
||||
posthog.capture(
|
||||
"new_distinct_id", "event-with-groups", {"property1": "value", "property2": "value"}, groups={"company": "id:5"}
|
||||
)
|
||||
|
||||
# # Add properties to the person
|
||||
posthog.identify("new_distinct_id", {"email": "something@something.com"})
|
||||
|
||||
# Add properties to a group
|
||||
posthog.group_identify("company", "id:5", {"employees": 11})
|
||||
|
||||
# properties set only once to the person
|
||||
posthog.set_once("new_distinct_id", {"self_serve_signup": True})
|
||||
|
||||
|
||||
posthog.set_once(
|
||||
"new_distinct_id", {"self_serve_signup": False}
|
||||
) # this will not change the property (because it was already set)
|
||||
|
||||
posthog.set("new_distinct_id", {"current_browser": "Chrome"})
|
||||
posthog.set("new_distinct_id", {"current_browser": "Firefox"})
|
||||
|
||||
|
||||
# #############################################################################
|
||||
# Make sure you have a personal API key for the examples below
|
||||
|
||||
# Local Evaluation
|
||||
|
||||
# If flag has City=Sydney, this call doesn't go to `/decide`
|
||||
print(posthog.feature_enabled("test-flag", "distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}))
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
"test-flag",
|
||||
"distinct_id_random_22",
|
||||
person_properties={"$geoip_city_name": "Sydney"},
|
||||
# Test non-example.com user (dependency should not be satisfied)
|
||||
result2 = hanzo_insights.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"regular_user",
|
||||
person_properties={"email": "user@other.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
)
|
||||
print(f"❌ Regular user (test-flag-dependency): {result2}")
|
||||
|
||||
|
||||
print(posthog.get_all_flags("distinct_id_random_22"))
|
||||
print(posthog.get_all_flags("distinct_id_random_22", only_evaluate_locally=True))
|
||||
print(
|
||||
posthog.get_all_flags(
|
||||
"distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}, only_evaluate_locally=True
|
||||
# Test beta-feature directly for comparison
|
||||
beta1 = hanzo_insights.feature_enabled(
|
||||
"beta-feature",
|
||||
"example_user",
|
||||
person_properties={"email": "user@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
)
|
||||
beta2 = hanzo_insights.feature_enabled(
|
||||
"beta-feature",
|
||||
"regular_user",
|
||||
person_properties={"email": "user@other.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
print(f"📊 Beta feature comparison - @example.com: {beta1}, regular: {beta2}")
|
||||
|
||||
print("\n🎯 Results Summary:")
|
||||
print(
|
||||
f" - Flag dependencies evaluated locally: {'✅ YES' if result1 != result2 else '❌ NO'}"
|
||||
)
|
||||
print(" - Zero API calls needed: ✅ YES (all evaluated locally)")
|
||||
print(" - Python SDK supports flag dependencies: ✅ YES")
|
||||
|
||||
posthog.shutdown()
|
||||
print("\n" + "-" * 60)
|
||||
print("PRODUCTION-STYLE MULTIVARIATE DEPENDENCY CHAIN")
|
||||
print("-" * 60)
|
||||
print("🔗 Testing complex multivariate flag dependencies...")
|
||||
print(
|
||||
" Structure: multivariate-root-flag -> multivariate-intermediate-flag -> multivariate-leaf-flag"
|
||||
)
|
||||
print("")
|
||||
print("📋 Required setup (if flags don't exist):")
|
||||
print(
|
||||
" 1. Create 'multivariate-leaf-flag' with fruit variants (pineapple, mango, papaya, kiwi)"
|
||||
)
|
||||
print(" - pineapple: email = 'pineapple@example.com'")
|
||||
print(" - mango: email = 'mango@example.com'")
|
||||
print(
|
||||
" 2. Create 'multivariate-intermediate-flag' with color variants (blue, red)"
|
||||
)
|
||||
print(" - blue: depends on multivariate-leaf-flag = 'pineapple'")
|
||||
print(" - red: depends on multivariate-leaf-flag = 'mango'")
|
||||
print(
|
||||
" 3. Create 'multivariate-root-flag' with show variants (breaking-bad, the-wire)"
|
||||
)
|
||||
print(" - breaking-bad: depends on multivariate-intermediate-flag = 'blue'")
|
||||
print(" - the-wire: depends on multivariate-intermediate-flag = 'red'")
|
||||
print("")
|
||||
|
||||
# Test pineapple -> blue -> breaking-bad chain
|
||||
dependent_result3 = hanzo_insights.get_feature_flag(
|
||||
"multivariate-root-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": "pineapple@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
if str(dependent_result3) != "breaking-bad":
|
||||
print(
|
||||
f" ❌ Something went wrong evaluating 'multivariate-root-flag' with pineapple@example.com. Expected 'breaking-bad', got '{dependent_result3}'"
|
||||
)
|
||||
else:
|
||||
print("✅ 'multivariate-root-flag' with email pineapple@example.com succeeded")
|
||||
|
||||
# Test mango -> red -> the-wire chain
|
||||
dependent_result4 = hanzo_insights.get_feature_flag(
|
||||
"multivariate-root-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": "mango@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
if str(dependent_result4) != "the-wire":
|
||||
print(
|
||||
f" ❌ Something went wrong evaluating multivariate-root-flag with mango@example.com. Expected 'the-wire', got '{dependent_result4}'"
|
||||
)
|
||||
else:
|
||||
print("✅ 'multivariate-root-flag' with email mango@example.com succeeded")
|
||||
|
||||
# Show the complete chain evaluation
|
||||
print("\n🔍 Complete dependency chain evaluation:")
|
||||
for email, expected_chain in [
|
||||
("pineapple@example.com", ["pineapple", "blue", "breaking-bad"]),
|
||||
("mango@example.com", ["mango", "red", "the-wire"]),
|
||||
]:
|
||||
leaf = hanzo_insights.get_feature_flag(
|
||||
"multivariate-leaf-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": email},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
intermediate = hanzo_insights.get_feature_flag(
|
||||
"multivariate-intermediate-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": email},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
root = hanzo_insights.get_feature_flag(
|
||||
"multivariate-root-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": email},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
|
||||
actual_chain = [str(leaf), str(intermediate), str(root)]
|
||||
chain_success = actual_chain == expected_chain
|
||||
|
||||
print(f" 📧 {email}:")
|
||||
print(f" Expected: {' -> '.join(map(str, expected_chain))}")
|
||||
print(f" Actual: {' -> '.join(map(str, actual_chain))}")
|
||||
print(f" Status: {'✅ SUCCESS' if chain_success else '❌ FAILED'}")
|
||||
|
||||
print("\n🎯 Multivariate Chain Summary:")
|
||||
print(" - Complex dependency chains: ✅ SUPPORTED")
|
||||
print(" - Multivariate flag dependencies: ✅ SUPPORTED")
|
||||
print(" - Local evaluation of chains: ✅ WORKING")
|
||||
|
||||
elif choice == "5":
|
||||
print("\n" + "=" * 60)
|
||||
print("CONTEXT MANAGEMENT AND TAGGING EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
hanzo_insights.debug = True
|
||||
|
||||
print("🏷️ Testing context management...")
|
||||
print(
|
||||
"You can add tags to a context, and these are automatically added to any events captured within that context."
|
||||
)
|
||||
|
||||
# You can enter a new context using a with statement. Any exceptions thrown in the context will be captured,
|
||||
# and tagged with the context tags. Other events captured will also be tagged with the context tags. By default,
|
||||
# the new context inherits tags from the parent context.
|
||||
try:
|
||||
with hanzo_insights.new_context():
|
||||
hanzo_insights.tag("transaction_id", "abc123")
|
||||
hanzo_insights.tag("some_arbitrary_value", {"tags": "can be dicts"})
|
||||
|
||||
# This event will be captured with the tags set above
|
||||
hanzo_insights.capture("order_processed")
|
||||
print("✅ Event captured with inherited context tags")
|
||||
# This exception will be captured with the tags set above
|
||||
# raise Exception("Order processing failed")
|
||||
except Exception as e:
|
||||
print(f"Exception captured: {e}")
|
||||
|
||||
# Use fresh=True to start with a clean context (no inherited tags)
|
||||
try:
|
||||
with hanzo_insights.new_context(fresh=True):
|
||||
hanzo_insights.tag("session_id", "xyz789")
|
||||
# Only session_id tag will be present, no inherited tags
|
||||
hanzo_insights.capture("session_event")
|
||||
print("✅ Event captured with fresh context tags")
|
||||
# raise Exception("Session handling failed")
|
||||
except Exception as e:
|
||||
print(f"Exception captured: {e}")
|
||||
|
||||
# You can also use the `@hanzo_insights.scoped()` decorator to enter a new context.
|
||||
# By default, it inherits tags from the parent context
|
||||
@hanzo_insights.scoped()
|
||||
def process_order(order_id):
|
||||
hanzo_insights.tag("order_id", order_id)
|
||||
hanzo_insights.capture("order_step_completed")
|
||||
print(f"✅ Order {order_id} processed with scoped context")
|
||||
# Exception will be captured and tagged automatically
|
||||
# raise Exception("Order processing failed")
|
||||
|
||||
# Use fresh=True to start with a clean context (no inherited tags)
|
||||
@hanzo_insights.scoped(fresh=True)
|
||||
def process_payment(payment_id):
|
||||
hanzo_insights.tag("payment_id", payment_id)
|
||||
hanzo_insights.capture("payment_processed")
|
||||
print(f"✅ Payment {payment_id} processed with fresh scoped context")
|
||||
# Only payment_id tag will be present, no inherited tags
|
||||
# raise Exception("Payment processing failed")
|
||||
|
||||
process_order("12345")
|
||||
process_payment("67890")
|
||||
|
||||
elif choice == "6":
|
||||
print("\n🔄 Running all examples...")
|
||||
if not local_eval_available:
|
||||
print(" (Skipping local evaluation examples - no personal API key set)\n")
|
||||
|
||||
# Run example 1
|
||||
print(f"\n{'🔸' * 20} IDENTIFY AND CAPTURE {'🔸' * 20}")
|
||||
hanzo_insights.debug = True
|
||||
print("📊 Capturing events...")
|
||||
hanzo_insights.capture(
|
||||
"event",
|
||||
distinct_id="distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
send_feature_flags=True,
|
||||
)
|
||||
print("🔗 Creating alias...")
|
||||
hanzo_insights.alias("distinct_id", "new_distinct_id")
|
||||
print("👤 Identifying user...")
|
||||
hanzo_insights.set(
|
||||
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
|
||||
)
|
||||
|
||||
# Run example 2 (requires local evaluation)
|
||||
if local_eval_available:
|
||||
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
|
||||
print("🏁 Testing basic feature flags...")
|
||||
print(f"beta-feature: {hanzo_insights.feature_enabled('beta-feature', 'distinct_id')}")
|
||||
print(
|
||||
f"Sydney user: {hanzo_insights.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: {hanzo_insights.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 = hanzo_insights.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"demo_user",
|
||||
person_properties={"email": "user@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
result2 = hanzo_insights.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"demo_user2",
|
||||
person_properties={"email": "user@other.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
print(f"✅ @example.com user: {result1}, regular user: {result2}")
|
||||
|
||||
# Run example 5
|
||||
print(f"\n{'🔸' * 20} CONTEXT MANAGEMENT {'🔸' * 20}")
|
||||
print("🏷️ Testing context management...")
|
||||
with hanzo_insights.new_context():
|
||||
hanzo_insights.tag("demo_run", "all_examples")
|
||||
hanzo_insights.capture("demo_completed")
|
||||
print("✅ Demo completed with context tags")
|
||||
|
||||
elif choice == "7":
|
||||
print("👋 Goodbye!")
|
||||
hanzo_insights.shutdown()
|
||||
exit()
|
||||
|
||||
else:
|
||||
print("❌ Invalid choice. Please run again and select 1-7.")
|
||||
hanzo_insights.shutdown()
|
||||
exit()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ Example completed!")
|
||||
print("=" * 60)
|
||||
|
||||
hanzo_insights.shutdown()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Redis-based distributed cache for Insights feature flag definitions.
|
||||
|
||||
This example demonstrates how to implement a FlagDefinitionCacheProvider
|
||||
using Redis for multi-instance deployments (leader election pattern).
|
||||
|
||||
Usage:
|
||||
import redis
|
||||
from hanzo_insights import Insights
|
||||
|
||||
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
|
||||
cache = RedisFlagCache(redis_client, service_key="my-service")
|
||||
|
||||
client = Insights(
|
||||
"<project_api_key>",
|
||||
personal_api_key="<personal_api_key>",
|
||||
flag_definition_cache_provider=cache,
|
||||
)
|
||||
|
||||
Requirements:
|
||||
pip install redis
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from hanzo_insights import FlagDefinitionCacheData, FlagDefinitionCacheProvider
|
||||
from redis import Redis
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class RedisFlagCache(FlagDefinitionCacheProvider):
|
||||
"""
|
||||
A distributed cache for Insights feature flag definitions using Redis.
|
||||
|
||||
In a multi-instance deployment (e.g., multiple serverless functions or containers),
|
||||
we want only ONE instance to poll Insights 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:
|
||||
- insights:flags:{service_key} - Cached flag definitions (JSON)
|
||||
- insights: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"insights:flags:{service_key}"
|
||||
self._lock_key = f"insights: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 Insights.
|
||||
|
||||
Atomically either:
|
||||
- Acquires the lock if no one holds it, OR
|
||||
- Extends the lock TTL if we already hold it
|
||||
|
||||
Returns:
|
||||
True if this instance is the leader and should fetch, False otherwise.
|
||||
"""
|
||||
result = self._try_lead(
|
||||
keys=[self._lock_key],
|
||||
args=[self._instance_id, self.LOCK_TTL_MS],
|
||||
)
|
||||
return result == 1
|
||||
|
||||
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
|
||||
"""
|
||||
Store fetched flag definitions in Redis.
|
||||
|
||||
Args:
|
||||
data: The flag definitions to cache.
|
||||
"""
|
||||
self._redis.set(self._cache_key, json.dumps(data), ex=self.CACHE_TTL_SECONDS)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""
|
||||
Release leadership if we hold it. Safe to call even if not the leader.
|
||||
"""
|
||||
self._stop_lead(keys=[self._lock_key], args=[self._instance_id])
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test script for Insights remote config endpoint.
|
||||
"""
|
||||
|
||||
import hanzo_insights
|
||||
|
||||
# Initialize Insights client
|
||||
hanzo_insights.api_key = "phc_..."
|
||||
hanzo_insights.personal_api_key = "phs_..." # or "phx_..."
|
||||
hanzo_insights.host = "http://localhost:8000" # or "https://us.insights.hanzo.ai"
|
||||
hanzo_insights.debug = True
|
||||
|
||||
|
||||
def test_remote_config():
|
||||
"""Test remote config payload retrieval."""
|
||||
print("Testing remote config endpoint...")
|
||||
|
||||
# Test feature flag key - replace with an actual flag key from your project
|
||||
flag_key = "unencrypted-remote-config-setting"
|
||||
|
||||
try:
|
||||
# Get remote config payload
|
||||
payload = hanzo_insights.get_remote_config_payload(flag_key)
|
||||
print(f"✅ Success! Remote config payload for '{flag_key}': {payload}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting remote config: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_remote_config()
|
||||
@@ -0,0 +1,894 @@
|
||||
import datetime # noqa: F401
|
||||
from typing import Any, Callable, Dict, Optional # noqa: F401
|
||||
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from hanzo_insights.args import ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
|
||||
from hanzo_insights.client import Client
|
||||
from hanzo_insights.contexts import (
|
||||
identify_context as inner_identify_context,
|
||||
)
|
||||
from hanzo_insights.contexts import (
|
||||
new_context as inner_new_context,
|
||||
)
|
||||
from hanzo_insights.contexts import (
|
||||
scoped as inner_scoped,
|
||||
)
|
||||
from hanzo_insights.contexts import (
|
||||
set_capture_exception_code_variables_context as inner_set_capture_exception_code_variables_context,
|
||||
)
|
||||
from hanzo_insights.contexts import (
|
||||
set_code_variables_ignore_patterns_context as inner_set_code_variables_ignore_patterns_context,
|
||||
)
|
||||
from hanzo_insights.contexts import (
|
||||
set_code_variables_mask_patterns_context as inner_set_code_variables_mask_patterns_context,
|
||||
)
|
||||
from hanzo_insights.contexts import (
|
||||
set_context_device_id as inner_set_context_device_id,
|
||||
)
|
||||
from hanzo_insights.contexts import (
|
||||
set_context_session as inner_set_context_session,
|
||||
)
|
||||
from hanzo_insights.contexts import (
|
||||
tag as inner_tag,
|
||||
)
|
||||
from hanzo_insights.contexts import (
|
||||
get_tags as inner_get_tags,
|
||||
)
|
||||
from hanzo_insights.exception_utils import (
|
||||
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
|
||||
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
|
||||
)
|
||||
from hanzo_insights.feature_flags import (
|
||||
InconclusiveMatchError as InconclusiveMatchError,
|
||||
)
|
||||
from hanzo_insights.feature_flags import (
|
||||
RequiresServerEvaluation as RequiresServerEvaluation,
|
||||
)
|
||||
from hanzo_insights.flag_definition_cache import (
|
||||
FlagDefinitionCacheData as FlagDefinitionCacheData,
|
||||
FlagDefinitionCacheProvider as FlagDefinitionCacheProvider,
|
||||
)
|
||||
from hanzo_insights.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 hanzo_insights.types import (
|
||||
FeatureFlag,
|
||||
FlagsAndPayloads,
|
||||
)
|
||||
from hanzo_insights.types import (
|
||||
FeatureFlagResult as FeatureFlagResult,
|
||||
)
|
||||
from hanzo_insights.version import VERSION
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
"""Context management."""
|
||||
|
||||
|
||||
def new_context(fresh=False, capture_exceptions=True, client=None):
|
||||
"""
|
||||
Create a new context scope that will be active for the duration of the with block.
|
||||
|
||||
Args:
|
||||
fresh: Whether to start with a fresh context (default: False)
|
||||
capture_exceptions: Whether to capture exceptions raised within the context (default: True)
|
||||
client: Optional Insights client instance to use for this context (default: None)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import new_context, tag, capture
|
||||
with new_context():
|
||||
tag("request_id", "123")
|
||||
capture("event_name", properties={"property": "value"})
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_new_context(
|
||||
fresh=fresh, capture_exceptions=capture_exceptions, client=client
|
||||
)
|
||||
|
||||
|
||||
def scoped(fresh=False, capture_exceptions=True):
|
||||
"""
|
||||
Decorator that creates a new context for the function.
|
||||
|
||||
Args:
|
||||
fresh: Whether to start with a fresh context (default: False)
|
||||
capture_exceptions: Whether to capture and track exceptions with Insights error tracking (default: True)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import scoped, tag, capture
|
||||
@scoped()
|
||||
def process_payment(payment_id):
|
||||
tag("payment_id", payment_id)
|
||||
capture("payment_started")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_scoped(fresh=fresh, capture_exceptions=capture_exceptions)
|
||||
|
||||
|
||||
def set_context_session(session_id: str):
|
||||
"""
|
||||
Set the session ID for the current context.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to associate with the current context and its children
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import set_context_session
|
||||
set_context_session("session_123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
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 hanzo_insights 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.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID to associate with the current context and its children
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import identify_context
|
||||
identify_context("user_123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
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.
|
||||
|
||||
Args:
|
||||
name: The tag key
|
||||
value: The tag value
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import tag
|
||||
tag("user_id", "123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
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]
|
||||
on_error = None # type: Optional[Callable]
|
||||
debug = False # type: bool
|
||||
send = True # type: bool
|
||||
sync_mode = False # type: bool
|
||||
disabled = False # type: bool
|
||||
personal_api_key = None # type: Optional[str]
|
||||
project_api_key = None # type: Optional[str]
|
||||
poll_interval = 30 # type: int
|
||||
disable_geoip = True # type: bool
|
||||
feature_flags_request_timeout_seconds = 3 # type: int
|
||||
super_properties = None # type: Optional[Dict]
|
||||
# Currently alpha, use at your own risk
|
||||
enable_exception_autocapture = False # type: bool
|
||||
log_captured_exceptions = False # type: bool
|
||||
# Used to determine in app paths for exception autocapture. Defaults to the current working directory
|
||||
project_root = None # type: Optional[str]
|
||||
# Used for our AI observability feature to not capture any prompt or output just usage + metadata
|
||||
privacy_mode = False # type: bool
|
||||
# Whether to enable feature flag polling for local evaluation by default. Defaults to True.
|
||||
# We recommend setting this to False if you are only using the personalApiKey for evaluating remote config payloads via `get_remote_config_payload` and not using local evaluation.
|
||||
enable_local_evaluation = True # type: bool
|
||||
|
||||
default_client = None # 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 `hanzo_insights.capture(distinct-id, event-name)` - basically, to enforce
|
||||
# the breaking change made between 5.3.0 and 6.0.0. This decision can be unrolled in later
|
||||
# versions, without a breaking change, to get back the type information in function signatures
|
||||
def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]:
|
||||
"""
|
||||
Capture anything a user does within your system.
|
||||
|
||||
Args:
|
||||
event: The event name to specify the event
|
||||
**kwargs: Optional arguments including:
|
||||
distinct_id: Unique identifier for the user
|
||||
properties: Dict of event properties
|
||||
timestamp: When the event occurred
|
||||
groups: Dict of group types and IDs
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Details:
|
||||
Capture allows you to capture anything a user does within your system, which you can later use in Insights to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Context and capture usage
|
||||
from hanzo_insights import new_context, identify_context, tag_context, capture
|
||||
# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)
|
||||
with new_context():
|
||||
# Associate this context with some user, by distinct_id
|
||||
identify_context('some user')
|
||||
|
||||
# Capture an event, associated with the context-level distinct ID ('some user')
|
||||
capture('movie started')
|
||||
|
||||
# Capture an event associated with some other user (overriding the context-level distinct ID)
|
||||
capture('movie joined', distinct_id='some-other-user')
|
||||
|
||||
# Capture an event with some properties
|
||||
capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})
|
||||
|
||||
# Capture an event with some properties
|
||||
capture('purchase', properties={'product_id': '123', 'category': 'romcom'})
|
||||
# Capture an event with some associated group
|
||||
capture('purchase', groups={'company': 'id:5'})
|
||||
|
||||
# Adding a tag to the current context will cause it to appear on all subsequent events
|
||||
tag_context('some-tag', 'some-value')
|
||||
|
||||
capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict
|
||||
```
|
||||
```python
|
||||
# Set event properties
|
||||
from hanzo_insights import capture
|
||||
capture(
|
||||
"user_signed_up",
|
||||
distinct_id="distinct_id_of_the_user",
|
||||
properties={
|
||||
"login_type": "email",
|
||||
"is_free_trial": "true"
|
||||
}
|
||||
)
|
||||
```
|
||||
Category:
|
||||
Events
|
||||
"""
|
||||
|
||||
return _proxy("capture", event, **kwargs)
|
||||
|
||||
|
||||
def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
"""
|
||||
Set properties on a user record.
|
||||
|
||||
Details:
|
||||
This will overwrite previous people property values. Generally operates similar to `capture`, with distinct_id being an optional argument, defaulting to the current context's distinct ID. If there is no context-level distinct ID, and no override distinct_id is passed, this function will do nothing. Context tags are folded into $set properties, so tagging the current context and then calling `set` will cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Set person properties
|
||||
from hanzo_insights import capture
|
||||
capture(
|
||||
'distinct_id',
|
||||
event='event_name',
|
||||
properties={
|
||||
'$set': {'name': 'Max Hedgehog'},
|
||||
'$set_once': {'initial_url': '/blog'}
|
||||
}
|
||||
)
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
|
||||
return _proxy("set", **kwargs)
|
||||
|
||||
|
||||
def set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
"""
|
||||
Set properties on a user record, only if they do not yet exist.
|
||||
|
||||
Details:
|
||||
This will not overwrite previous people property values, unlike `set`. Otherwise, operates in an identical manner to `set`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Set property once
|
||||
from hanzo_insights import capture
|
||||
capture(
|
||||
'distinct_id',
|
||||
event='event_name',
|
||||
properties={
|
||||
'$set': {'name': 'Max Hedgehog'},
|
||||
'$set_once': {'initial_url': '/blog'}
|
||||
}
|
||||
)
|
||||
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
return _proxy("set_once", **kwargs)
|
||||
|
||||
|
||||
def group_identify(
|
||||
group_type, # type: str
|
||||
group_key, # type: str
|
||||
properties=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> Optional[str]
|
||||
"""
|
||||
Set properties on a group.
|
||||
|
||||
Args:
|
||||
group_type: Type of your group
|
||||
group_key: Unique identifier of the group
|
||||
properties: Properties to set on the group
|
||||
timestamp: Optional timestamp for the event
|
||||
uuid: Optional UUID for the event
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Group identify
|
||||
from hanzo_insights import group_identify
|
||||
group_identify('company', 'company_id_in_your_db', {
|
||||
'name': 'Awesome Inc.',
|
||||
'employees': 11
|
||||
})
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
|
||||
return _proxy(
|
||||
"group_identify",
|
||||
group_type=group_type,
|
||||
group_key=group_key,
|
||||
properties=properties,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def alias(
|
||||
previous_id, # type: str
|
||||
distinct_id, # type: str
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> Optional[str]
|
||||
"""
|
||||
Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.
|
||||
|
||||
Args:
|
||||
previous_id: The unique ID of the user before
|
||||
distinct_id: The current unique id
|
||||
timestamp: Optional timestamp for the event
|
||||
uuid: Optional UUID for the event
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Details:
|
||||
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Alias user
|
||||
from hanzo_insights import alias
|
||||
alias(previous_id='distinct_id', distinct_id='alias_id')
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
|
||||
return _proxy(
|
||||
"alias",
|
||||
previous_id=previous_id,
|
||||
distinct_id=distinct_id,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def capture_exception(
|
||||
exception: Optional[ExceptionArg] = None,
|
||||
**kwargs: Unpack[OptionalCaptureArgs],
|
||||
):
|
||||
"""
|
||||
Capture exceptions that happen in your code.
|
||||
|
||||
Args:
|
||||
exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
|
||||
|
||||
Details:
|
||||
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in hanzo_insights. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with hanzo_insights.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call `hanzo_insights.capture_exception(ValueError("Some Error"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Capture exception
|
||||
from hanzo_insights import capture_exception
|
||||
try:
|
||||
risky_operation()
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
```
|
||||
Category:
|
||||
Events
|
||||
"""
|
||||
|
||||
return _proxy("capture_exception", exception=exception, **kwargs)
|
||||
|
||||
|
||||
def feature_enabled(
|
||||
key, # type: str
|
||||
distinct_id, # type: str
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False, # type: bool
|
||||
send_feature_flag_events=True, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
device_id=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> bool
|
||||
"""
|
||||
Use feature flags to enable or disable features for users.
|
||||
|
||||
Args:
|
||||
key: The feature flag key
|
||||
distinct_id: The user's distinct ID
|
||||
groups: Groups mapping
|
||||
person_properties: Person properties
|
||||
group_properties: Group properties
|
||||
only_evaluate_locally: Whether to evaluate only locally
|
||||
send_feature_flag_events: Whether to send feature flag events
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Details:
|
||||
You can call `hanzo_insights.load_feature_flags()` before to make sure you're not doing unexpected requests.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Boolean feature flag
|
||||
from hanzo_insights import feature_enabled, get_feature_flag_payload
|
||||
is_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')
|
||||
if is_my_flag_enabled:
|
||||
matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
|
||||
```
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy(
|
||||
"feature_enabled",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag(
|
||||
key, # type: str
|
||||
distinct_id, # type: str
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False, # type: bool
|
||||
send_feature_flag_events=True, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
device_id=None, # type: Optional[str]
|
||||
) -> Optional[FeatureFlag]:
|
||||
"""
|
||||
Get feature flag variant for users. Used with experiments.
|
||||
|
||||
Args:
|
||||
key: The feature flag key
|
||||
distinct_id: The user's distinct ID
|
||||
groups: Groups mapping from group type to group key
|
||||
person_properties: Person properties
|
||||
group_properties: Group properties in format { group_type_name: { group_properties } }
|
||||
only_evaluate_locally: Whether to evaluate only locally
|
||||
send_feature_flag_events: Whether to send feature flag events
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Details:
|
||||
`groups` are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5", you would pass groups={"organization": "5"}. `group_properties` take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count, you'll send these as: group_properties={"organization": {"name": "Hanzo", "employees": 11}}.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Multivariate feature flag
|
||||
from hanzo_insights import get_feature_flag, get_feature_flag_payload
|
||||
enabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')
|
||||
if enabled_variant == 'variant-key':
|
||||
matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
|
||||
```
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy(
|
||||
"get_feature_flag",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags(
|
||||
distinct_id, # type: str
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
device_id=None, # type: Optional[str]
|
||||
) -> Optional[dict[str, FeatureFlag]]:
|
||||
"""
|
||||
Get all flags for a given user.
|
||||
|
||||
Args:
|
||||
distinct_id: The user's distinct ID
|
||||
groups: Groups mapping
|
||||
person_properties: Person properties
|
||||
group_properties: Group properties
|
||||
only_evaluate_locally: Whether to evaluate only locally
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Details:
|
||||
Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# All flags for user
|
||||
from hanzo_insights import get_all_flags
|
||||
get_all_flags('distinct_id_of_your_user')
|
||||
```
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy(
|
||||
"get_all_flags",
|
||||
distinct_id=distinct_id,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
disable_geoip=disable_geoip,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag_result(
|
||||
key,
|
||||
distinct_id,
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
device_id=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> Optional[FeatureFlagResult]
|
||||
"""
|
||||
Get a FeatureFlagResult object which contains the flag result and payload.
|
||||
|
||||
This method evaluates a feature flag and returns a FeatureFlagResult object containing:
|
||||
- enabled: Whether the flag is enabled
|
||||
- variant: The variant value if the flag has variants
|
||||
- payload: The payload associated with the flag (automatically deserialized from JSON)
|
||||
- key: The flag key
|
||||
- reason: Why the flag was enabled/disabled
|
||||
|
||||
Example:
|
||||
```python
|
||||
result = hanzo_insights.get_feature_flag_result('beta-feature', 'distinct_id')
|
||||
if result and result.enabled:
|
||||
# Use the variant and payload
|
||||
print(f"Variant: {result.variant}")
|
||||
print(f"Payload: {result.payload}")
|
||||
```
|
||||
"""
|
||||
return _proxy(
|
||||
"get_feature_flag_result",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag_payload(
|
||||
key,
|
||||
distinct_id,
|
||||
match_value=None,
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
device_id=None, # type: Optional[str]
|
||||
) -> Optional[str]:
|
||||
return _proxy(
|
||||
"get_feature_flag_payload",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
match_value=match_value,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
|
||||
def get_remote_config_payload(
|
||||
key, # type: str
|
||||
):
|
||||
"""Get the payload for a remote config feature flag.
|
||||
|
||||
Args:
|
||||
key: The key of the feature flag
|
||||
|
||||
Returns:
|
||||
The payload associated with the feature flag. If payload is encrypted, the return value will decrypted
|
||||
|
||||
Note:
|
||||
Requires personal_api_key to be set for authentication
|
||||
"""
|
||||
return _proxy(
|
||||
"get_remote_config_payload",
|
||||
key=key,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags_and_payloads(
|
||||
distinct_id,
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
device_id=None, # type: Optional[str]
|
||||
) -> FlagsAndPayloads:
|
||||
return _proxy(
|
||||
"get_all_flags_and_payloads",
|
||||
distinct_id=distinct_id,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
disable_geoip=disable_geoip,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
|
||||
def feature_flag_definitions():
|
||||
"""
|
||||
Returns loaded feature flags.
|
||||
|
||||
Details:
|
||||
Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import feature_flag_definitions
|
||||
definitions = feature_flag_definitions()
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy("feature_flag_definitions")
|
||||
|
||||
|
||||
def load_feature_flags():
|
||||
"""
|
||||
Load feature flag definitions from the server.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import load_feature_flags
|
||||
load_feature_flags()
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy("load_feature_flags")
|
||||
|
||||
|
||||
def flush():
|
||||
"""
|
||||
Tell the client to flush all queued events.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import flush
|
||||
flush()
|
||||
```
|
||||
|
||||
Category:
|
||||
Client management
|
||||
"""
|
||||
_proxy("flush")
|
||||
|
||||
|
||||
def join():
|
||||
"""
|
||||
Block program until the client clears the queue. Used during program shutdown. You should use `shutdown()` directly in most cases.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import join
|
||||
join()
|
||||
```
|
||||
|
||||
Category:
|
||||
Client management
|
||||
"""
|
||||
_proxy("join")
|
||||
|
||||
|
||||
def shutdown():
|
||||
"""
|
||||
Flush all messages and cleanly shutdown the client.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import shutdown
|
||||
shutdown()
|
||||
```
|
||||
|
||||
Category:
|
||||
Client management
|
||||
"""
|
||||
_proxy("flush")
|
||||
_proxy("join")
|
||||
|
||||
|
||||
def setup() -> Client:
|
||||
global default_client
|
||||
if not default_client:
|
||||
if not api_key:
|
||||
raise ValueError("API key is required")
|
||||
default_client = Client(
|
||||
api_key,
|
||||
host=host,
|
||||
debug=debug,
|
||||
on_error=on_error,
|
||||
send=send,
|
||||
sync_mode=sync_mode,
|
||||
personal_api_key=personal_api_key,
|
||||
poll_interval=poll_interval,
|
||||
disabled=disabled,
|
||||
disable_geoip=disable_geoip,
|
||||
feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds,
|
||||
super_properties=super_properties,
|
||||
# TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK)
|
||||
# This kind of initialisation is very annoying for exception capture. We need to figure out a way around this,
|
||||
# or deprecate this proxy option fully (it's already in the process of deprecation, no new clients should be using this method since like 5-6 months)
|
||||
enable_exception_autocapture=enable_exception_autocapture,
|
||||
log_captured_exceptions=log_captured_exceptions,
|
||||
enable_local_evaluation=enable_local_evaluation,
|
||||
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
|
||||
default_client.disabled = disabled
|
||||
default_client.debug = debug
|
||||
|
||||
return default_client
|
||||
|
||||
|
||||
def _proxy(method, *args, **kwargs):
|
||||
"""Create an analytics client if one doesn't exist and send to it."""
|
||||
setup()
|
||||
|
||||
fn = getattr(default_client, method)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
|
||||
class Insights(Client):
|
||||
"""Hanzo Insights client for product analytics."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,3 @@
|
||||
from hanzo_insights.ai.prompts import Prompts
|
||||
|
||||
__all__ = ["Prompts"]
|
||||
@@ -0,0 +1,27 @@
|
||||
from .anthropic import Anthropic
|
||||
from .anthropic_async import AsyncAnthropic
|
||||
from .anthropic_providers import (
|
||||
AnthropicBedrock,
|
||||
AnthropicVertex,
|
||||
AsyncAnthropicBedrock,
|
||||
AsyncAnthropicVertex,
|
||||
)
|
||||
from .anthropic_converter import (
|
||||
format_anthropic_response,
|
||||
format_anthropic_input,
|
||||
extract_anthropic_tools,
|
||||
format_anthropic_streaming_content,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Anthropic",
|
||||
"AsyncAnthropic",
|
||||
"AnthropicBedrock",
|
||||
"AsyncAnthropicBedrock",
|
||||
"AnthropicVertex",
|
||||
"AsyncAnthropicVertex",
|
||||
"format_anthropic_response",
|
||||
"format_anthropic_input",
|
||||
"extract_anthropic_tools",
|
||||
"format_anthropic_streaming_content",
|
||||
]
|
||||
@@ -0,0 +1,248 @@
|
||||
try:
|
||||
import anthropic
|
||||
from anthropic.resources import Messages
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
|
||||
)
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hanzo_insights.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
|
||||
from hanzo_insights.ai.utils import (
|
||||
call_llm_and_track_usage,
|
||||
merge_usage_stats,
|
||||
)
|
||||
from hanzo_insights.ai.anthropic.anthropic_converter import (
|
||||
extract_anthropic_usage_from_event,
|
||||
handle_anthropic_content_block_start,
|
||||
handle_anthropic_text_delta,
|
||||
handle_anthropic_tool_delta,
|
||||
finalize_anthropic_tool_input,
|
||||
)
|
||||
from hanzo_insights.ai.sanitization import sanitize_anthropic
|
||||
from hanzo_insights.client import Client as InsightsClient
|
||||
from hanzo_insights import setup
|
||||
|
||||
|
||||
class Anthropic(anthropic.Anthropic):
|
||||
"""
|
||||
A wrapper around the Anthropic SDK that automatically sends LLM usage events to Insights.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
insights_client: Insights client for tracking usage
|
||||
**kwargs: Additional arguments passed to the Anthropic client
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = insights_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
class WrappedMessages(Messages):
|
||||
_client: Anthropic
|
||||
|
||||
def create(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Create a message using Anthropic's API while tracking usage in Insights.
|
||||
|
||||
Args:
|
||||
insights_distinct_id: Optional ID to associate with the usage event
|
||||
insights_trace_id: Optional trace UUID for linking events
|
||||
insights_properties: Optional dictionary of extra properties to include in the event
|
||||
insights_privacy_mode: Whether to redact sensitive information in tracking
|
||||
insights_groups: Optional group analytics properties
|
||||
**kwargs: Arguments passed to Anthropic's messages.create
|
||||
"""
|
||||
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return self._create_streaming(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return call_llm_and_track_usage(
|
||||
insights_distinct_id,
|
||||
self._client._ph_client,
|
||||
"anthropic",
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def stream(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
return self._create_streaming(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _create_streaming(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
|
||||
accumulated_content = ""
|
||||
content_blocks: List[StreamingContentBlock] = []
|
||||
tools_in_progress: Dict[str, ToolInProgress] = {}
|
||||
current_text_block: Optional[StreamingContentBlock] = None
|
||||
response = super().create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content
|
||||
nonlocal content_blocks
|
||||
nonlocal tools_in_progress
|
||||
nonlocal current_text_block
|
||||
|
||||
try:
|
||||
for event in response:
|
||||
# Extract usage stats from event
|
||||
event_usage = extract_anthropic_usage_from_event(event)
|
||||
merge_usage_stats(usage_stats, event_usage)
|
||||
|
||||
# Handle content block start events
|
||||
if hasattr(event, "type") and event.type == "content_block_start":
|
||||
block, tool = handle_anthropic_content_block_start(event)
|
||||
|
||||
if block:
|
||||
content_blocks.append(block)
|
||||
|
||||
if block.get("type") == "text":
|
||||
current_text_block = block
|
||||
else:
|
||||
current_text_block = None
|
||||
|
||||
if tool:
|
||||
tool_id = tool["block"].get("id")
|
||||
if tool_id:
|
||||
tools_in_progress[tool_id] = tool
|
||||
|
||||
# Handle text delta events
|
||||
delta_text = handle_anthropic_text_delta(event, current_text_block)
|
||||
|
||||
if delta_text:
|
||||
accumulated_content += delta_text
|
||||
|
||||
# Handle tool input delta events
|
||||
handle_anthropic_tool_delta(
|
||||
event, content_blocks, tools_in_progress
|
||||
)
|
||||
|
||||
# Handle content block stop events
|
||||
if hasattr(event, "type") and event.type == "content_block_stop":
|
||||
current_text_block = None
|
||||
finalize_anthropic_tool_input(
|
||||
event, content_blocks, tools_in_progress
|
||||
)
|
||||
|
||||
yield event
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
self._capture_streaming_event(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
content_blocks,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
accumulated_content: str,
|
||||
):
|
||||
from hanzo_insights.ai.types import StreamingEventData
|
||||
from hanzo_insights.ai.anthropic.anthropic_converter import (
|
||||
format_anthropic_streaming_input,
|
||||
format_anthropic_streaming_output_complete,
|
||||
)
|
||||
from hanzo_insights.ai.utils import capture_streaming_event
|
||||
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_anthropic_streaming_input(kwargs)
|
||||
sanitized_input = sanitize_anthropic(formatted_input)
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="anthropic",
|
||||
model=kwargs.get("model", "unknown"),
|
||||
base_url=str(self._client.base_url),
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_anthropic_streaming_output_complete(
|
||||
content_blocks, accumulated_content
|
||||
),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=insights_distinct_id,
|
||||
trace_id=insights_trace_id,
|
||||
properties=insights_properties,
|
||||
privacy_mode=insights_privacy_mode,
|
||||
groups=insights_groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
@@ -0,0 +1,248 @@
|
||||
try:
|
||||
import anthropic
|
||||
from anthropic.resources import AsyncMessages
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
|
||||
)
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hanzo_insights import setup
|
||||
from hanzo_insights.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
|
||||
from hanzo_insights.ai.utils import (
|
||||
call_llm_and_track_usage_async,
|
||||
merge_usage_stats,
|
||||
)
|
||||
from hanzo_insights.ai.anthropic.anthropic_converter import (
|
||||
extract_anthropic_usage_from_event,
|
||||
handle_anthropic_content_block_start,
|
||||
handle_anthropic_text_delta,
|
||||
handle_anthropic_tool_delta,
|
||||
finalize_anthropic_tool_input,
|
||||
)
|
||||
from hanzo_insights.ai.sanitization import sanitize_anthropic
|
||||
from hanzo_insights.client import Client as InsightsClient
|
||||
|
||||
|
||||
class AsyncAnthropic(anthropic.AsyncAnthropic):
|
||||
"""
|
||||
An async wrapper around the Anthropic SDK that automatically sends LLM usage events to Insights.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
insights_client: Insights client for tracking usage
|
||||
**kwargs: Additional arguments passed to the Anthropic client
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = insights_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
|
||||
|
||||
class AsyncWrappedMessages(AsyncMessages):
|
||||
_client: AsyncAnthropic
|
||||
|
||||
async def create(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Create a message using Anthropic's API while tracking usage in Insights.
|
||||
|
||||
Args:
|
||||
insights_distinct_id: Optional ID to associate with the usage event
|
||||
insights_trace_id: Optional trace UUID for linking events
|
||||
insights_properties: Optional dictionary of extra properties to include in the event
|
||||
insights_privacy_mode: Whether to redact sensitive information in tracking
|
||||
insights_groups: Optional group analytics properties
|
||||
**kwargs: Arguments passed to Anthropic's messages.create
|
||||
"""
|
||||
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return await self._create_streaming(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return await call_llm_and_track_usage_async(
|
||||
insights_distinct_id,
|
||||
self._client._ph_client,
|
||||
"anthropic",
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
return await self._create_streaming(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _create_streaming(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
|
||||
accumulated_content = ""
|
||||
content_blocks: List[StreamingContentBlock] = []
|
||||
tools_in_progress: Dict[str, ToolInProgress] = {}
|
||||
current_text_block: Optional[StreamingContentBlock] = None
|
||||
response = await super().create(**kwargs)
|
||||
|
||||
async def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content
|
||||
nonlocal content_blocks
|
||||
nonlocal tools_in_progress
|
||||
nonlocal current_text_block
|
||||
|
||||
try:
|
||||
async for event in response:
|
||||
# Extract usage stats from event
|
||||
event_usage = extract_anthropic_usage_from_event(event)
|
||||
merge_usage_stats(usage_stats, event_usage)
|
||||
|
||||
# Handle content block start events
|
||||
if hasattr(event, "type") and event.type == "content_block_start":
|
||||
block, tool = handle_anthropic_content_block_start(event)
|
||||
|
||||
if block:
|
||||
content_blocks.append(block)
|
||||
|
||||
if block.get("type") == "text":
|
||||
current_text_block = block
|
||||
else:
|
||||
current_text_block = None
|
||||
|
||||
if tool:
|
||||
tool_id = tool["block"].get("id")
|
||||
if tool_id:
|
||||
tools_in_progress[tool_id] = tool
|
||||
|
||||
# Handle text delta events
|
||||
delta_text = handle_anthropic_text_delta(event, current_text_block)
|
||||
|
||||
if delta_text:
|
||||
accumulated_content += delta_text
|
||||
|
||||
# Handle tool input delta events
|
||||
handle_anthropic_tool_delta(
|
||||
event, content_blocks, tools_in_progress
|
||||
)
|
||||
|
||||
# Handle content block stop events
|
||||
if hasattr(event, "type") and event.type == "content_block_stop":
|
||||
current_text_block = None
|
||||
finalize_anthropic_tool_input(
|
||||
event, content_blocks, tools_in_progress
|
||||
)
|
||||
|
||||
yield event
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
await self._capture_streaming_event(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
content_blocks,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
async def _capture_streaming_event(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
accumulated_content: str,
|
||||
):
|
||||
from hanzo_insights.ai.types import StreamingEventData
|
||||
from hanzo_insights.ai.anthropic.anthropic_converter import (
|
||||
format_anthropic_streaming_input,
|
||||
format_anthropic_streaming_output_complete,
|
||||
)
|
||||
from hanzo_insights.ai.utils import capture_streaming_event
|
||||
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_anthropic_streaming_input(kwargs)
|
||||
sanitized_input = sanitize_anthropic(formatted_input)
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="anthropic",
|
||||
model=kwargs.get("model", "unknown"),
|
||||
base_url=str(self._client.base_url),
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_anthropic_streaming_output_complete(
|
||||
content_blocks, accumulated_content
|
||||
),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=insights_distinct_id,
|
||||
trace_id=insights_trace_id,
|
||||
properties=insights_properties,
|
||||
privacy_mode=insights_privacy_mode,
|
||||
groups=insights_groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
Anthropic-specific conversion utilities.
|
||||
|
||||
This module handles the conversion of Anthropic API responses and inputs
|
||||
into standardized formats for Insights tracking.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hanzo_insights.ai.types import (
|
||||
FormattedContentItem,
|
||||
FormattedFunctionCall,
|
||||
FormattedMessage,
|
||||
FormattedTextContent,
|
||||
StreamingContentBlock,
|
||||
TokenUsage,
|
||||
ToolInProgress,
|
||||
)
|
||||
from hanzo_insights.ai.utils import serialize_raw_usage
|
||||
|
||||
|
||||
def format_anthropic_response(response: Any) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format an Anthropic response into standardized message format.
|
||||
|
||||
Args:
|
||||
response: The response object from Anthropic API
|
||||
|
||||
Returns:
|
||||
List of formatted messages with role and content
|
||||
"""
|
||||
|
||||
output: List[FormattedMessage] = []
|
||||
|
||||
if response is None:
|
||||
return output
|
||||
|
||||
content: List[FormattedContentItem] = []
|
||||
|
||||
# Process content blocks from the response
|
||||
if hasattr(response, "content"):
|
||||
for choice in response.content:
|
||||
if (
|
||||
hasattr(choice, "type")
|
||||
and choice.type == "text"
|
||||
and hasattr(choice, "text")
|
||||
and choice.text
|
||||
):
|
||||
text_content: FormattedTextContent = {
|
||||
"type": "text",
|
||||
"text": choice.text,
|
||||
}
|
||||
content.append(text_content)
|
||||
|
||||
elif (
|
||||
hasattr(choice, "type")
|
||||
and choice.type == "tool_use"
|
||||
and hasattr(choice, "name")
|
||||
and hasattr(choice, "id")
|
||||
):
|
||||
function_call: FormattedFunctionCall = {
|
||||
"type": "function",
|
||||
"id": choice.id,
|
||||
"function": {
|
||||
"name": choice.name,
|
||||
"arguments": getattr(choice, "input", {}),
|
||||
},
|
||||
}
|
||||
content.append(function_call)
|
||||
|
||||
if content:
|
||||
message: FormattedMessage = {
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}
|
||||
output.append(message)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def format_anthropic_input(
|
||||
messages: List[Dict[str, Any]], system: Optional[str] = None
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format Anthropic input messages with optional system prompt.
|
||||
|
||||
Args:
|
||||
messages: List of message dictionaries
|
||||
system: Optional system prompt to prepend
|
||||
|
||||
Returns:
|
||||
List of formatted messages
|
||||
"""
|
||||
|
||||
formatted_messages: List[FormattedMessage] = []
|
||||
|
||||
# Add system message if provided
|
||||
if system is not None:
|
||||
formatted_messages.append({"role": "system", "content": system})
|
||||
|
||||
# Add user messages
|
||||
if messages:
|
||||
for msg in messages:
|
||||
# Messages are already in the correct format, just ensure type safety
|
||||
formatted_msg: FormattedMessage = {
|
||||
"role": msg.get("role", "user"),
|
||||
"content": msg.get("content", ""),
|
||||
}
|
||||
formatted_messages.append(formatted_msg)
|
||||
|
||||
return formatted_messages
|
||||
|
||||
|
||||
def extract_anthropic_tools(kwargs: Dict[str, Any]) -> Optional[Any]:
|
||||
"""
|
||||
Extract tool definitions from Anthropic API kwargs.
|
||||
|
||||
Args:
|
||||
kwargs: Keyword arguments passed to Anthropic API
|
||||
|
||||
Returns:
|
||||
Tool definitions if present, None otherwise
|
||||
"""
|
||||
|
||||
return kwargs.get("tools", None)
|
||||
|
||||
|
||||
def format_anthropic_streaming_content(
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
) -> List[FormattedContentItem]:
|
||||
"""
|
||||
Format content blocks from Anthropic streaming response.
|
||||
|
||||
Used by streaming handlers to format accumulated content blocks.
|
||||
|
||||
Args:
|
||||
content_blocks: List of content block dictionaries from streaming
|
||||
|
||||
Returns:
|
||||
List of formatted content items
|
||||
"""
|
||||
|
||||
formatted: List[FormattedContentItem] = []
|
||||
|
||||
for block in content_blocks:
|
||||
if block.get("type") == "text":
|
||||
formatted.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": block.get("text") or "",
|
||||
}
|
||||
)
|
||||
|
||||
elif block.get("type") == "function":
|
||||
formatted.append(
|
||||
{
|
||||
"type": "function",
|
||||
"id": block.get("id"),
|
||||
"function": block.get("function") or {},
|
||||
}
|
||||
)
|
||||
|
||||
return formatted
|
||||
|
||||
|
||||
def extract_anthropic_web_search_count(response: Any) -> int:
|
||||
"""
|
||||
Extract web search count from Anthropic response.
|
||||
|
||||
Anthropic provides exact web search counts via usage.server_tool_use.web_search_requests.
|
||||
|
||||
Args:
|
||||
response: The response from Anthropic API
|
||||
|
||||
Returns:
|
||||
Number of web search requests (0 if none)
|
||||
"""
|
||||
if not hasattr(response, "usage"):
|
||||
return 0
|
||||
|
||||
if not hasattr(response.usage, "server_tool_use"):
|
||||
return 0
|
||||
|
||||
server_tool_use = response.usage.server_tool_use
|
||||
|
||||
if hasattr(server_tool_use, "web_search_requests"):
|
||||
return max(0, int(getattr(server_tool_use, "web_search_requests", 0)))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract usage from a full Anthropic response (non-streaming).
|
||||
|
||||
Args:
|
||||
response: The complete response from Anthropic API
|
||||
|
||||
Returns:
|
||||
TokenUsage with standardized usage
|
||||
"""
|
||||
if not hasattr(response, "usage"):
|
||||
return TokenUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
result = TokenUsage(
|
||||
input_tokens=getattr(response.usage, "input_tokens", 0),
|
||||
output_tokens=getattr(response.usage, "output_tokens", 0),
|
||||
)
|
||||
|
||||
if hasattr(response.usage, "cache_read_input_tokens"):
|
||||
cache_read = response.usage.cache_read_input_tokens
|
||||
if cache_read and cache_read > 0:
|
||||
result["cache_read_input_tokens"] = cache_read
|
||||
|
||||
if hasattr(response.usage, "cache_creation_input_tokens"):
|
||||
cache_creation = response.usage.cache_creation_input_tokens
|
||||
if cache_creation and cache_creation > 0:
|
||||
result["cache_creation_input_tokens"] = cache_creation
|
||||
|
||||
web_search_count = extract_anthropic_web_search_count(response)
|
||||
if web_search_count > 0:
|
||||
result["web_search_count"] = web_search_count
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from an Anthropic streaming event.
|
||||
|
||||
Args:
|
||||
event: Streaming event from Anthropic API
|
||||
|
||||
Returns:
|
||||
Dictionary of usage statistics
|
||||
"""
|
||||
|
||||
usage: TokenUsage = TokenUsage()
|
||||
|
||||
# Handle usage stats from message_start event
|
||||
if hasattr(event, "type") and event.type == "message_start":
|
||||
if hasattr(event, "message") and hasattr(event.message, "usage"):
|
||||
usage["input_tokens"] = getattr(event.message.usage, "input_tokens", 0)
|
||||
usage["cache_creation_input_tokens"] = getattr(
|
||||
event.message.usage, "cache_creation_input_tokens", 0
|
||||
)
|
||||
usage["cache_read_input_tokens"] = getattr(
|
||||
event.message.usage, "cache_read_input_tokens", 0
|
||||
)
|
||||
# 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
|
||||
|
||||
|
||||
def handle_anthropic_content_block_start(
|
||||
event: Any,
|
||||
) -> Tuple[Optional[StreamingContentBlock], Optional[ToolInProgress]]:
|
||||
"""
|
||||
Handle content block start event from Anthropic streaming.
|
||||
|
||||
Args:
|
||||
event: Content block start event
|
||||
|
||||
Returns:
|
||||
Tuple of (content_block, tool_in_progress)
|
||||
"""
|
||||
|
||||
if not (hasattr(event, "type") and event.type == "content_block_start"):
|
||||
return None, None
|
||||
|
||||
if not hasattr(event, "content_block"):
|
||||
return None, None
|
||||
|
||||
block = event.content_block
|
||||
|
||||
if not hasattr(block, "type"):
|
||||
return None, None
|
||||
|
||||
if block.type == "text":
|
||||
content_block: StreamingContentBlock = {"type": "text", "text": ""}
|
||||
return content_block, None
|
||||
|
||||
elif block.type == "tool_use":
|
||||
tool_block: StreamingContentBlock = {
|
||||
"type": "function",
|
||||
"id": getattr(block, "id", ""),
|
||||
"function": {"name": getattr(block, "name", ""), "arguments": {}},
|
||||
}
|
||||
tool_in_progress: ToolInProgress = {"block": tool_block, "input_string": ""}
|
||||
return tool_block, tool_in_progress
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def handle_anthropic_text_delta(
|
||||
event: Any, current_block: Optional[StreamingContentBlock]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Handle text delta event from Anthropic streaming.
|
||||
|
||||
Args:
|
||||
event: Delta event
|
||||
current_block: Current text block being accumulated
|
||||
|
||||
Returns:
|
||||
Text delta if present
|
||||
"""
|
||||
|
||||
if hasattr(event, "delta") and hasattr(event.delta, "text"):
|
||||
delta_text = event.delta.text or ""
|
||||
|
||||
if current_block is not None and current_block.get("type") == "text":
|
||||
text_val = current_block.get("text")
|
||||
if text_val is not None:
|
||||
current_block["text"] = text_val + delta_text
|
||||
else:
|
||||
current_block["text"] = delta_text
|
||||
|
||||
return delta_text
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def handle_anthropic_tool_delta(
|
||||
event: Any,
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
tools_in_progress: Dict[str, ToolInProgress],
|
||||
) -> None:
|
||||
"""
|
||||
Handle tool input delta event from Anthropic streaming.
|
||||
|
||||
Args:
|
||||
event: Tool delta event
|
||||
content_blocks: List of content blocks
|
||||
tools_in_progress: Dictionary tracking tools being accumulated
|
||||
"""
|
||||
|
||||
if not (hasattr(event, "type") and event.type == "content_block_delta"):
|
||||
return
|
||||
|
||||
if not (
|
||||
hasattr(event, "delta")
|
||||
and hasattr(event.delta, "type")
|
||||
and event.delta.type == "input_json_delta"
|
||||
):
|
||||
return
|
||||
|
||||
if hasattr(event, "index") and event.index < len(content_blocks):
|
||||
block = content_blocks[event.index]
|
||||
|
||||
if block.get("type") == "function" and block.get("id") in tools_in_progress:
|
||||
tool = tools_in_progress[block["id"]]
|
||||
partial_json = getattr(event.delta, "partial_json", "")
|
||||
tool["input_string"] += partial_json
|
||||
|
||||
|
||||
def finalize_anthropic_tool_input(
|
||||
event: Any,
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
tools_in_progress: Dict[str, ToolInProgress],
|
||||
) -> None:
|
||||
"""
|
||||
Finalize tool input when content block stops.
|
||||
|
||||
Args:
|
||||
event: Content block stop event
|
||||
content_blocks: List of content blocks
|
||||
tools_in_progress: Dictionary tracking tools being accumulated
|
||||
"""
|
||||
|
||||
if not (hasattr(event, "type") and event.type == "content_block_stop"):
|
||||
return
|
||||
|
||||
if hasattr(event, "index") and event.index < len(content_blocks):
|
||||
block = content_blocks[event.index]
|
||||
|
||||
if block.get("type") == "function" and block.get("id") in tools_in_progress:
|
||||
tool = tools_in_progress[block["id"]]
|
||||
|
||||
try:
|
||||
block["function"]["arguments"] = json.loads(tool["input_string"])
|
||||
except (json.JSONDecodeError, Exception):
|
||||
# Keep empty dict if parsing fails
|
||||
pass
|
||||
|
||||
del tools_in_progress[block["id"]]
|
||||
|
||||
|
||||
def format_anthropic_streaming_input(kwargs: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Format Anthropic streaming input using system prompt merging.
|
||||
|
||||
Args:
|
||||
kwargs: Keyword arguments passed to Anthropic API
|
||||
|
||||
Returns:
|
||||
Formatted input ready for Insights tracking
|
||||
"""
|
||||
from hanzo_insights.ai.utils import merge_system_prompt
|
||||
|
||||
return merge_system_prompt(kwargs, "anthropic")
|
||||
|
||||
|
||||
def format_anthropic_streaming_output_complete(
|
||||
content_blocks: List[StreamingContentBlock], accumulated_content: str
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format complete Anthropic streaming output.
|
||||
|
||||
Combines existing logic for formatting content blocks with fallback to accumulated content.
|
||||
|
||||
Args:
|
||||
content_blocks: List of content blocks accumulated during streaming
|
||||
accumulated_content: Raw accumulated text content as fallback
|
||||
|
||||
Returns:
|
||||
Formatted messages ready for Insights tracking
|
||||
"""
|
||||
formatted_content = format_anthropic_streaming_content(content_blocks)
|
||||
|
||||
if formatted_content:
|
||||
return [{"role": "assistant", "content": formatted_content}]
|
||||
else:
|
||||
# Fallback to accumulated content if no blocks
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": accumulated_content}],
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
|
||||
)
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from hanzo_insights.ai.anthropic.anthropic import WrappedMessages
|
||||
from hanzo_insights.ai.anthropic.anthropic_async import AsyncWrappedMessages
|
||||
from hanzo_insights.client import Client as InsightsClient
|
||||
from hanzo_insights import setup
|
||||
|
||||
|
||||
class AnthropicBedrock(anthropic.AnthropicBedrock):
|
||||
"""
|
||||
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to Insights.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = insights_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
class AsyncAnthropicBedrock(anthropic.AsyncAnthropicBedrock):
|
||||
"""
|
||||
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to Insights.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = insights_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
|
||||
|
||||
class AnthropicVertex(anthropic.AnthropicVertex):
|
||||
"""
|
||||
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to Insights.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = insights_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
class AsyncAnthropicVertex(anthropic.AsyncAnthropicVertex):
|
||||
"""
|
||||
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to Insights.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = insights_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
@@ -0,0 +1,25 @@
|
||||
from .gemini import Client
|
||||
from .gemini_async import AsyncClient
|
||||
from .gemini_converter import (
|
||||
format_gemini_input,
|
||||
format_gemini_response,
|
||||
extract_gemini_tools,
|
||||
)
|
||||
|
||||
|
||||
# Create a genai-like module for perfect drop-in replacement
|
||||
class _GenAI:
|
||||
Client = Client
|
||||
AsyncClient = AsyncClient
|
||||
|
||||
|
||||
genai = _GenAI()
|
||||
|
||||
__all__ = [
|
||||
"Client",
|
||||
"AsyncClient",
|
||||
"genai",
|
||||
"format_gemini_input",
|
||||
"format_gemini_response",
|
||||
"extract_gemini_tools",
|
||||
]
|
||||
@@ -0,0 +1,420 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hanzo_insights.ai.types import TokenUsage, StreamingEventData
|
||||
from hanzo_insights.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 hanzo_insights import setup
|
||||
from hanzo_insights.ai.utils import (
|
||||
call_llm_and_track_usage,
|
||||
capture_streaming_event,
|
||||
merge_usage_stats,
|
||||
)
|
||||
from hanzo_insights.ai.gemini.gemini_converter import (
|
||||
extract_gemini_usage_from_chunk,
|
||||
extract_gemini_content_from_chunk,
|
||||
format_gemini_streaming_output,
|
||||
)
|
||||
from hanzo_insights.ai.sanitization import sanitize_gemini
|
||||
from hanzo_insights.client import Client as InsightsClient
|
||||
|
||||
|
||||
class Client:
|
||||
"""
|
||||
A drop-in replacement for genai.Client that automatically sends LLM usage events to Insights.
|
||||
|
||||
Usage:
|
||||
client = Client(
|
||||
api_key="your_api_key",
|
||||
insights_client=insights_client,
|
||||
insights_distinct_id="default_user", # Optional defaults
|
||||
insights_properties={"team": "ai"} # Optional defaults
|
||||
)
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello world"],
|
||||
insights_distinct_id="specific_user" # Override default
|
||||
)
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
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,
|
||||
insights_client: Optional[InsightsClient] = None,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_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
|
||||
insights_client: Insights client for tracking usage
|
||||
insights_distinct_id: Default distinct ID for all calls (can be overridden per call)
|
||||
insights_properties: Default properties for all calls (can be overridden per call)
|
||||
insights_privacy_mode: Default privacy mode for all calls (can be overridden per call)
|
||||
insights_groups: Default groups for all calls (can be overridden per call)
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
|
||||
self._ph_client = insights_client or setup()
|
||||
|
||||
if self._ph_client is None:
|
||||
raise ValueError("insights_client is required for Insights tracking")
|
||||
|
||||
self.models = Models(
|
||||
api_key=api_key,
|
||||
vertexai=vertexai,
|
||||
credentials=credentials,
|
||||
project=project,
|
||||
location=location,
|
||||
debug_config=debug_config,
|
||||
http_options=http_options,
|
||||
insights_client=self._ph_client,
|
||||
insights_distinct_id=insights_distinct_id,
|
||||
insights_properties=insights_properties,
|
||||
insights_privacy_mode=insights_privacy_mode,
|
||||
insights_groups=insights_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class Models:
|
||||
"""
|
||||
Models interface that mimics genai.Client().models with Insights tracking.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient # 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,
|
||||
insights_client: Optional[InsightsClient] = None,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_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
|
||||
insights_client: Insights client for tracking usage
|
||||
insights_distinct_id: Default distinct ID for all calls
|
||||
insights_properties: Default properties for all calls
|
||||
insights_privacy_mode: Default privacy mode for all calls
|
||||
insights_groups: Default groups for all calls
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
|
||||
self._ph_client = insights_client or setup()
|
||||
|
||||
if self._ph_client is None:
|
||||
raise ValueError("insights_client is required for Insights tracking")
|
||||
|
||||
# Store default Insights settings
|
||||
self._default_distinct_id = insights_distinct_id
|
||||
self._default_properties = insights_properties or {}
|
||||
self._default_privacy_mode = insights_privacy_mode
|
||||
self._default_groups = insights_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_insights_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 Insights parameters with client defaults."""
|
||||
|
||||
# Use call-level values if provided, otherwise fall back to defaults
|
||||
distinct_id = (
|
||||
call_distinct_id
|
||||
if call_distinct_id is not None
|
||||
else self._default_distinct_id
|
||||
)
|
||||
privacy_mode = (
|
||||
call_privacy_mode
|
||||
if call_privacy_mode is not None
|
||||
else self._default_privacy_mode
|
||||
)
|
||||
groups = call_groups if call_groups is not None else self._default_groups
|
||||
|
||||
# Merge properties: default properties + call properties (call properties override)
|
||||
properties = dict(self._default_properties)
|
||||
|
||||
if call_properties:
|
||||
properties.update(call_properties)
|
||||
|
||||
if call_trace_id is None:
|
||||
call_trace_id = str(uuid.uuid4())
|
||||
|
||||
return distinct_id, call_trace_id, properties, privacy_mode, groups
|
||||
|
||||
def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: Optional[bool] = None,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Generate content using Gemini's API while tracking usage in Insights.
|
||||
|
||||
This method signature exactly matches genai.Client().models.generate_content()
|
||||
with additional Insights tracking parameters.
|
||||
|
||||
Args:
|
||||
model: The model to use (e.g., 'gemini-2.0-flash')
|
||||
contents: The input content for generation
|
||||
insights_distinct_id: ID to associate with the usage event (overrides client default)
|
||||
insights_trace_id: Trace UUID for linking events (auto-generated if not provided)
|
||||
insights_properties: Extra properties to include in the event (merged with client defaults)
|
||||
insights_privacy_mode: Whether to redact sensitive information (overrides client default)
|
||||
insights_groups: Group analytics properties (overrides client default)
|
||||
**kwargs: Arguments passed to Gemini's generate_content
|
||||
"""
|
||||
|
||||
# Merge Insights parameters
|
||||
distinct_id, trace_id, properties, privacy_mode, groups = (
|
||||
self._merge_insights_params(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
)
|
||||
)
|
||||
|
||||
kwargs_with_contents = {"model": model, "contents": contents, **kwargs}
|
||||
|
||||
return call_llm_and_track_usage(
|
||||
distinct_id,
|
||||
self._ph_client,
|
||||
"gemini",
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
self._base_url,
|
||||
self._client.models.generate_content,
|
||||
**kwargs_with_contents,
|
||||
)
|
||||
|
||||
def _generate_content_streaming(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
distinct_id: Optional[str],
|
||||
trace_id: Optional[str],
|
||||
properties: Optional[Dict[str, Any]],
|
||||
privacy_mode: bool,
|
||||
groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
|
||||
accumulated_content = []
|
||||
|
||||
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
|
||||
response = self._client.models.generate_content_stream(**kwargs_without_stream)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content
|
||||
try:
|
||||
for chunk in response:
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_gemini_usage_from_chunk(chunk)
|
||||
|
||||
if chunk_usage:
|
||||
# Gemini reports cumulative totals, not incremental values
|
||||
merge_usage_stats(usage_stats, chunk_usage, mode="cumulative")
|
||||
|
||||
# Extract content from chunk (now returns content blocks)
|
||||
content_block = extract_gemini_content_from_chunk(chunk)
|
||||
|
||||
if content_block is not None:
|
||||
accumulated_content.append(content_block)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
self._capture_streaming_event(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
distinct_id: Optional[str],
|
||||
trace_id: Optional[str],
|
||||
properties: Optional[Dict[str, Any]],
|
||||
privacy_mode: bool,
|
||||
groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
):
|
||||
# Prepare standardized event data
|
||||
formatted_input = self._format_input(contents, **kwargs)
|
||||
sanitized_input = sanitize_gemini(formatted_input)
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="gemini",
|
||||
model=model,
|
||||
base_url=self._base_url,
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_gemini_streaming_output(output),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=distinct_id,
|
||||
trace_id=trace_id,
|
||||
properties=properties,
|
||||
privacy_mode=privacy_mode,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._ph_client, event_data)
|
||||
|
||||
def _format_input(self, contents, **kwargs):
|
||||
"""Format input contents for Insights tracking"""
|
||||
|
||||
# Create kwargs dict with contents for merge_system_prompt
|
||||
input_kwargs = {"contents": contents, **kwargs}
|
||||
return merge_system_prompt(input_kwargs, "gemini")
|
||||
|
||||
def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: Optional[bool] = None,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
# Merge Insights parameters
|
||||
distinct_id, trace_id, properties, privacy_mode, groups = (
|
||||
self._merge_insights_params(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
)
|
||||
)
|
||||
|
||||
return self._generate_content_streaming(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,423 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hanzo_insights.ai.types import TokenUsage, StreamingEventData
|
||||
from hanzo_insights.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 hanzo_insights import setup
|
||||
from hanzo_insights.ai.utils import (
|
||||
call_llm_and_track_usage_async,
|
||||
capture_streaming_event,
|
||||
merge_usage_stats,
|
||||
)
|
||||
from hanzo_insights.ai.gemini.gemini_converter import (
|
||||
extract_gemini_usage_from_chunk,
|
||||
extract_gemini_content_from_chunk,
|
||||
format_gemini_streaming_output,
|
||||
)
|
||||
from hanzo_insights.ai.sanitization import sanitize_gemini
|
||||
from hanzo_insights.client import Client as InsightsClient
|
||||
|
||||
|
||||
class AsyncClient:
|
||||
"""
|
||||
An async drop-in replacement for genai.Client that automatically sends LLM usage events to Insights.
|
||||
|
||||
Usage:
|
||||
client = AsyncClient(
|
||||
api_key="your_api_key",
|
||||
insights_client=insights_client,
|
||||
insights_distinct_id="default_user", # Optional defaults
|
||||
insights_properties={"team": "ai"} # Optional defaults
|
||||
)
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello world"],
|
||||
insights_distinct_id="specific_user" # Override default
|
||||
)
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
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,
|
||||
insights_client: Optional[InsightsClient] = None,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_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
|
||||
insights_client: Insights client for tracking usage
|
||||
insights_distinct_id: Default distinct ID for all calls (can be overridden per call)
|
||||
insights_properties: Default properties for all calls (can be overridden per call)
|
||||
insights_privacy_mode: Default privacy mode for all calls (can be overridden per call)
|
||||
insights_groups: Default groups for all calls (can be overridden per call)
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
|
||||
self._ph_client = insights_client or setup()
|
||||
|
||||
if self._ph_client is None:
|
||||
raise ValueError("insights_client is required for Insights tracking")
|
||||
|
||||
self.models = AsyncModels(
|
||||
api_key=api_key,
|
||||
vertexai=vertexai,
|
||||
credentials=credentials,
|
||||
project=project,
|
||||
location=location,
|
||||
debug_config=debug_config,
|
||||
http_options=http_options,
|
||||
insights_client=self._ph_client,
|
||||
insights_distinct_id=insights_distinct_id,
|
||||
insights_properties=insights_properties,
|
||||
insights_privacy_mode=insights_privacy_mode,
|
||||
insights_groups=insights_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class AsyncModels:
|
||||
"""
|
||||
Async Models interface that mimics genai.Client().aio.models with Insights tracking.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient # 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,
|
||||
insights_client: Optional[InsightsClient] = None,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_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
|
||||
insights_client: Insights client for tracking usage
|
||||
insights_distinct_id: Default distinct ID for all calls
|
||||
insights_properties: Default properties for all calls
|
||||
insights_privacy_mode: Default privacy mode for all calls
|
||||
insights_groups: Default groups for all calls
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
|
||||
self._ph_client = insights_client or setup()
|
||||
|
||||
if self._ph_client is None:
|
||||
raise ValueError("insights_client is required for Insights tracking")
|
||||
|
||||
# Store default Insights settings
|
||||
self._default_distinct_id = insights_distinct_id
|
||||
self._default_properties = insights_properties or {}
|
||||
self._default_privacy_mode = insights_privacy_mode
|
||||
self._default_groups = insights_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_insights_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 Insights 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,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: Optional[bool] = None,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Generate content using Gemini's API while tracking usage in Insights.
|
||||
|
||||
This method signature exactly matches genai.Client().aio.models.generate_content()
|
||||
with additional Insights tracking parameters.
|
||||
|
||||
Args:
|
||||
model: The model to use (e.g., 'gemini-2.0-flash')
|
||||
contents: The input content for generation
|
||||
insights_distinct_id: ID to associate with the usage event (overrides client default)
|
||||
insights_trace_id: Trace UUID for linking events (auto-generated if not provided)
|
||||
insights_properties: Extra properties to include in the event (merged with client defaults)
|
||||
insights_privacy_mode: Whether to redact sensitive information (overrides client default)
|
||||
insights_groups: Group analytics properties (overrides client default)
|
||||
**kwargs: Arguments passed to Gemini's generate_content
|
||||
"""
|
||||
|
||||
# Merge Insights parameters
|
||||
distinct_id, trace_id, properties, privacy_mode, groups = (
|
||||
self._merge_insights_params(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_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 Insights 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,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: Optional[bool] = None,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
# Merge Insights parameters
|
||||
distinct_id, trace_id, properties, privacy_mode, groups = (
|
||||
self._merge_insights_params(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
)
|
||||
)
|
||||
|
||||
return await self._generate_content_streaming(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,659 @@
|
||||
"""
|
||||
Gemini-specific conversion utilities.
|
||||
|
||||
This module handles the conversion of Gemini API responses and inputs
|
||||
into standardized formats for Insights tracking.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union
|
||||
|
||||
from hanzo_insights.ai.types import (
|
||||
FormattedContentItem,
|
||||
FormattedMessage,
|
||||
TokenUsage,
|
||||
)
|
||||
from hanzo_insights.ai.utils import serialize_raw_usage
|
||||
|
||||
|
||||
class GeminiPart(TypedDict, total=False):
|
||||
"""Represents a part in a Gemini message."""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
class GeminiMessage(TypedDict, total=False):
|
||||
"""Represents a Gemini message with various possible fields."""
|
||||
|
||||
role: str
|
||||
parts: List[Union[GeminiPart, Dict[str, Any]]]
|
||||
content: Union[str, List[Any]]
|
||||
text: str
|
||||
|
||||
|
||||
def _format_parts_as_content_blocks(parts: List[Any]) -> List[FormattedContentItem]:
|
||||
"""
|
||||
Format Gemini parts array into structured content blocks.
|
||||
|
||||
Preserves structure for multimodal content (text + images) instead of
|
||||
concatenating everything into a string.
|
||||
|
||||
Args:
|
||||
parts: List of parts that may contain text, inline_data, etc.
|
||||
|
||||
Returns:
|
||||
List of formatted content blocks
|
||||
"""
|
||||
content_blocks: List[FormattedContentItem] = []
|
||||
|
||||
for part in parts:
|
||||
# Handle dict with text field
|
||||
if isinstance(part, dict) and "text" in part:
|
||||
content_blocks.append({"type": "text", "text": part["text"]})
|
||||
|
||||
# Handle string parts
|
||||
elif isinstance(part, str):
|
||||
content_blocks.append({"type": "text", "text": part})
|
||||
|
||||
# Handle dict with inline_data (images, documents, etc.)
|
||||
elif isinstance(part, dict) and "inline_data" in part:
|
||||
inline_data = part["inline_data"]
|
||||
mime_type = inline_data.get("mime_type", "")
|
||||
content_type = "image" if mime_type.startswith("image/") else "document"
|
||||
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": content_type,
|
||||
"inline_data": inline_data,
|
||||
}
|
||||
)
|
||||
|
||||
# Handle object with text attribute
|
||||
elif hasattr(part, "text"):
|
||||
text_value = getattr(part, "text", "")
|
||||
if text_value:
|
||||
content_blocks.append({"type": "text", "text": text_value})
|
||||
|
||||
# Handle object with inline_data attribute
|
||||
elif hasattr(part, "inline_data"):
|
||||
inline_data = part.inline_data
|
||||
# Convert to dict if needed
|
||||
if hasattr(inline_data, "mime_type") and hasattr(inline_data, "data"):
|
||||
# Determine type based on mime_type
|
||||
mime_type = inline_data.mime_type
|
||||
content_type = "image" if mime_type.startswith("image/") else "document"
|
||||
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": content_type,
|
||||
"inline_data": {
|
||||
"mime_type": mime_type,
|
||||
"data": inline_data.data,
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "image",
|
||||
"inline_data": inline_data,
|
||||
}
|
||||
)
|
||||
|
||||
return content_blocks
|
||||
|
||||
|
||||
def _format_dict_message(item: Dict[str, Any]) -> FormattedMessage:
|
||||
"""
|
||||
Format a dictionary message into standardized format.
|
||||
|
||||
Args:
|
||||
item: Dictionary containing message data
|
||||
|
||||
Returns:
|
||||
Formatted message with role and content
|
||||
"""
|
||||
|
||||
# Handle dict format with parts array (Gemini-specific format)
|
||||
if "parts" in item and isinstance(item["parts"], list):
|
||||
content_blocks = _format_parts_as_content_blocks(item["parts"])
|
||||
return {"role": item.get("role", "user"), "content": content_blocks}
|
||||
|
||||
# Handle dict with content field
|
||||
if "content" in item:
|
||||
content = item["content"]
|
||||
|
||||
if isinstance(content, list):
|
||||
# If content is a list, format it as content blocks
|
||||
content_blocks = _format_parts_as_content_blocks(content)
|
||||
return {"role": item.get("role", "user"), "content": content_blocks}
|
||||
|
||||
elif not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
return {"role": item.get("role", "user"), "content": content}
|
||||
|
||||
# Handle dict with text field
|
||||
if "text" in item:
|
||||
return {"role": item.get("role", "user"), "content": item["text"]}
|
||||
|
||||
# Fallback to string representation
|
||||
return {"role": "user", "content": str(item)}
|
||||
|
||||
|
||||
def _format_object_message(item: Any) -> FormattedMessage:
|
||||
"""
|
||||
Format an object (with attributes) into standardized format.
|
||||
|
||||
Args:
|
||||
item: Object that may have text or parts attributes
|
||||
|
||||
Returns:
|
||||
Formatted message with role and content
|
||||
"""
|
||||
|
||||
# Handle object with parts attribute
|
||||
if hasattr(item, "parts") and hasattr(item.parts, "__iter__"):
|
||||
content_blocks = _format_parts_as_content_blocks(list(item.parts))
|
||||
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
|
||||
|
||||
# Ensure role is a string
|
||||
if not isinstance(role, str):
|
||||
role = "user"
|
||||
|
||||
return {"role": role, "content": content_blocks}
|
||||
|
||||
# Handle object with text attribute
|
||||
if hasattr(item, "text"):
|
||||
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
|
||||
|
||||
# Ensure role is a string
|
||||
if not isinstance(role, str):
|
||||
role = "user"
|
||||
|
||||
return {"role": role, "content": item.text}
|
||||
|
||||
# Handle object with content attribute
|
||||
if hasattr(item, "content"):
|
||||
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
|
||||
|
||||
# Ensure role is a string
|
||||
if not isinstance(role, str):
|
||||
role = "user"
|
||||
|
||||
content = item.content
|
||||
|
||||
if isinstance(content, list):
|
||||
content_blocks = _format_parts_as_content_blocks(content)
|
||||
return {"role": role, "content": content_blocks}
|
||||
|
||||
elif not isinstance(content, str):
|
||||
content = str(content)
|
||||
return {"role": role, "content": content}
|
||||
|
||||
# Fallback to string representation
|
||||
return {"role": "user", "content": str(item)}
|
||||
|
||||
|
||||
def format_gemini_response(response: Any) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format a Gemini response into standardized message format.
|
||||
|
||||
Args:
|
||||
response: The response object from Gemini API
|
||||
|
||||
Returns:
|
||||
List of formatted messages with role and content
|
||||
"""
|
||||
|
||||
output: List[FormattedMessage] = []
|
||||
|
||||
if response is None:
|
||||
return output
|
||||
|
||||
if hasattr(response, "candidates") and response.candidates:
|
||||
for candidate in response.candidates:
|
||||
if hasattr(candidate, "content") and candidate.content:
|
||||
content: List[FormattedContentItem] = []
|
||||
|
||||
if hasattr(candidate.content, "parts") and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
if hasattr(part, "text") and part.text:
|
||||
content.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": part.text,
|
||||
}
|
||||
)
|
||||
|
||||
elif hasattr(part, "function_call") and part.function_call:
|
||||
function_call = part.function_call
|
||||
content.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": function_call.name,
|
||||
"arguments": function_call.args,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
elif hasattr(part, "inline_data") and part.inline_data:
|
||||
# Handle audio/media inline data
|
||||
import base64
|
||||
|
||||
inline_data = part.inline_data
|
||||
mime_type = getattr(inline_data, "mime_type", "audio/pcm")
|
||||
raw_data = getattr(inline_data, "data", b"")
|
||||
|
||||
# Encode binary data as base64 string for JSON serialization
|
||||
if isinstance(raw_data, bytes):
|
||||
data = base64.b64encode(raw_data).decode("utf-8")
|
||||
else:
|
||||
# Already a string (base64)
|
||||
data = raw_data
|
||||
|
||||
content.append(
|
||||
{
|
||||
"type": "audio",
|
||||
"mime_type": mime_type,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
if content:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
elif hasattr(candidate, "text") and candidate.text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": candidate.text}],
|
||||
}
|
||||
)
|
||||
|
||||
elif hasattr(response, "text") and response.text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": response.text}],
|
||||
}
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def extract_gemini_system_instruction(config: Any) -> Optional[str]:
|
||||
"""
|
||||
Extract system instruction from Gemini config parameter.
|
||||
|
||||
Args:
|
||||
config: Config object or dict that may contain system instruction
|
||||
|
||||
Returns:
|
||||
System instruction string if present, None otherwise
|
||||
"""
|
||||
if config is None:
|
||||
return None
|
||||
|
||||
# Handle different config formats
|
||||
if hasattr(config, "system_instruction"):
|
||||
return config.system_instruction
|
||||
elif isinstance(config, dict) and "system_instruction" in config:
|
||||
return config["system_instruction"]
|
||||
elif isinstance(config, dict) and "systemInstruction" in config:
|
||||
return config["systemInstruction"]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_gemini_tools(kwargs: Dict[str, Any]) -> Optional[Any]:
|
||||
"""
|
||||
Extract tool definitions from Gemini API kwargs.
|
||||
|
||||
Args:
|
||||
kwargs: Keyword arguments passed to Gemini API
|
||||
|
||||
Returns:
|
||||
Tool definitions if present, None otherwise
|
||||
"""
|
||||
|
||||
if "config" in kwargs and hasattr(kwargs["config"], "tools"):
|
||||
return kwargs["config"].tools
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_gemini_input_with_system(
|
||||
contents: Any, config: Any = None
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format Gemini input contents into standardized message format, including system instruction handling.
|
||||
|
||||
Args:
|
||||
contents: Input contents in various possible formats
|
||||
config: Config object or dict that may contain system instruction
|
||||
|
||||
Returns:
|
||||
List of formatted messages with role and content fields, with system message prepended if needed
|
||||
"""
|
||||
formatted_messages = format_gemini_input(contents)
|
||||
|
||||
# Check if system instruction is provided in config parameter
|
||||
system_instruction = extract_gemini_system_instruction(config)
|
||||
|
||||
if system_instruction is not None:
|
||||
has_system = any(msg.get("role") == "system" for msg in formatted_messages)
|
||||
if not has_system:
|
||||
from hanzo_insights.ai.types import FormattedMessage
|
||||
|
||||
system_message: FormattedMessage = {
|
||||
"role": "system",
|
||||
"content": system_instruction,
|
||||
}
|
||||
formatted_messages = [system_message] + list(formatted_messages)
|
||||
|
||||
return formatted_messages
|
||||
|
||||
|
||||
def format_gemini_input(contents: Any) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format Gemini input contents into standardized message format for Insights tracking.
|
||||
|
||||
This function handles various input formats:
|
||||
- String inputs
|
||||
- List of strings, dicts, or objects
|
||||
- Single dict or object
|
||||
- Gemini-specific format with parts array
|
||||
|
||||
Args:
|
||||
contents: Input contents in various possible formats
|
||||
|
||||
Returns:
|
||||
List of formatted messages with role and content fields
|
||||
"""
|
||||
|
||||
# Handle string input
|
||||
if isinstance(contents, str):
|
||||
return [{"role": "user", "content": contents}]
|
||||
|
||||
# Handle list input
|
||||
if isinstance(contents, list):
|
||||
formatted: List[FormattedMessage] = []
|
||||
|
||||
for item in contents:
|
||||
if isinstance(item, str):
|
||||
formatted.append({"role": "user", "content": item})
|
||||
|
||||
elif isinstance(item, dict):
|
||||
formatted.append(_format_dict_message(item))
|
||||
|
||||
else:
|
||||
formatted.append(_format_object_message(item))
|
||||
|
||||
return formatted
|
||||
|
||||
# Handle single dict input
|
||||
if isinstance(contents, dict):
|
||||
return [_format_dict_message(contents)]
|
||||
|
||||
# Handle single object input
|
||||
return [_format_object_message(contents)]
|
||||
|
||||
|
||||
def extract_gemini_web_search_count(response: Any) -> int:
|
||||
"""
|
||||
Extract web search count from Gemini response.
|
||||
|
||||
Gemini bills per request that uses grounding, not per query.
|
||||
Returns 1 if grounding_metadata is present with actual search data, 0 otherwise.
|
||||
|
||||
Args:
|
||||
response: The response from Gemini API
|
||||
|
||||
Returns:
|
||||
1 if web search/grounding was used, 0 otherwise
|
||||
"""
|
||||
|
||||
# Check for grounding_metadata in candidates
|
||||
if hasattr(response, "candidates"):
|
||||
for candidate in response.candidates:
|
||||
if (
|
||||
hasattr(candidate, "grounding_metadata")
|
||||
and candidate.grounding_metadata
|
||||
):
|
||||
grounding_metadata = candidate.grounding_metadata
|
||||
|
||||
# Check if web_search_queries exists and is non-empty
|
||||
if hasattr(grounding_metadata, "web_search_queries"):
|
||||
queries = grounding_metadata.web_search_queries
|
||||
|
||||
if queries is not None and len(queries) > 0:
|
||||
return 1
|
||||
|
||||
# Check if grounding_chunks exists and is non-empty
|
||||
if hasattr(grounding_metadata, "grounding_chunks"):
|
||||
chunks = grounding_metadata.grounding_chunks
|
||||
|
||||
if chunks is not None and len(chunks) > 0:
|
||||
return 1
|
||||
|
||||
# Also check for google_search or grounding in function call names
|
||||
if hasattr(candidate, "content") and candidate.content:
|
||||
if hasattr(candidate.content, "parts") and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
if hasattr(part, "function_call") and part.function_call:
|
||||
function_name = getattr(
|
||||
part.function_call, "name", ""
|
||||
).lower()
|
||||
|
||||
if (
|
||||
"google_search" in function_name
|
||||
or "grounding" in function_name
|
||||
):
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_usage_from_metadata(metadata: Any) -> TokenUsage:
|
||||
"""
|
||||
Common logic to extract usage from Gemini metadata.
|
||||
Used by both streaming and non-streaming paths.
|
||||
|
||||
Args:
|
||||
metadata: usage_metadata from Gemini response or chunk
|
||||
|
||||
Returns:
|
||||
TokenUsage with standardized usage
|
||||
"""
|
||||
usage = TokenUsage(
|
||||
input_tokens=getattr(metadata, "prompt_token_count", 0),
|
||||
output_tokens=getattr(metadata, "candidates_token_count", 0),
|
||||
)
|
||||
|
||||
# Add cache tokens if present (don't add if 0)
|
||||
if hasattr(metadata, "cached_content_token_count"):
|
||||
cache_tokens = metadata.cached_content_token_count
|
||||
if cache_tokens and cache_tokens > 0:
|
||||
usage["cache_read_input_tokens"] = cache_tokens
|
||||
|
||||
# Add reasoning tokens if present (don't add if 0)
|
||||
if hasattr(metadata, "thoughts_token_count"):
|
||||
reasoning_tokens = metadata.thoughts_token_count
|
||||
if reasoning_tokens and reasoning_tokens > 0:
|
||||
usage["reasoning_tokens"] = reasoning_tokens
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def extract_gemini_usage_from_response(response: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from a full Gemini response (non-streaming).
|
||||
|
||||
Args:
|
||||
response: The complete response from Gemini API
|
||||
|
||||
Returns:
|
||||
TokenUsage with standardized usage statistics
|
||||
"""
|
||||
if not hasattr(response, "usage_metadata") or not response.usage_metadata:
|
||||
return TokenUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
usage = _extract_usage_from_metadata(response.usage_metadata)
|
||||
|
||||
# Add web search count if present
|
||||
web_search_count = extract_gemini_web_search_count(response)
|
||||
if web_search_count > 0:
|
||||
usage["web_search_count"] = web_search_count
|
||||
|
||||
return usage
|
||||
|
||||
|
||||
def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from a Gemini streaming chunk.
|
||||
|
||||
Args:
|
||||
chunk: Streaming chunk from Gemini API
|
||||
|
||||
Returns:
|
||||
TokenUsage with standardized usage statistics
|
||||
"""
|
||||
|
||||
usage: TokenUsage = TokenUsage()
|
||||
|
||||
# Extract web search count from the chunk before checking for usage_metadata
|
||||
# Web search indicators can appear on any chunk, not just those with usage data
|
||||
web_search_count = extract_gemini_web_search_count(chunk)
|
||||
if web_search_count > 0:
|
||||
usage["web_search_count"] = web_search_count
|
||||
|
||||
if not hasattr(chunk, "usage_metadata") or not chunk.usage_metadata:
|
||||
return usage
|
||||
|
||||
usage_from_metadata = _extract_usage_from_metadata(chunk.usage_metadata)
|
||||
|
||||
# Merge the usage from metadata with any web search count we found
|
||||
usage.update(usage_from_metadata)
|
||||
|
||||
return usage
|
||||
|
||||
|
||||
def extract_gemini_content_from_chunk(chunk: Any) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract content (text or function call) from a Gemini streaming chunk.
|
||||
|
||||
Args:
|
||||
chunk: Streaming chunk from Gemini API
|
||||
|
||||
Returns:
|
||||
Content block dictionary if present, None otherwise
|
||||
"""
|
||||
|
||||
# Check for text content
|
||||
if hasattr(chunk, "text") and chunk.text:
|
||||
return {"type": "text", "text": chunk.text}
|
||||
|
||||
# Check for function calls in candidates
|
||||
if hasattr(chunk, "candidates") and chunk.candidates:
|
||||
for candidate in chunk.candidates:
|
||||
if hasattr(candidate, "content") and candidate.content:
|
||||
if hasattr(candidate.content, "parts") and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
# Check for function_call part
|
||||
if hasattr(part, "function_call") and part.function_call:
|
||||
function_call = part.function_call
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": function_call.name,
|
||||
"arguments": function_call.args,
|
||||
},
|
||||
}
|
||||
# Also check for text in parts
|
||||
elif hasattr(part, "text") and part.text:
|
||||
return {"type": "text", "text": part.text}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_gemini_streaming_output(
|
||||
accumulated_content: Union[str, List[Any]],
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format the final output from Gemini streaming.
|
||||
|
||||
Args:
|
||||
accumulated_content: Accumulated content from streaming (string, list of strings, or list of content blocks)
|
||||
|
||||
Returns:
|
||||
List of formatted messages
|
||||
"""
|
||||
|
||||
# Handle legacy string input (backward compatibility)
|
||||
if isinstance(accumulated_content, str):
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": accumulated_content}],
|
||||
}
|
||||
]
|
||||
|
||||
# Handle list input
|
||||
if isinstance(accumulated_content, list):
|
||||
content: List[FormattedContentItem] = []
|
||||
text_parts = []
|
||||
|
||||
for item in accumulated_content:
|
||||
if isinstance(item, str):
|
||||
# Legacy support: accumulate strings
|
||||
text_parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
# New format: content blocks
|
||||
if item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif item.get("type") == "function":
|
||||
# If we have accumulated text, add it first
|
||||
if text_parts:
|
||||
content.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": "".join(text_parts),
|
||||
}
|
||||
)
|
||||
text_parts = []
|
||||
|
||||
# Add the function call
|
||||
content.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": item.get("function", {}),
|
||||
}
|
||||
)
|
||||
|
||||
# Add any remaining text
|
||||
if text_parts:
|
||||
content.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": "".join(text_parts),
|
||||
}
|
||||
)
|
||||
|
||||
# If we have content, return it
|
||||
if content:
|
||||
return [{"role": "assistant", "content": content}]
|
||||
|
||||
# Fallback for empty or unexpected input
|
||||
return [{"role": "assistant", "content": [{"type": "text", "text": ""}]}]
|
||||
@@ -1,8 +1,11 @@
|
||||
try:
|
||||
import langchain # noqa: F401
|
||||
import langchain_core # noqa: F401
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install LangChain to use this feature: 'pip install langchain'")
|
||||
raise ModuleNotFoundError(
|
||||
"Please install LangChain to use this feature: 'pip install langchain-core'"
|
||||
)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -12,24 +15,38 @@ from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from uuid import UUID
|
||||
|
||||
from langchain.callbacks.base import BaseCallbackHandler
|
||||
from langchain.schema.agent import AgentAction, AgentFinish
|
||||
try:
|
||||
# LangChain 1.0+ and modern 0.x with langchain-core
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
# Fallback for older LangChain versions
|
||||
from langchain.callbacks.base import BaseCallbackHandler
|
||||
from langchain.schema.agent import AgentAction, AgentFinish
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.messages import AIMessage, BaseMessage, FunctionMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
FunctionMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolCall,
|
||||
ToolMessage,
|
||||
)
|
||||
from langchain_core.outputs import ChatGeneration, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from posthog import default_client
|
||||
from posthog.ai.utils import get_model_params, with_privacy_mode
|
||||
from posthog.client import Client
|
||||
from hanzo_insights import setup
|
||||
from hanzo_insights.ai.sanitization import sanitize_langchain
|
||||
from hanzo_insights.ai.utils import get_model_params, with_privacy_mode
|
||||
from hanzo_insights.client import Client
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
log = logging.getLogger("hanzo_insights")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -60,6 +77,10 @@ class GenerationMetadata(SpanMetadata):
|
||||
"""Model parameters of the run: temperature, max_tokens, etc."""
|
||||
base_url: Optional[str] = None
|
||||
"""Base URL of the provider's API used in the run."""
|
||||
tools: Optional[List[Dict[str, Any]]] = None
|
||||
"""Tools provided to the model."""
|
||||
insights_properties: Optional[Dict[str, Any]] = None
|
||||
"""Insights properties of the run."""
|
||||
|
||||
|
||||
RunMetadata = Union[SpanMetadata, GenerationMetadata]
|
||||
@@ -68,13 +89,13 @@ RunMetadataStorage = Dict[UUID, RunMetadata]
|
||||
|
||||
class CallbackHandler(BaseCallbackHandler):
|
||||
"""
|
||||
The PostHog LLM observability callback handler for LangChain.
|
||||
The Insights LLM observability callback handler for LangChain.
|
||||
"""
|
||||
|
||||
_client: Client
|
||||
"""PostHog client instance."""
|
||||
_ph_client: Client
|
||||
"""Insights client instance."""
|
||||
|
||||
_distinct_id: Optional[Union[str, int, float, UUID]]
|
||||
_distinct_id: Optional[Union[str, int, UUID]]
|
||||
"""Distinct ID of the user to associate the trace with."""
|
||||
|
||||
_trace_id: Optional[Union[str, int, float, UUID]]
|
||||
@@ -102,7 +123,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
self,
|
||||
client: Optional[Client] = None,
|
||||
*,
|
||||
distinct_id: Optional[Union[str, int, float, UUID]] = None,
|
||||
distinct_id: Optional[Union[str, int, UUID]] = None,
|
||||
trace_id: Optional[Union[str, int, float, UUID]] = None,
|
||||
properties: Optional[Dict[str, Any]] = None,
|
||||
privacy_mode: bool = False,
|
||||
@@ -110,17 +131,14 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
client: PostHog client instance.
|
||||
client: Insights client instance.
|
||||
distinct_id: Optional distinct ID of the user to associate the trace with.
|
||||
trace_id: Optional trace ID to use for the event.
|
||||
properties: Optional additional metadata to use for the trace.
|
||||
privacy_mode: Whether to redact the input and output of the trace.
|
||||
groups: Optional additional PostHog groups to use for the trace.
|
||||
groups: Optional additional Insights groups to use for the trace.
|
||||
"""
|
||||
posthog_client = client or default_client
|
||||
if posthog_client is None:
|
||||
raise ValueError("PostHog client is required")
|
||||
self._client = posthog_client
|
||||
self._ph_client = client or setup()
|
||||
self._distinct_id = distinct_id
|
||||
self._trace_id = trace_id
|
||||
self._properties = properties or {}
|
||||
@@ -141,7 +159,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
):
|
||||
self._log_debug_event("on_chain_start", run_id, parent_run_id, inputs=inputs)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(serialized, inputs, run_id, parent_run_id, **kwargs)
|
||||
self._set_trace_or_span_metadata(
|
||||
serialized, inputs, run_id, parent_run_id, **kwargs
|
||||
)
|
||||
|
||||
def on_chain_end(
|
||||
self,
|
||||
@@ -174,9 +194,13 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._log_debug_event("on_chat_model_start", run_id, parent_run_id, messages=messages)
|
||||
self._log_debug_event(
|
||||
"on_chat_model_start", run_id, parent_run_id, messages=messages
|
||||
)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
input = [_convert_message_to_dict(message) for row in messages for message in row]
|
||||
input = [
|
||||
_convert_message_to_dict(message) for row in messages for message in row
|
||||
]
|
||||
self._set_llm_metadata(serialized, run_id, input, **kwargs)
|
||||
|
||||
def on_llm_start(
|
||||
@@ -214,7 +238,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
"""
|
||||
The callback works for both streaming and non-streaming runs. For streaming runs, the chain must set `stream_usage=True` in the LLM.
|
||||
"""
|
||||
self._log_debug_event("on_llm_end", run_id, parent_run_id, response=response, kwargs=kwargs)
|
||||
self._log_debug_event(
|
||||
"on_llm_end", run_id, parent_run_id, response=response, kwargs=kwargs
|
||||
)
|
||||
self._pop_run_and_capture_generation(run_id, parent_run_id, response)
|
||||
|
||||
def on_llm_error(
|
||||
@@ -238,8 +264,13 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_tool_start", run_id, parent_run_id, input_str=input_str)
|
||||
self._set_trace_or_span_metadata(serialized, input_str, run_id, parent_run_id, **kwargs)
|
||||
self._log_debug_event(
|
||||
"on_tool_start", run_id, parent_run_id, input_str=input_str
|
||||
)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(
|
||||
serialized, input_str, run_id, parent_run_id, **kwargs
|
||||
)
|
||||
|
||||
def on_tool_end(
|
||||
self,
|
||||
@@ -275,7 +306,10 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_retriever_start", run_id, parent_run_id, query=query)
|
||||
self._set_trace_or_span_metadata(serialized, query, run_id, parent_run_id, **kwargs)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(
|
||||
serialized, query, run_id, parent_run_id, **kwargs
|
||||
)
|
||||
|
||||
def on_retriever_end(
|
||||
self,
|
||||
@@ -285,7 +319,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._log_debug_event("on_retriever_end", run_id, parent_run_id, documents=documents)
|
||||
self._log_debug_event(
|
||||
"on_retriever_end", run_id, parent_run_id, documents=documents
|
||||
)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, documents)
|
||||
|
||||
def on_retriever_error(
|
||||
@@ -360,7 +396,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
):
|
||||
default_name = "trace" if parent_run_id is None else "span"
|
||||
run_name = _get_langchain_run_name(serialized, **kwargs) or default_name
|
||||
self._runs[run_id] = SpanMetadata(name=run_name, input=input, start_time=time.time(), end_time=None)
|
||||
self._runs[run_id] = SpanMetadata(
|
||||
name=run_name, input=input, start_time=time.time(), end_time=None
|
||||
)
|
||||
|
||||
def _set_llm_metadata(
|
||||
self,
|
||||
@@ -372,14 +410,20 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
**kwargs,
|
||||
):
|
||||
run_name = _get_langchain_run_name(serialized, **kwargs) or "generation"
|
||||
generation = GenerationMetadata(name=run_name, input=messages, start_time=time.time(), end_time=None)
|
||||
generation = GenerationMetadata(
|
||||
name=run_name, input=messages, start_time=time.time(), end_time=None
|
||||
)
|
||||
if isinstance(invocation_params, dict):
|
||||
generation.model_params = get_model_params(invocation_params)
|
||||
if tools := invocation_params.get("tools"):
|
||||
generation.tools = tools
|
||||
if isinstance(metadata, dict):
|
||||
if model := metadata.get("ls_model_name"):
|
||||
generation.model = model
|
||||
if provider := metadata.get("ls_provider"):
|
||||
generation.provider = provider
|
||||
|
||||
generation.insights_properties = metadata.get("insights_properties")
|
||||
try:
|
||||
base_url = serialized["kwargs"]["openai_api_base"]
|
||||
if base_url is not None:
|
||||
@@ -404,7 +448,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
return run_id
|
||||
return trace_id
|
||||
|
||||
def _get_parent_run_id(self, trace_id: Any, run_id: UUID, parent_run_id: Optional[UUID]):
|
||||
def _get_parent_run_id(
|
||||
self, trace_id: Any, run_id: UUID, parent_run_id: Optional[UUID]
|
||||
):
|
||||
"""
|
||||
Replace the parent run ID with the trace ID for second level runs when a custom trace ID is set.
|
||||
"""
|
||||
@@ -412,17 +458,25 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
return trace_id
|
||||
return parent_run_id
|
||||
|
||||
def _pop_run_and_capture_trace_or_span(self, run_id: UUID, parent_run_id: Optional[UUID], outputs: Any):
|
||||
def _pop_run_and_capture_trace_or_span(
|
||||
self, run_id: UUID, parent_run_id: Optional[UUID], outputs: Any
|
||||
):
|
||||
trace_id = self._get_trace_id(run_id)
|
||||
self._pop_parent_of_run(run_id)
|
||||
run = self._pop_run_metadata(run_id)
|
||||
if not run:
|
||||
return
|
||||
if isinstance(run, GenerationMetadata):
|
||||
log.warning(f"Run {run_id} is a generation, but attempted to be captured as a trace or span.")
|
||||
log.warning(
|
||||
f"Run {run_id} is a generation, but attempted to be captured as a trace or span."
|
||||
)
|
||||
return
|
||||
self._capture_trace_or_span(
|
||||
trace_id, run_id, run, outputs, self._get_parent_run_id(trace_id, run_id, parent_run_id)
|
||||
trace_id,
|
||||
run_id,
|
||||
run,
|
||||
outputs,
|
||||
self._get_parent_run_id(trace_id, run_id, parent_run_id),
|
||||
)
|
||||
|
||||
def _capture_trace_or_span(
|
||||
@@ -436,10 +490,13 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
event_name = "$ai_trace" if parent_run_id is None else "$ai_span"
|
||||
event_properties = {
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_input_state": with_privacy_mode(self._client, self._privacy_mode, run.input),
|
||||
"$ai_input_state": with_privacy_mode(
|
||||
self._ph_client, self._privacy_mode, sanitize_langchain(run.input)
|
||||
),
|
||||
"$ai_latency": run.latency,
|
||||
"$ai_span_name": run.name,
|
||||
"$ai_span_id": run_id,
|
||||
"$ai_framework": "langchain",
|
||||
}
|
||||
if parent_run_id is not None:
|
||||
event_properties["$ai_parent_id"] = parent_run_id
|
||||
@@ -449,13 +506,23 @@ 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._client, self._privacy_mode, outputs)
|
||||
event_properties["$ai_output_state"] = with_privacy_mode(
|
||||
self._ph_client, self._privacy_mode, outputs
|
||||
)
|
||||
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._client.capture(
|
||||
self._ph_client.capture(
|
||||
distinct_id=self._distinct_id or run_id,
|
||||
event=event_name,
|
||||
properties=event_properties,
|
||||
@@ -463,7 +530,10 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
)
|
||||
|
||||
def _pop_run_and_capture_generation(
|
||||
self, run_id: UUID, parent_run_id: Optional[UUID], response: Union[LLMResult, BaseException]
|
||||
self,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID],
|
||||
response: Union[LLMResult, BaseException],
|
||||
):
|
||||
trace_id = self._get_trace_id(run_id)
|
||||
self._pop_parent_of_run(run_id)
|
||||
@@ -471,10 +541,16 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
if not run:
|
||||
return
|
||||
if not isinstance(run, GenerationMetadata):
|
||||
log.warning(f"Run {run_id} is not a generation, but attempted to be captured as a generation.")
|
||||
log.warning(
|
||||
f"Run {run_id} is not a generation, but attempted to be captured as a generation."
|
||||
)
|
||||
return
|
||||
self._capture_generation(
|
||||
trace_id, run_id, run, response, self._get_parent_run_id(trace_id, run_id, parent_run_id)
|
||||
trace_id,
|
||||
run_id,
|
||||
run,
|
||||
response,
|
||||
self._get_parent_run_id(trace_id, run_id, parent_run_id),
|
||||
)
|
||||
|
||||
def _capture_generation(
|
||||
@@ -493,21 +569,49 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
"$ai_provider": run.provider,
|
||||
"$ai_model": run.model,
|
||||
"$ai_model_parameters": run.model_params,
|
||||
"$ai_input": with_privacy_mode(self._client, self._privacy_mode, run.input),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._ph_client, self._privacy_mode, sanitize_langchain(run.input)
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_latency": run.latency,
|
||||
"$ai_base_url": run.base_url,
|
||||
"$ai_framework": "langchain",
|
||||
}
|
||||
|
||||
if isinstance(run.insights_properties, dict):
|
||||
event_properties.update(run.insights_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
|
||||
input_tokens, output_tokens = _parse_usage(output)
|
||||
event_properties["$ai_input_tokens"] = input_tokens
|
||||
event_properties["$ai_output_tokens"] = output_tokens
|
||||
usage = _parse_usage(output, run.provider, run.model)
|
||||
event_properties["$ai_input_tokens"] = usage.input_tokens
|
||||
event_properties["$ai_output_tokens"] = usage.output_tokens
|
||||
event_properties["$ai_cache_creation_input_tokens"] = (
|
||||
usage.cache_write_tokens
|
||||
)
|
||||
event_properties["$ai_cache_read_input_tokens"] = usage.cache_read_tokens
|
||||
event_properties["$ai_reasoning_tokens"] = usage.reasoning_tokens
|
||||
|
||||
# Generation results
|
||||
generation_result = output.generations[-1]
|
||||
@@ -517,16 +621,15 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
for generation in generation_result
|
||||
]
|
||||
else:
|
||||
completions = [_extract_raw_esponse(generation) for generation in generation_result]
|
||||
event_properties["$ai_output_choices"] = with_privacy_mode(self._client, self._privacy_mode, completions)
|
||||
completions = [
|
||||
_extract_raw_response(generation)
|
||||
for generation in generation_result
|
||||
]
|
||||
event_properties["$ai_output_choices"] = with_privacy_mode(
|
||||
self._ph_client, self._privacy_mode, completions
|
||||
)
|
||||
|
||||
if self._properties:
|
||||
event_properties.update(self._properties)
|
||||
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._client.capture(
|
||||
self._ph_client.capture(
|
||||
distinct_id=self._distinct_id or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
@@ -545,7 +648,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
)
|
||||
|
||||
|
||||
def _extract_raw_esponse(last_response):
|
||||
def _extract_raw_response(last_response):
|
||||
"""Extract the response from the last response of the LLM call."""
|
||||
# We return the text of the response if not empty
|
||||
if last_response.text is not None and last_response.text.strip() != "":
|
||||
@@ -558,12 +661,35 @@ def _extract_raw_esponse(last_response):
|
||||
return ""
|
||||
|
||||
|
||||
def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
|
||||
def _convert_lc_tool_calls_to_oai(
|
||||
tool_calls: list[ToolCall],
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"id": tool_call["id"],
|
||||
"function": {
|
||||
"name": tool_call["name"],
|
||||
"arguments": json.dumps(tool_call["args"]),
|
||||
},
|
||||
}
|
||||
for tool_call in tool_calls
|
||||
]
|
||||
except KeyError:
|
||||
return tool_calls
|
||||
|
||||
|
||||
def _convert_message_to_dict(message: BaseMessage) -> dict[str, Any]:
|
||||
# assistant message
|
||||
if isinstance(message, HumanMessage):
|
||||
message_dict = {"role": "user", "content": message.content}
|
||||
elif isinstance(message, AIMessage):
|
||||
message_dict = {"role": "assistant", "content": message.content}
|
||||
if message.tool_calls:
|
||||
message_dict["tool_calls"] = _convert_lc_tool_calls_to_oai(
|
||||
message.tool_calls
|
||||
)
|
||||
elif isinstance(message, SystemMessage):
|
||||
message_dict = {"role": "system", "content": message.content}
|
||||
elif isinstance(message, ToolMessage):
|
||||
@@ -576,12 +702,26 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
|
||||
if message.additional_kwargs:
|
||||
message_dict.update(message.additional_kwargs)
|
||||
|
||||
if "content" in message_dict and not message_dict["content"]:
|
||||
message_dict["content"] = ""
|
||||
|
||||
return message_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelUsage:
|
||||
input_tokens: Optional[int]
|
||||
output_tokens: Optional[int]
|
||||
cache_write_tokens: Optional[int]
|
||||
cache_read_tokens: Optional[int]
|
||||
reasoning_tokens: Optional[int]
|
||||
|
||||
|
||||
def _parse_usage_model(
|
||||
usage: Union[BaseModel, Dict],
|
||||
) -> Tuple[Union[int, None], Union[int, None]]:
|
||||
usage: Union[BaseModel, dict],
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> ModelUsage:
|
||||
if isinstance(usage, BaseModel):
|
||||
usage = usage.__dict__
|
||||
|
||||
@@ -589,12 +729,23 @@ def _parse_usage_model(
|
||||
# https://pypi.org/project/langchain-anthropic/ (works also for Bedrock-Anthropic)
|
||||
("input_tokens", "input"),
|
||||
("output_tokens", "output"),
|
||||
("cache_creation_input_tokens", "cache_write"),
|
||||
("cache_read_input_tokens", "cache_read"),
|
||||
# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/get-token-count
|
||||
("prompt_token_count", "input"),
|
||||
("candidates_token_count", "output"),
|
||||
("cached_content_token_count", "cache_read"),
|
||||
("thoughts_token_count", "reasoning"),
|
||||
# Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/monitoring-cw.html#runtime-cloudwatch-metrics
|
||||
("inputTokenCount", "input"),
|
||||
("outputTokenCount", "output"),
|
||||
("cacheCreationInputTokenCount", "cache_write"),
|
||||
("cacheReadInputTokenCount", "cache_read"),
|
||||
# Bedrock Anthropic
|
||||
("prompt_tokens", "input"),
|
||||
("completion_tokens", "output"),
|
||||
("cache_creation_input_tokens", "cache_write"),
|
||||
("cache_read_input_tokens", "cache_read"),
|
||||
# langchain-ibm https://pypi.org/project/langchain-ibm/
|
||||
("input_token_count", "input"),
|
||||
("generated_token_count", "output"),
|
||||
@@ -605,29 +756,98 @@ def _parse_usage_model(
|
||||
if model_key in usage:
|
||||
captured_count = usage[model_key]
|
||||
final_count = (
|
||||
sum(captured_count) if isinstance(captured_count, list) else captured_count
|
||||
sum(captured_count)
|
||||
if isinstance(captured_count, list)
|
||||
else captured_count
|
||||
) # For Bedrock, the token count is a list when streamed
|
||||
|
||||
parsed_usage[type_key] = final_count
|
||||
|
||||
return parsed_usage.get("input"), parsed_usage.get("output")
|
||||
# Caching (OpenAI & langchain 0.3.9+)
|
||||
if "input_token_details" in usage and isinstance(
|
||||
usage["input_token_details"], dict
|
||||
):
|
||||
parsed_usage["cache_write"] = usage["input_token_details"].get("cache_creation")
|
||||
parsed_usage["cache_read"] = usage["input_token_details"].get("cache_read")
|
||||
|
||||
# Reasoning (OpenAI & langchain 0.3.9+)
|
||||
if "output_token_details" in usage and isinstance(
|
||||
usage["output_token_details"], dict
|
||||
):
|
||||
parsed_usage["reasoning"] = usage["output_token_details"].get("reasoning")
|
||||
|
||||
field_mapping = {
|
||||
"input": "input_tokens",
|
||||
"output": "output_tokens",
|
||||
"cache_write": "cache_write_tokens",
|
||||
"cache_read": "cache_read_tokens",
|
||||
"reasoning": "reasoning_tokens",
|
||||
}
|
||||
normalized_usage = ModelUsage(
|
||||
**{
|
||||
dataclass_key: parsed_usage.get(mapped_key) or 0
|
||||
for mapped_key, dataclass_key in field_mapping.items()
|
||||
},
|
||||
)
|
||||
# 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):
|
||||
def _parse_usage(
|
||||
response: LLMResult, provider: Optional[str] = None, model: Optional[str] = None
|
||||
) -> ModelUsage:
|
||||
# langchain-anthropic uses the usage field
|
||||
llm_usage_keys = ["token_usage", "usage"]
|
||||
llm_usage: Tuple[Union[int, None], Union[int, None]] = (None, None)
|
||||
llm_usage: ModelUsage = ModelUsage(
|
||||
input_tokens=None,
|
||||
output_tokens=None,
|
||||
cache_write_tokens=None,
|
||||
cache_read_tokens=None,
|
||||
reasoning_tokens=None,
|
||||
)
|
||||
|
||||
if response.llm_output is not None:
|
||||
for key in llm_usage_keys:
|
||||
if response.llm_output.get(key):
|
||||
llm_usage = _parse_usage_model(response.llm_output[key])
|
||||
llm_usage = _parse_usage_model(
|
||||
response.llm_output[key], provider, model
|
||||
)
|
||||
break
|
||||
|
||||
if hasattr(response, "generations"):
|
||||
for generation in response.generations:
|
||||
if "usage" in generation:
|
||||
llm_usage = _parse_usage_model(generation["usage"], provider, model)
|
||||
break
|
||||
|
||||
for generation_chunk in generation:
|
||||
if generation_chunk.generation_info and ("usage_metadata" in generation_chunk.generation_info):
|
||||
llm_usage = _parse_usage_model(generation_chunk.generation_info["usage_metadata"])
|
||||
if generation_chunk.generation_info and (
|
||||
"usage_metadata" in generation_chunk.generation_info
|
||||
):
|
||||
llm_usage = _parse_usage_model(
|
||||
generation_chunk.generation_info["usage_metadata"],
|
||||
provider,
|
||||
model,
|
||||
)
|
||||
break
|
||||
|
||||
message_chunk = getattr(generation_chunk, "message", {})
|
||||
@@ -639,20 +859,47 @@ def _parse_usage(response: LLMResult):
|
||||
else None
|
||||
)
|
||||
bedrock_titan_usage = (
|
||||
response_metadata.get("amazon-bedrock-invocationMetrics", None) # for Bedrock-Titan
|
||||
response_metadata.get(
|
||||
"amazon-bedrock-invocationMetrics", None
|
||||
) # for Bedrock-Titan
|
||||
if isinstance(response_metadata, dict)
|
||||
else None
|
||||
)
|
||||
ollama_usage = getattr(message_chunk, "usage_metadata", None) # for Ollama
|
||||
ollama_usage = getattr(
|
||||
message_chunk, "usage_metadata", None
|
||||
) # for Ollama
|
||||
|
||||
chunk_usage = bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
|
||||
chunk_usage = (
|
||||
bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
|
||||
)
|
||||
if chunk_usage:
|
||||
llm_usage = _parse_usage_model(chunk_usage)
|
||||
llm_usage = _parse_usage_model(chunk_usage, provider, model)
|
||||
break
|
||||
|
||||
return llm_usage
|
||||
|
||||
|
||||
def _capture_exception_and_update_properties(
|
||||
client: Client,
|
||||
exception: BaseException,
|
||||
distinct_id: Optional[Union[str, int, UUID]],
|
||||
groups: Optional[Dict[str, Any]],
|
||||
event_properties: Dict[str, Any],
|
||||
):
|
||||
if client.enable_exception_autocapture:
|
||||
exception_id = client.capture_exception(
|
||||
exception,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups,
|
||||
properties=event_properties,
|
||||
)
|
||||
|
||||
if exception_id:
|
||||
event_properties["$exception_event_id"] = exception_id
|
||||
|
||||
return event_properties
|
||||
|
||||
|
||||
def _get_http_status(error: BaseException) -> int:
|
||||
# OpenAI: https://github.com/openai/openai-python/blob/main/src/openai/_exceptions.py
|
||||
# Anthropic: https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_exceptions.py
|
||||
@@ -661,7 +908,9 @@ def _get_http_status(error: BaseException) -> int:
|
||||
return status_code
|
||||
|
||||
|
||||
def _get_langchain_run_name(serialized: Optional[Dict[str, Any]], **kwargs: Any) -> Optional[str]:
|
||||
def _get_langchain_run_name(
|
||||
serialized: Optional[Dict[str, Any]], **kwargs: Any
|
||||
) -> Optional[str]:
|
||||
"""Retrieve the name of a serialized LangChain runnable.
|
||||
|
||||
The prioritization for the determination of the run name is as follows:
|
||||
@@ -0,0 +1,20 @@
|
||||
from .openai import OpenAI
|
||||
from .openai_async import AsyncOpenAI
|
||||
from .openai_providers import AsyncAzureOpenAI, AzureOpenAI
|
||||
from .openai_converter import (
|
||||
format_openai_response,
|
||||
format_openai_input,
|
||||
extract_openai_tools,
|
||||
format_openai_streaming_content,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OpenAI",
|
||||
"AsyncOpenAI",
|
||||
"AzureOpenAI",
|
||||
"AsyncAzureOpenAI",
|
||||
"format_openai_response",
|
||||
"format_openai_input",
|
||||
"extract_openai_tools",
|
||||
"format_openai_streaming_content",
|
||||
]
|
||||
@@ -0,0 +1,600 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hanzo_insights.ai.types import TokenUsage
|
||||
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the OpenAI SDK to use this feature: 'pip install openai'"
|
||||
)
|
||||
|
||||
from hanzo_insights.ai.utils import (
|
||||
call_llm_and_track_usage,
|
||||
extract_available_tool_calls,
|
||||
merge_usage_stats,
|
||||
with_privacy_mode,
|
||||
)
|
||||
from hanzo_insights.ai.openai.openai_converter import (
|
||||
extract_openai_usage_from_chunk,
|
||||
extract_openai_content_from_chunk,
|
||||
extract_openai_tool_calls_from_chunk,
|
||||
accumulate_openai_tool_calls,
|
||||
)
|
||||
from hanzo_insights.ai.sanitization import sanitize_openai, sanitize_openai_response
|
||||
from hanzo_insights.client import Client as InsightsClient
|
||||
from hanzo_insights import setup
|
||||
|
||||
|
||||
class OpenAI(openai.OpenAI):
|
||||
"""
|
||||
A wrapper around the OpenAI SDK that automatically sends LLM usage events to Insights.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: OpenAI API key.
|
||||
insights_client: If provided, events will be captured via this client instead of the global client.
|
||||
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
|
||||
"""
|
||||
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = insights_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = WrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = WrappedBeta(self, self._original_beta)
|
||||
|
||||
if self._original_responses is not None:
|
||||
self.responses = WrappedResponses(self, self._original_responses)
|
||||
|
||||
|
||||
class WrappedResponses:
|
||||
"""Wrapper for OpenAI responses that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_responses):
|
||||
self._client = client
|
||||
self._original = original_responses
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original responses object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def create(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return self._create_streaming(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return call_llm_and_track_usage(
|
||||
insights_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
self._client.base_url,
|
||||
self._original.create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _create_streaming(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
final_content = []
|
||||
model_from_response: Optional[str] = None
|
||||
response = self._original.create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal final_content # noqa: F824
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
# Extract model from response object in chunk (for stored prompts)
|
||||
if hasattr(chunk, "response") and chunk.response:
|
||||
if model_from_response is None and hasattr(
|
||||
chunk.response, "model"
|
||||
):
|
||||
model_from_response = chunk.response.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
|
||||
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "responses")
|
||||
|
||||
if content is not None:
|
||||
final_content.append(content)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = final_content
|
||||
self._capture_streaming_event(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
None, # Responses API doesn't have tools
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
from hanzo_insights.ai.types import StreamingEventData
|
||||
from hanzo_insights.ai.openai.openai_converter import (
|
||||
format_openai_streaming_input,
|
||||
format_openai_streaming_output,
|
||||
)
|
||||
from hanzo_insights.ai.utils import capture_streaming_event
|
||||
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_openai_streaming_input(kwargs, "responses")
|
||||
sanitized_input = sanitize_openai_response(formatted_input)
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="openai",
|
||||
model=model,
|
||||
base_url=str(self._client.base_url),
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_openai_streaming_output(output, "responses"),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=insights_distinct_id,
|
||||
trace_id=insights_trace_id,
|
||||
properties=insights_properties,
|
||||
privacy_mode=insights_privacy_mode,
|
||||
groups=insights_groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
|
||||
def parse(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in Insights.
|
||||
|
||||
Args:
|
||||
insights_distinct_id: Optional ID to associate with the usage event.
|
||||
insights_trace_id: Optional trace UUID for linking events.
|
||||
insights_properties: Optional dictionary of extra properties to include in the event.
|
||||
insights_privacy_mode: Whether to anonymize the input and output.
|
||||
insights_groups: Optional dictionary of groups to associate with the event.
|
||||
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's responses.parse call.
|
||||
"""
|
||||
return call_llm_and_track_usage(
|
||||
insights_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
self._client.base_url,
|
||||
self._original.parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class WrappedChat:
|
||||
"""Wrapper for OpenAI chat that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_chat):
|
||||
self._client = client
|
||||
self._original = original_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedCompletions:
|
||||
"""Wrapper for OpenAI chat completions that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_completions):
|
||||
self._client = client
|
||||
self._original = original_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def create(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return self._create_streaming(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return call_llm_and_track_usage(
|
||||
insights_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
self._client.base_url,
|
||||
self._original.create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _create_streaming(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
accumulated_content = []
|
||||
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
|
||||
model_from_response: Optional[str] = None
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
response = self._original.create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
nonlocal accumulated_tool_calls
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
# Extract model from chunk (Chat Completions chunks have model field)
|
||||
if model_from_response is None and hasattr(chunk, "model"):
|
||||
model_from_response = chunk.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
|
||||
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "chat")
|
||||
|
||||
if content is not None:
|
||||
accumulated_content.append(content)
|
||||
|
||||
# Extract and accumulate tool calls from chunk
|
||||
chunk_tool_calls = extract_openai_tool_calls_from_chunk(chunk)
|
||||
if chunk_tool_calls:
|
||||
accumulate_openai_tool_calls(
|
||||
accumulated_tool_calls, chunk_tool_calls
|
||||
)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
# Convert accumulated tool calls dict to list
|
||||
tool_calls_list = (
|
||||
list(accumulated_tool_calls.values())
|
||||
if accumulated_tool_calls
|
||||
else None
|
||||
)
|
||||
|
||||
self._capture_streaming_event(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
accumulated_content,
|
||||
tool_calls_list,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
from hanzo_insights.ai.types import StreamingEventData
|
||||
from hanzo_insights.ai.openai.openai_converter import (
|
||||
format_openai_streaming_input,
|
||||
format_openai_streaming_output,
|
||||
)
|
||||
from hanzo_insights.ai.utils import capture_streaming_event
|
||||
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_openai_streaming_input(kwargs, "chat")
|
||||
sanitized_input = sanitize_openai(formatted_input)
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="openai",
|
||||
model=model,
|
||||
base_url=str(self._client.base_url),
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_openai_streaming_output(output, "chat", tool_calls),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=insights_distinct_id,
|
||||
trace_id=insights_trace_id,
|
||||
properties=insights_properties,
|
||||
privacy_mode=insights_privacy_mode,
|
||||
groups=insights_groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
|
||||
|
||||
class WrappedEmbeddings:
|
||||
"""Wrapper for OpenAI embeddings that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_embeddings):
|
||||
self._client = client
|
||||
self._original = original_embeddings
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original embeddings object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def create(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in Insights.
|
||||
|
||||
Args:
|
||||
insights_distinct_id: Optional ID to associate with the usage event.
|
||||
insights_trace_id: Optional trace UUID for linking events.
|
||||
insights_properties: Optional dictionary of extra properties to include in the event.
|
||||
insights_privacy_mode: Whether to anonymize the input and output.
|
||||
insights_groups: Optional dictionary of groups to associate with the event.
|
||||
**kwargs: Any additional parameters for the OpenAI Embeddings API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's embeddings.create call.
|
||||
"""
|
||||
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
start_time = time.time()
|
||||
response = self._original.create(**kwargs)
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
usage_stats = {}
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage_stats = {
|
||||
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
|
||||
"total_tokens": getattr(response.usage, "total_tokens", 0),
|
||||
}
|
||||
|
||||
latency = end_time - start_time
|
||||
|
||||
# Build the event properties
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
insights_privacy_mode,
|
||||
sanitize_openai_response(kwargs.get("input")),
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": insights_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(insights_properties or {}),
|
||||
}
|
||||
|
||||
if insights_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Send capture event for embeddings
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=insights_distinct_id or insights_trace_id,
|
||||
event="$ai_embedding",
|
||||
properties=event_properties,
|
||||
groups=insights_groups,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class WrappedBeta:
|
||||
"""Wrapper for OpenAI beta features that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_beta):
|
||||
self._client = client
|
||||
self._original = original_beta
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def chat(self):
|
||||
return WrappedBetaChat(self._client, self._original.chat)
|
||||
|
||||
|
||||
class WrappedBetaChat:
|
||||
"""Wrapper for OpenAI beta chat that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_beta_chat):
|
||||
self._client = client
|
||||
self._original = original_beta_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedBetaCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedBetaCompletions:
|
||||
"""Wrapper for OpenAI beta chat completions that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_beta_completions):
|
||||
self._client = client
|
||||
self._original = original_beta_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def parse(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
return call_llm_and_track_usage(
|
||||
insights_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
self._client.base_url,
|
||||
self._original.parse,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,658 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hanzo_insights.ai.types import TokenUsage
|
||||
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the OpenAI SDK to use this feature: 'pip install openai'"
|
||||
)
|
||||
|
||||
from hanzo_insights import setup
|
||||
from hanzo_insights.ai.utils import (
|
||||
call_llm_and_track_usage_async,
|
||||
extract_available_tool_calls,
|
||||
get_model_params,
|
||||
merge_usage_stats,
|
||||
with_privacy_mode,
|
||||
)
|
||||
from hanzo_insights.ai.openai.openai_converter import (
|
||||
extract_openai_usage_from_chunk,
|
||||
extract_openai_content_from_chunk,
|
||||
extract_openai_tool_calls_from_chunk,
|
||||
accumulate_openai_tool_calls,
|
||||
format_openai_streaming_output,
|
||||
)
|
||||
from hanzo_insights.ai.sanitization import sanitize_openai, sanitize_openai_response
|
||||
from hanzo_insights.client import Client as InsightsClient
|
||||
|
||||
|
||||
class AsyncOpenAI(openai.AsyncOpenAI):
|
||||
"""
|
||||
An async wrapper around the OpenAI SDK that automatically sends LLM usage events to Insights.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: OpenAI API key.
|
||||
insights_client: If provided, events will be captured via this client instead
|
||||
of the global hanzo_insights.
|
||||
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
|
||||
"""
|
||||
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = insights_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = WrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = WrappedBeta(self, self._original_beta)
|
||||
|
||||
if self._original_responses is not None:
|
||||
self.responses = WrappedResponses(self, self._original_responses)
|
||||
|
||||
|
||||
class WrappedResponses:
|
||||
"""Async wrapper for OpenAI responses that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_responses):
|
||||
self._client = client
|
||||
self._original = original_responses
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original responses object for any methods we don't explicitly handle."""
|
||||
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return await self._create_streaming(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return await call_llm_and_track_usage_async(
|
||||
insights_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
self._client.base_url,
|
||||
self._original.create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _create_streaming(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
final_content = []
|
||||
model_from_response: Optional[str] = None
|
||||
response = await self._original.create(**kwargs)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal final_content # noqa: F824
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
# Extract model from response object in chunk (for stored prompts)
|
||||
if hasattr(chunk, "response") and chunk.response:
|
||||
if model_from_response is None and hasattr(
|
||||
chunk.response, "model"
|
||||
):
|
||||
model_from_response = chunk.response.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
|
||||
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "responses")
|
||||
|
||||
if content is not None:
|
||||
final_content.append(content)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = final_content
|
||||
|
||||
await self._capture_streaming_event(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
return async_generator()
|
||||
|
||||
async def _capture_streaming_event(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": model,
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
insights_privacy_mode,
|
||||
sanitize_openai_response(kwargs.get("input")),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
insights_privacy_mode,
|
||||
format_openai_streaming_output(output, "responses"),
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
|
||||
"$ai_cache_read_input_tokens": usage_stats.get(
|
||||
"cache_read_input_tokens", 0
|
||||
),
|
||||
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": insights_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(insights_properties or {}),
|
||||
}
|
||||
|
||||
# Add web search count if present
|
||||
web_search_count = usage_stats.get("web_search_count")
|
||||
if (
|
||||
web_search_count is not None
|
||||
and isinstance(web_search_count, int)
|
||||
and web_search_count > 0
|
||||
):
|
||||
event_properties["$ai_web_search_count"] = web_search_count
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
if insights_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=insights_distinct_id or insights_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=insights_groups,
|
||||
)
|
||||
|
||||
async def parse(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in Insights.
|
||||
|
||||
Args:
|
||||
insights_distinct_id: Optional ID to associate with the usage event.
|
||||
insights_trace_id: Optional trace UUID for linking events.
|
||||
insights_properties: Optional dictionary of extra properties to include in the event.
|
||||
insights_privacy_mode: Whether to anonymize the input and output.
|
||||
insights_groups: Optional dictionary of groups to associate with the event.
|
||||
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's responses.parse call.
|
||||
"""
|
||||
return await call_llm_and_track_usage_async(
|
||||
insights_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
self._client.base_url,
|
||||
self._original.parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class WrappedChat:
|
||||
"""Async wrapper for OpenAI chat that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_chat):
|
||||
self._client = client
|
||||
self._original = original_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedCompletions:
|
||||
"""Async wrapper for OpenAI chat completions that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_completions):
|
||||
self._client = client
|
||||
self._original = original_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
# If streaming, handle streaming specifically
|
||||
if kwargs.get("stream", False):
|
||||
return await self._create_streaming(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
response = await call_llm_and_track_usage_async(
|
||||
insights_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
self._client.base_url,
|
||||
self._original.create,
|
||||
**kwargs,
|
||||
)
|
||||
return response
|
||||
|
||||
async def _create_streaming(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
accumulated_content = []
|
||||
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
|
||||
model_from_response: Optional[str] = None
|
||||
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
response = await self._original.create(**kwargs)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
nonlocal accumulated_tool_calls
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
# Extract model from chunk (Chat Completions chunks have model field)
|
||||
if model_from_response is None and hasattr(chunk, "model"):
|
||||
model_from_response = chunk.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "chat")
|
||||
if content is not None:
|
||||
accumulated_content.append(content)
|
||||
|
||||
# Extract and accumulate tool calls from chunk
|
||||
chunk_tool_calls = extract_openai_tool_calls_from_chunk(chunk)
|
||||
if chunk_tool_calls:
|
||||
accumulate_openai_tool_calls(
|
||||
accumulated_tool_calls, chunk_tool_calls
|
||||
)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
# Convert accumulated tool calls dict to list
|
||||
tool_calls_list = (
|
||||
list(accumulated_tool_calls.values())
|
||||
if accumulated_tool_calls
|
||||
else None
|
||||
)
|
||||
|
||||
await self._capture_streaming_event(
|
||||
insights_distinct_id,
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
accumulated_content,
|
||||
tool_calls_list,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
return async_generator()
|
||||
|
||||
async def _capture_streaming_event(
|
||||
self,
|
||||
insights_distinct_id: Optional[str],
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": model,
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
insights_privacy_mode,
|
||||
sanitize_openai(kwargs.get("messages")),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
insights_privacy_mode,
|
||||
format_openai_streaming_output(output, "chat", tool_calls),
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
|
||||
"$ai_cache_read_input_tokens": usage_stats.get(
|
||||
"cache_read_input_tokens", 0
|
||||
),
|
||||
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": insights_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(insights_properties or {}),
|
||||
}
|
||||
|
||||
# Add web search count if present
|
||||
web_search_count = usage_stats.get("web_search_count")
|
||||
|
||||
if (
|
||||
web_search_count is not None
|
||||
and isinstance(web_search_count, int)
|
||||
and web_search_count > 0
|
||||
):
|
||||
event_properties["$ai_web_search_count"] = web_search_count
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
if insights_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=insights_distinct_id or insights_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=insights_groups,
|
||||
)
|
||||
|
||||
|
||||
class WrappedEmbeddings:
|
||||
"""Async wrapper for OpenAI embeddings that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_embeddings):
|
||||
self._client = client
|
||||
self._original = original_embeddings
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original embeddings object for any methods we don't explicitly handle."""
|
||||
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in Insights.
|
||||
|
||||
Args:
|
||||
insights_distinct_id: Optional ID to associate with the usage event.
|
||||
insights_trace_id: Optional trace UUID for linking events.
|
||||
insights_properties: Optional dictionary of extra properties to include in the event.
|
||||
insights_privacy_mode: Whether to anonymize the input and output.
|
||||
insights_groups: Optional dictionary of groups to associate with the event.
|
||||
**kwargs: Any additional parameters for the OpenAI Embeddings API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's embeddings.create call.
|
||||
"""
|
||||
|
||||
if insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
start_time = time.time()
|
||||
response = await self._original.create(**kwargs)
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage_stats = TokenUsage(
|
||||
input_tokens=getattr(response.usage, "prompt_tokens", 0),
|
||||
output_tokens=getattr(response.usage, "completion_tokens", 0),
|
||||
)
|
||||
|
||||
latency = end_time - start_time
|
||||
|
||||
# Build the event properties
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
insights_privacy_mode,
|
||||
sanitize_openai_response(kwargs.get("input")),
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": insights_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(insights_properties or {}),
|
||||
}
|
||||
|
||||
if insights_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Send capture event for embeddings
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=insights_distinct_id or insights_trace_id,
|
||||
event="$ai_embedding",
|
||||
properties=event_properties,
|
||||
groups=insights_groups,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class WrappedBeta:
|
||||
"""Async wrapper for OpenAI beta features that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_beta):
|
||||
self._client = client
|
||||
self._original = original_beta
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta object for any methods we don't explicitly handle."""
|
||||
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def chat(self):
|
||||
return WrappedBetaChat(self._client, self._original.chat)
|
||||
|
||||
|
||||
class WrappedBetaChat:
|
||||
"""Async wrapper for OpenAI beta chat that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_beta_chat):
|
||||
self._client = client
|
||||
self._original = original_beta_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta chat object for any methods we don't explicitly handle."""
|
||||
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedBetaCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedBetaCompletions:
|
||||
"""Async wrapper for OpenAI beta chat completions that tracks usage in Insights."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_beta_completions):
|
||||
self._client = client
|
||||
self._original = original_beta_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta completions object for any methods we don't explicitly handle."""
|
||||
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def parse(
|
||||
self,
|
||||
insights_distinct_id: Optional[str] = None,
|
||||
insights_trace_id: Optional[str] = None,
|
||||
insights_properties: Optional[Dict[str, Any]] = None,
|
||||
insights_privacy_mode: bool = False,
|
||||
insights_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
return await call_llm_and_track_usage_async(
|
||||
insights_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
insights_trace_id,
|
||||
insights_properties,
|
||||
insights_privacy_mode,
|
||||
insights_groups,
|
||||
self._client.base_url,
|
||||
self._original.parse,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,760 @@
|
||||
"""
|
||||
OpenAI-specific conversion utilities.
|
||||
|
||||
This module handles the conversion of OpenAI API responses and inputs
|
||||
into standardized formats for Insights tracking. It supports both
|
||||
Chat Completions API and Responses API formats.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hanzo_insights.ai.types import (
|
||||
FormattedContentItem,
|
||||
FormattedFunctionCall,
|
||||
FormattedImageContent,
|
||||
FormattedMessage,
|
||||
FormattedTextContent,
|
||||
TokenUsage,
|
||||
)
|
||||
from hanzo_insights.ai.utils import serialize_raw_usage
|
||||
|
||||
|
||||
def format_openai_response(response: Any) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format an OpenAI response into standardized message format.
|
||||
|
||||
Handles both Chat Completions API and Responses API formats.
|
||||
|
||||
Args:
|
||||
response: The response object from OpenAI API
|
||||
|
||||
Returns:
|
||||
List of formatted messages with role and content
|
||||
"""
|
||||
|
||||
output: List[FormattedMessage] = []
|
||||
|
||||
if response is None:
|
||||
return output
|
||||
|
||||
# Handle Chat Completions response format
|
||||
if hasattr(response, "choices"):
|
||||
content: List[FormattedContentItem] = []
|
||||
role = "assistant"
|
||||
|
||||
for choice in response.choices:
|
||||
if hasattr(choice, "message") and choice.message:
|
||||
if choice.message.role:
|
||||
role = choice.message.role
|
||||
|
||||
if choice.message.content:
|
||||
content.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": choice.message.content,
|
||||
}
|
||||
)
|
||||
|
||||
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
|
||||
for tool_call in choice.message.tool_calls:
|
||||
content.append(
|
||||
{
|
||||
"type": "function",
|
||||
"id": tool_call.id,
|
||||
"function": {
|
||||
"name": tool_call.function.name,
|
||||
"arguments": tool_call.function.arguments,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Handle audio output (gpt-4o-audio-preview)
|
||||
if hasattr(choice.message, "audio") and choice.message.audio:
|
||||
# Convert Pydantic model to dict to capture all fields from OpenAI
|
||||
audio_dict = choice.message.audio.model_dump()
|
||||
content.append({"type": "audio", **audio_dict})
|
||||
|
||||
if content:
|
||||
output.append(
|
||||
{
|
||||
"role": role,
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
# Handle Responses API format
|
||||
if hasattr(response, "output"):
|
||||
content = []
|
||||
role = "assistant"
|
||||
|
||||
for item in response.output:
|
||||
if item.type == "message":
|
||||
role = item.role
|
||||
|
||||
if hasattr(item, "content") and isinstance(item.content, list):
|
||||
for content_item in item.content:
|
||||
if (
|
||||
hasattr(content_item, "type")
|
||||
and content_item.type == "output_text"
|
||||
and hasattr(content_item, "text")
|
||||
):
|
||||
content.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": content_item.text,
|
||||
}
|
||||
)
|
||||
|
||||
elif hasattr(content_item, "text"):
|
||||
content.append({"type": "text", "text": content_item.text})
|
||||
|
||||
elif (
|
||||
hasattr(content_item, "type")
|
||||
and content_item.type == "input_image"
|
||||
and hasattr(content_item, "image_url")
|
||||
):
|
||||
image_content: FormattedImageContent = {
|
||||
"type": "image",
|
||||
"image": content_item.image_url,
|
||||
}
|
||||
content.append(image_content)
|
||||
|
||||
elif hasattr(item, "content"):
|
||||
text_content = {"type": "text", "text": str(item.content)}
|
||||
content.append(text_content)
|
||||
|
||||
elif hasattr(item, "type") and item.type == "function_call":
|
||||
content.append(
|
||||
{
|
||||
"type": "function",
|
||||
"id": getattr(item, "call_id", getattr(item, "id", "")),
|
||||
"function": {
|
||||
"name": item.name,
|
||||
"arguments": getattr(item, "arguments", {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if content:
|
||||
output.append(
|
||||
{
|
||||
"role": role,
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def format_openai_input(
|
||||
messages: Optional[List[Dict[str, Any]]] = None, input_data: Optional[Any] = None
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format OpenAI input messages.
|
||||
|
||||
Handles both messages parameter (Chat Completions) and input parameter (Responses API).
|
||||
|
||||
Args:
|
||||
messages: List of message dictionaries for Chat Completions API
|
||||
input_data: Input data for Responses API
|
||||
|
||||
Returns:
|
||||
List of formatted messages
|
||||
"""
|
||||
|
||||
formatted_messages: List[FormattedMessage] = []
|
||||
|
||||
# Handle Chat Completions API format
|
||||
if messages is not None:
|
||||
for msg in messages:
|
||||
formatted_messages.append(
|
||||
{
|
||||
"role": msg.get("role", "user"),
|
||||
"content": msg.get("content", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Handle Responses API format
|
||||
if input_data is not None:
|
||||
if isinstance(input_data, list):
|
||||
for item in input_data:
|
||||
role = "user"
|
||||
content = ""
|
||||
|
||||
if isinstance(item, dict):
|
||||
role = item.get("role", "user")
|
||||
content = item.get("content", "")
|
||||
|
||||
elif isinstance(item, str):
|
||||
content = item
|
||||
|
||||
else:
|
||||
content = str(item)
|
||||
|
||||
formatted_messages.append({"role": role, "content": content})
|
||||
|
||||
elif isinstance(input_data, str):
|
||||
formatted_messages.append({"role": "user", "content": input_data})
|
||||
|
||||
else:
|
||||
formatted_messages.append({"role": "user", "content": str(input_data)})
|
||||
|
||||
return formatted_messages
|
||||
|
||||
|
||||
def extract_openai_tools(kwargs: Dict[str, Any]) -> Optional[Any]:
|
||||
"""
|
||||
Extract tool definitions from OpenAI API kwargs.
|
||||
|
||||
Args:
|
||||
kwargs: Keyword arguments passed to OpenAI API
|
||||
|
||||
Returns:
|
||||
Tool definitions if present, None otherwise
|
||||
"""
|
||||
|
||||
# Check for tools parameter (newer API)
|
||||
if "tools" in kwargs:
|
||||
return kwargs["tools"]
|
||||
|
||||
# Check for functions parameter (older API)
|
||||
if "functions" in kwargs:
|
||||
return kwargs["functions"]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_openai_streaming_content(
|
||||
accumulated_content: str, tool_calls: Optional[List[Dict[str, Any]]] = None
|
||||
) -> List[FormattedContentItem]:
|
||||
"""
|
||||
Format content from OpenAI streaming response.
|
||||
|
||||
Used by streaming handlers to format accumulated content.
|
||||
|
||||
Args:
|
||||
accumulated_content: Accumulated text content from streaming
|
||||
tool_calls: Optional list of tool calls accumulated during streaming
|
||||
|
||||
Returns:
|
||||
List of formatted content items
|
||||
"""
|
||||
formatted: List[FormattedContentItem] = []
|
||||
|
||||
# Add text content if present
|
||||
if accumulated_content:
|
||||
text_content: FormattedTextContent = {
|
||||
"type": "text",
|
||||
"text": accumulated_content,
|
||||
}
|
||||
formatted.append(text_content)
|
||||
|
||||
# Add tool calls if present
|
||||
if tool_calls:
|
||||
for tool_call in tool_calls:
|
||||
function_call: FormattedFunctionCall = {
|
||||
"type": "function",
|
||||
"id": tool_call.get("id"),
|
||||
"function": tool_call.get("function", {}),
|
||||
}
|
||||
formatted.append(function_call)
|
||||
|
||||
return formatted
|
||||
|
||||
|
||||
def extract_openai_web_search_count(response: Any) -> int:
|
||||
"""
|
||||
Extract web search count from OpenAI response.
|
||||
|
||||
Uses a two-tier detection strategy:
|
||||
1. Priority 1 (exact count): Check for output[].type == "web_search_call" (Responses API)
|
||||
2. Priority 2 (binary detection): Check for various web search indicators:
|
||||
- Root-level citations, search_results, or usage.search_context_size (Perplexity)
|
||||
- Annotations with type "url_citation" in choices/output (including delta for streaming)
|
||||
|
||||
Args:
|
||||
response: The response from OpenAI API
|
||||
|
||||
Returns:
|
||||
Number of web search requests (exact count or binary 1/0)
|
||||
"""
|
||||
|
||||
# Priority 1: Check for exact count in Responses API output
|
||||
if hasattr(response, "output"):
|
||||
web_search_count = 0
|
||||
|
||||
for item in response.output:
|
||||
if hasattr(item, "type") and item.type == "web_search_call":
|
||||
web_search_count += 1
|
||||
|
||||
web_search_count = max(0, web_search_count)
|
||||
|
||||
if web_search_count > 0:
|
||||
return web_search_count
|
||||
|
||||
# Priority 2: Binary detection (returns 1 or 0)
|
||||
|
||||
# Check root-level indicators (Perplexity)
|
||||
if hasattr(response, "citations"):
|
||||
citations = getattr(response, "citations")
|
||||
|
||||
if citations and len(citations) > 0:
|
||||
return 1
|
||||
|
||||
if hasattr(response, "search_results"):
|
||||
search_results = getattr(response, "search_results")
|
||||
|
||||
if search_results and len(search_results) > 0:
|
||||
return 1
|
||||
|
||||
if hasattr(response, "usage") and hasattr(response.usage, "search_context_size"):
|
||||
if response.usage.search_context_size:
|
||||
return 1
|
||||
|
||||
# Check for url_citation annotations in choices (Chat Completions)
|
||||
if hasattr(response, "choices"):
|
||||
for choice in response.choices:
|
||||
# Check message.annotations (non-streaming or final chunk)
|
||||
if hasattr(choice, "message") and hasattr(choice.message, "annotations"):
|
||||
annotations = choice.message.annotations
|
||||
|
||||
if annotations:
|
||||
for annotation in annotations:
|
||||
# Support both dict and object formats
|
||||
annotation_type = (
|
||||
annotation.get("type")
|
||||
if isinstance(annotation, dict)
|
||||
else getattr(annotation, "type", None)
|
||||
)
|
||||
|
||||
if annotation_type == "url_citation":
|
||||
return 1
|
||||
|
||||
# Check delta.annotations (streaming chunks)
|
||||
if hasattr(choice, "delta") and hasattr(choice.delta, "annotations"):
|
||||
annotations = choice.delta.annotations
|
||||
|
||||
if annotations:
|
||||
for annotation in annotations:
|
||||
# Support both dict and object formats
|
||||
annotation_type = (
|
||||
annotation.get("type")
|
||||
if isinstance(annotation, dict)
|
||||
else getattr(annotation, "type", None)
|
||||
)
|
||||
|
||||
if annotation_type == "url_citation":
|
||||
return 1
|
||||
|
||||
# Check for url_citation annotations in output (Responses API)
|
||||
if hasattr(response, "output"):
|
||||
for item in response.output:
|
||||
if hasattr(item, "content") and isinstance(item.content, list):
|
||||
for content_item in item.content:
|
||||
if hasattr(content_item, "annotations"):
|
||||
annotations = content_item.annotations
|
||||
|
||||
if annotations:
|
||||
for annotation in annotations:
|
||||
# Support both dict and object formats
|
||||
annotation_type = (
|
||||
annotation.get("type")
|
||||
if isinstance(annotation, dict)
|
||||
else getattr(annotation, "type", None)
|
||||
)
|
||||
|
||||
if annotation_type == "url_citation":
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def extract_openai_usage_from_response(response: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from a full OpenAI response (non-streaming).
|
||||
Handles both Chat Completions and Responses API.
|
||||
|
||||
Args:
|
||||
response: The complete response from OpenAI API
|
||||
|
||||
Returns:
|
||||
TokenUsage with standardized usage statistics
|
||||
"""
|
||||
if not hasattr(response, "usage"):
|
||||
return TokenUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
cached_tokens = 0
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
reasoning_tokens = 0
|
||||
|
||||
# Responses API format
|
||||
if hasattr(response.usage, "input_tokens"):
|
||||
input_tokens = response.usage.input_tokens
|
||||
if hasattr(response.usage, "output_tokens"):
|
||||
output_tokens = response.usage.output_tokens
|
||||
if hasattr(response.usage, "input_tokens_details") and hasattr(
|
||||
response.usage.input_tokens_details, "cached_tokens"
|
||||
):
|
||||
cached_tokens = response.usage.input_tokens_details.cached_tokens
|
||||
if hasattr(response.usage, "output_tokens_details") and hasattr(
|
||||
response.usage.output_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
reasoning_tokens = response.usage.output_tokens_details.reasoning_tokens
|
||||
|
||||
# Chat Completions format
|
||||
if hasattr(response.usage, "prompt_tokens"):
|
||||
input_tokens = response.usage.prompt_tokens
|
||||
if hasattr(response.usage, "completion_tokens"):
|
||||
output_tokens = response.usage.completion_tokens
|
||||
if hasattr(response.usage, "prompt_tokens_details") and hasattr(
|
||||
response.usage.prompt_tokens_details, "cached_tokens"
|
||||
):
|
||||
cached_tokens = response.usage.prompt_tokens_details.cached_tokens
|
||||
if hasattr(response.usage, "completion_tokens_details") and hasattr(
|
||||
response.usage.completion_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
reasoning_tokens = response.usage.completion_tokens_details.reasoning_tokens
|
||||
|
||||
result = TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
if cached_tokens > 0:
|
||||
result["cache_read_input_tokens"] = cached_tokens
|
||||
if reasoning_tokens > 0:
|
||||
result["reasoning_tokens"] = reasoning_tokens
|
||||
|
||||
web_search_count = extract_openai_web_search_count(response)
|
||||
if web_search_count > 0:
|
||||
result["web_search_count"] = web_search_count
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def extract_openai_usage_from_chunk(
|
||||
chunk: Any, provider_type: str = "chat"
|
||||
) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from an OpenAI streaming chunk.
|
||||
|
||||
Handles both Chat Completions and Responses API formats.
|
||||
|
||||
Args:
|
||||
chunk: Streaming chunk from OpenAI API
|
||||
provider_type: Either "chat" or "responses" to handle different API formats
|
||||
|
||||
Returns:
|
||||
Dictionary of usage statistics
|
||||
"""
|
||||
|
||||
usage: TokenUsage = TokenUsage()
|
||||
|
||||
if provider_type == "chat":
|
||||
# Extract web search count from the chunk before checking for usage
|
||||
# Web search indicators (citations, annotations) can appear on any chunk,
|
||||
# not just those with usage data
|
||||
web_search_count = extract_openai_web_search_count(chunk)
|
||||
if web_search_count > 0:
|
||||
usage["web_search_count"] = web_search_count
|
||||
|
||||
if not hasattr(chunk, "usage") or not chunk.usage:
|
||||
return usage
|
||||
|
||||
# Chat Completions API uses prompt_tokens and completion_tokens
|
||||
# Standardize to input_tokens and output_tokens
|
||||
usage["input_tokens"] = getattr(chunk.usage, "prompt_tokens", 0)
|
||||
usage["output_tokens"] = getattr(chunk.usage, "completion_tokens", 0)
|
||||
|
||||
# Handle cached tokens
|
||||
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
|
||||
chunk.usage.prompt_tokens_details, "cached_tokens"
|
||||
):
|
||||
usage["cache_read_input_tokens"] = (
|
||||
chunk.usage.prompt_tokens_details.cached_tokens
|
||||
)
|
||||
|
||||
# Handle reasoning tokens
|
||||
if hasattr(chunk.usage, "completion_tokens_details") and hasattr(
|
||||
chunk.usage.completion_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
usage["reasoning_tokens"] = (
|
||||
chunk.usage.completion_tokens_details.reasoning_tokens
|
||||
)
|
||||
|
||||
# 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":
|
||||
if (
|
||||
hasattr(chunk, "response")
|
||||
and hasattr(chunk.response, "usage")
|
||||
and chunk.response.usage
|
||||
):
|
||||
response_usage = chunk.response.usage
|
||||
usage["input_tokens"] = getattr(response_usage, "input_tokens", 0)
|
||||
usage["output_tokens"] = getattr(response_usage, "output_tokens", 0)
|
||||
|
||||
# Handle cached tokens
|
||||
if hasattr(response_usage, "input_tokens_details") and hasattr(
|
||||
response_usage.input_tokens_details, "cached_tokens"
|
||||
):
|
||||
usage["cache_read_input_tokens"] = (
|
||||
response_usage.input_tokens_details.cached_tokens
|
||||
)
|
||||
|
||||
# Handle reasoning tokens
|
||||
if hasattr(response_usage, "output_tokens_details") and hasattr(
|
||||
response_usage.output_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
usage["reasoning_tokens"] = (
|
||||
response_usage.output_tokens_details.reasoning_tokens
|
||||
)
|
||||
|
||||
# Extract web search count from the complete response
|
||||
if hasattr(chunk, "response"):
|
||||
web_search_count = extract_openai_web_search_count(chunk.response)
|
||||
if web_search_count > 0:
|
||||
usage["web_search_count"] = web_search_count
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def extract_openai_content_from_chunk(
|
||||
chunk: Any, provider_type: str = "chat"
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Extract content from an OpenAI streaming chunk.
|
||||
|
||||
Handles both Chat Completions and Responses API formats.
|
||||
|
||||
Args:
|
||||
chunk: Streaming chunk from OpenAI API
|
||||
provider_type: Either "chat" or "responses" to handle different API formats
|
||||
|
||||
Returns:
|
||||
Text content if present, None otherwise
|
||||
"""
|
||||
|
||||
if provider_type == "chat":
|
||||
# Chat Completions API format
|
||||
if (
|
||||
hasattr(chunk, "choices")
|
||||
and chunk.choices
|
||||
and len(chunk.choices) > 0
|
||||
and chunk.choices[0].delta
|
||||
and chunk.choices[0].delta.content
|
||||
):
|
||||
return chunk.choices[0].delta.content
|
||||
|
||||
elif provider_type == "responses":
|
||||
# Responses API format
|
||||
if hasattr(chunk, "type") and chunk.type == "response.completed":
|
||||
if hasattr(chunk, "response") and chunk.response:
|
||||
res = chunk.response
|
||||
if res.output and len(res.output) > 0:
|
||||
# Return the full output for responses
|
||||
return res.output[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_openai_tool_calls_from_chunk(chunk: Any) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Extract tool calls from an OpenAI streaming chunk.
|
||||
|
||||
Args:
|
||||
chunk: Streaming chunk from OpenAI API
|
||||
|
||||
Returns:
|
||||
List of tool call deltas if present, None otherwise
|
||||
"""
|
||||
if (
|
||||
hasattr(chunk, "choices")
|
||||
and chunk.choices
|
||||
and len(chunk.choices) > 0
|
||||
and chunk.choices[0].delta
|
||||
and hasattr(chunk.choices[0].delta, "tool_calls")
|
||||
and chunk.choices[0].delta.tool_calls
|
||||
):
|
||||
tool_calls = []
|
||||
for tool_call in chunk.choices[0].delta.tool_calls:
|
||||
tc_dict = {
|
||||
"index": getattr(tool_call, "index", None),
|
||||
}
|
||||
|
||||
if hasattr(tool_call, "id") and tool_call.id:
|
||||
tc_dict["id"] = tool_call.id
|
||||
|
||||
if hasattr(tool_call, "type") and tool_call.type:
|
||||
tc_dict["type"] = tool_call.type
|
||||
|
||||
if hasattr(tool_call, "function") and tool_call.function:
|
||||
function_dict = {}
|
||||
if hasattr(tool_call.function, "name") and tool_call.function.name:
|
||||
function_dict["name"] = tool_call.function.name
|
||||
if (
|
||||
hasattr(tool_call.function, "arguments")
|
||||
and tool_call.function.arguments
|
||||
):
|
||||
function_dict["arguments"] = tool_call.function.arguments
|
||||
tc_dict["function"] = function_dict
|
||||
|
||||
tool_calls.append(tc_dict)
|
||||
return tool_calls
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def accumulate_openai_tool_calls(
|
||||
accumulated_tool_calls: Dict[int, Dict[str, Any]],
|
||||
chunk_tool_calls: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Accumulate tool calls from streaming chunks.
|
||||
|
||||
OpenAI sends tool calls incrementally:
|
||||
- First chunk has id, type, function.name and partial function.arguments
|
||||
- Subsequent chunks have more function.arguments
|
||||
|
||||
Args:
|
||||
accumulated_tool_calls: Dictionary mapping index to accumulated tool call data
|
||||
chunk_tool_calls: List of tool call deltas from current chunk
|
||||
"""
|
||||
for tool_call_delta in chunk_tool_calls:
|
||||
index = tool_call_delta.get("index")
|
||||
if index is None:
|
||||
continue
|
||||
|
||||
# Initialize tool call if first time seeing this index
|
||||
if index not in accumulated_tool_calls:
|
||||
accumulated_tool_calls[index] = {
|
||||
"id": "",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
},
|
||||
}
|
||||
|
||||
# Update with new data from delta
|
||||
tc = accumulated_tool_calls[index]
|
||||
|
||||
if "id" in tool_call_delta and tool_call_delta["id"]:
|
||||
tc["id"] = tool_call_delta["id"]
|
||||
|
||||
if "type" in tool_call_delta and tool_call_delta["type"]:
|
||||
tc["type"] = tool_call_delta["type"]
|
||||
|
||||
if "function" in tool_call_delta:
|
||||
func_delta = tool_call_delta["function"]
|
||||
if "name" in func_delta and func_delta["name"]:
|
||||
tc["function"]["name"] = func_delta["name"]
|
||||
if "arguments" in func_delta and func_delta["arguments"]:
|
||||
# Arguments are sent incrementally, concatenate them
|
||||
tc["function"]["arguments"] += func_delta["arguments"]
|
||||
|
||||
|
||||
def format_openai_streaming_output(
|
||||
accumulated_content: Any,
|
||||
provider_type: str = "chat",
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format the final output from OpenAI streaming.
|
||||
|
||||
Args:
|
||||
accumulated_content: Accumulated content from streaming (string for chat, list for responses)
|
||||
provider_type: Either "chat" or "responses" to handle different API formats
|
||||
tool_calls: Optional list of accumulated tool calls
|
||||
|
||||
Returns:
|
||||
List of formatted messages
|
||||
"""
|
||||
|
||||
if provider_type == "chat":
|
||||
content_items: List[FormattedContentItem] = []
|
||||
|
||||
# Add text content if present
|
||||
if isinstance(accumulated_content, str) and accumulated_content:
|
||||
content_items.append({"type": "text", "text": accumulated_content})
|
||||
elif isinstance(accumulated_content, list):
|
||||
# If it's a list of strings, join them
|
||||
text = "".join(str(item) for item in accumulated_content if item)
|
||||
if text:
|
||||
content_items.append({"type": "text", "text": text})
|
||||
|
||||
# Add tool calls if present
|
||||
if tool_calls:
|
||||
for tool_call in tool_calls:
|
||||
if "function" in tool_call:
|
||||
function_call: FormattedFunctionCall = {
|
||||
"type": "function",
|
||||
"id": tool_call.get("id", ""),
|
||||
"function": tool_call["function"],
|
||||
}
|
||||
content_items.append(function_call)
|
||||
|
||||
# Return formatted message with content
|
||||
if content_items:
|
||||
return [{"role": "assistant", "content": content_items}]
|
||||
else:
|
||||
# Empty response
|
||||
return [{"role": "assistant", "content": []}]
|
||||
|
||||
elif provider_type == "responses":
|
||||
# Responses API: accumulated_content is a list of output items
|
||||
if isinstance(accumulated_content, list) and accumulated_content:
|
||||
# The output is already formatted, just return it
|
||||
return accumulated_content
|
||||
elif isinstance(accumulated_content, str):
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": accumulated_content}],
|
||||
}
|
||||
]
|
||||
|
||||
# Fallback for any other format
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": str(accumulated_content)}],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def format_openai_streaming_input(
|
||||
kwargs: Dict[str, Any], api_type: str = "chat"
|
||||
) -> Any:
|
||||
"""
|
||||
Format OpenAI streaming input based on API type.
|
||||
|
||||
Args:
|
||||
kwargs: Keyword arguments passed to OpenAI API
|
||||
api_type: Either "chat" or "responses"
|
||||
|
||||
Returns:
|
||||
Formatted input ready for Insights tracking
|
||||
"""
|
||||
from hanzo_insights.ai.utils import merge_system_prompt
|
||||
|
||||
return merge_system_prompt(kwargs, "openai")
|
||||
@@ -0,0 +1,98 @@
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Open AI SDK to use this feature: 'pip install openai'"
|
||||
)
|
||||
|
||||
from hanzo_insights.ai.openai.openai import (
|
||||
WrappedBeta,
|
||||
WrappedChat,
|
||||
WrappedEmbeddings,
|
||||
WrappedResponses,
|
||||
)
|
||||
from hanzo_insights.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta
|
||||
from hanzo_insights.ai.openai.openai_async import WrappedChat as AsyncWrappedChat
|
||||
from hanzo_insights.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings
|
||||
from hanzo_insights.ai.openai.openai_async import WrappedResponses as AsyncWrappedResponses
|
||||
from typing import Optional
|
||||
|
||||
from hanzo_insights.client import Client as InsightsClient
|
||||
from hanzo_insights import setup
|
||||
|
||||
|
||||
class AzureOpenAI(openai.AzureOpenAI):
|
||||
"""
|
||||
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to Insights.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: Azure OpenAI API key.
|
||||
insights_client: If provided, events will be captured via this client instead
|
||||
of the global hanzo_insights.
|
||||
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = insights_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = WrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = WrappedBeta(self, self._original_beta)
|
||||
|
||||
if self._original_responses is not None:
|
||||
self.responses = WrappedResponses(self, self._original_responses)
|
||||
|
||||
|
||||
class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
|
||||
"""
|
||||
An async wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to Insights.
|
||||
"""
|
||||
|
||||
_ph_client: InsightsClient
|
||||
|
||||
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: Azure OpenAI API key.
|
||||
insights_client: If provided, events will be captured via this client instead
|
||||
of the global hanzo_insights.
|
||||
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = insights_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = AsyncWrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = AsyncWrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = AsyncWrappedBeta(self, self._original_beta)
|
||||
|
||||
# Only add responses if available (newer OpenAI versions)
|
||||
if self._original_responses is not None:
|
||||
self.responses = AsyncWrappedResponses(self, self._original_responses)
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents.tracing import Trace
|
||||
|
||||
from hanzo_insights.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 hanzo_insights.ai.openai_agents.processor import InsightsTracingProcessor
|
||||
|
||||
__all__ = ["InsightsTracingProcessor", "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,
|
||||
) -> InsightsTracingProcessor:
|
||||
"""
|
||||
One-liner to instrument OpenAI Agents SDK with Hanzo Insights tracing.
|
||||
|
||||
This registers an InsightsTracingProcessor with the OpenAI Agents SDK,
|
||||
automatically capturing traces, spans, and LLM generations.
|
||||
|
||||
Args:
|
||||
client: Optional Insights 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 Insights groups to associate with events.
|
||||
properties: Optional additional properties to include with all events.
|
||||
|
||||
Returns:
|
||||
InsightsTracingProcessor: The registered processor instance.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from hanzo_insights.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 Insights
|
||||
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 = InsightsTracingProcessor(
|
||||
client=client,
|
||||
distinct_id=distinct_id,
|
||||
privacy_mode=privacy_mode,
|
||||
groups=groups,
|
||||
properties=properties,
|
||||
)
|
||||
add_trace_processor(processor)
|
||||
return processor
|
||||
@@ -0,0 +1,863 @@
|
||||
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 hanzo_insights import setup
|
||||
from hanzo_insights.client import Client
|
||||
|
||||
log = logging.getLogger("hanzo_insights")
|
||||
|
||||
|
||||
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 InsightsTracingProcessor(TracingProcessor):
|
||||
"""
|
||||
A tracing processor that sends OpenAI Agents SDK traces to Hanzo Insights.
|
||||
|
||||
This processor implements the TracingProcessor interface from the OpenAI Agents SDK
|
||||
and maps agent traces, spans, and generations to Insights LLM analytics events.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from agents import Agent, Runner
|
||||
from agents.tracing import add_trace_processor
|
||||
from hanzo_insights.ai.openai_agents import InsightsTracingProcessor
|
||||
|
||||
# Create and register the processor
|
||||
processor = InsightsTracingProcessor(
|
||||
distinct_id="user@example.com",
|
||||
privacy_mode=False,
|
||||
)
|
||||
add_trace_processor(processor)
|
||||
|
||||
# Run agents as normal - traces automatically sent to Insights
|
||||
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 Insights tracing processor.
|
||||
|
||||
Args:
|
||||
client: Optional Insights 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 Insights 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 Insights 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 Insights 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 Insights 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}")
|
||||
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Prompt management for Hanzo Insights AI SDK.
|
||||
|
||||
Fetch and compile LLM prompts from Insights with caching and fallback support.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from hanzo_insights.request import USER_AGENT, _get_session
|
||||
from hanzo_insights.utils import remove_trailing_slash
|
||||
|
||||
log = logging.getLogger("hanzo_insights")
|
||||
|
||||
APP_ENDPOINT = "https://us.insights.hanzo.ai"
|
||||
DEFAULT_CACHE_TTL_SECONDS = 300 # 5 minutes
|
||||
|
||||
PromptVariables = Dict[str, Union[str, int, float, bool]]
|
||||
PromptCacheKey = tuple[str, Optional[int]]
|
||||
|
||||
|
||||
class CachedPrompt:
|
||||
"""Cached prompt with metadata."""
|
||||
|
||||
def __init__(self, prompt: str, fetched_at: float):
|
||||
self.prompt = prompt
|
||||
self.fetched_at = fetched_at
|
||||
|
||||
|
||||
def _cache_key(name: str, version: Optional[int]) -> PromptCacheKey:
|
||||
"""Build a cache key for latest or versioned prompt fetches."""
|
||||
return (name, version)
|
||||
|
||||
|
||||
def _prompt_reference(name: str, version: Optional[int]) -> str:
|
||||
"""Format a prompt reference for logs and errors."""
|
||||
label = f'prompt "{name}"'
|
||||
if version is not None:
|
||||
return f"{label} version {version}"
|
||||
return label
|
||||
|
||||
|
||||
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 Insights.
|
||||
|
||||
Can be initialized with a Insights client or with direct options.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from hanzo_insights import Insights
|
||||
from hanzo_insights.ai.prompts import Prompts
|
||||
|
||||
# With Insights client
|
||||
client = Insights('phc_xxx', host='https://us.insights.hanzo.ai', personal_api_key='phx_xxx')
|
||||
prompts = Prompts(client)
|
||||
|
||||
# Or with direct options (no Insights client needed)
|
||||
prompts = Prompts(
|
||||
personal_api_key='phx_xxx',
|
||||
project_api_key='phc_xxx',
|
||||
host='https://us.insights.hanzo.ai',
|
||||
)
|
||||
|
||||
# Fetch with caching and fallback
|
||||
template = prompts.get('support-system-prompt', fallback='You are a helpful assistant.')
|
||||
|
||||
# Fetch a specific published version
|
||||
prompt_v1 = prompts.get('support-system-prompt', version=1)
|
||||
|
||||
# Compile with variables
|
||||
system_prompt = prompts.compile(template, {
|
||||
'company': 'Acme Corp',
|
||||
'tier': 'premium',
|
||||
})
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: 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:
|
||||
client: Insights client instance (optional if personal_api_key provided)
|
||||
personal_api_key: Direct personal API key (optional if client provided)
|
||||
project_api_key: Direct project API key (optional if client provided)
|
||||
host: Insights 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[PromptCacheKey, CachedPrompt] = {}
|
||||
|
||||
if client is not None:
|
||||
self._personal_api_key = getattr(client, "personal_api_key", None) or ""
|
||||
self._project_api_key = getattr(client, "api_key", None) or ""
|
||||
self._host = remove_trailing_slash(
|
||||
getattr(client, "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,
|
||||
version: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Fetch a prompt by name from the Insights 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
|
||||
version: Specific prompt version to fetch. If None, fetches the latest
|
||||
version
|
||||
|
||||
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
|
||||
)
|
||||
cache_key = _cache_key(name, version)
|
||||
|
||||
# Check cache first
|
||||
cached = self._cache.get(cache_key)
|
||||
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, version)
|
||||
fetched_at = time.time()
|
||||
|
||||
# Update cache
|
||||
self._cache[cache_key] = CachedPrompt(prompt=prompt, fetched_at=fetched_at)
|
||||
|
||||
return prompt
|
||||
|
||||
except Exception as error:
|
||||
prompt_reference = _prompt_reference(name, version)
|
||||
# Fallback order:
|
||||
# 1. Return stale cache (with warning)
|
||||
if cached is not None:
|
||||
log.warning(
|
||||
"[Insights Prompts] Failed to fetch %s, using stale cache: %s",
|
||||
prompt_reference,
|
||||
error,
|
||||
)
|
||||
return cached.prompt
|
||||
|
||||
# 2. Return fallback (with warning)
|
||||
if fallback is not None:
|
||||
log.warning(
|
||||
"[Insights Prompts] Failed to fetch %s, using fallback: %s",
|
||||
prompt_reference,
|
||||
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, *, version: Optional[int] = None
|
||||
) -> None:
|
||||
"""
|
||||
Clear cached prompts.
|
||||
|
||||
Args:
|
||||
name: Specific prompt name to clear. If None, clears all cached prompts.
|
||||
version: Specific prompt version to clear. Requires name.
|
||||
"""
|
||||
if version is not None and name is None:
|
||||
raise ValueError("'version' requires 'name' to be provided")
|
||||
|
||||
if name is None:
|
||||
self._cache.clear()
|
||||
return
|
||||
|
||||
if version is not None:
|
||||
self._cache.pop(_cache_key(name, version), None)
|
||||
return
|
||||
|
||||
keys_to_clear = [key for key in self._cache if key[0] == name]
|
||||
for key in keys_to_clear:
|
||||
self._cache.pop(key, None)
|
||||
|
||||
def _fetch_prompt_from_api(self, name: str, version: Optional[int] = None) -> str:
|
||||
"""
|
||||
Fetch prompt from Insights API.
|
||||
|
||||
Endpoint:
|
||||
{host}/api/environments/@current/llm_prompts/name/{encoded_name}/
|
||||
?token={encoded_project_api_key}[&version={version}]
|
||||
Auth: Bearer {personal_api_key}
|
||||
|
||||
Args:
|
||||
name: The name of the prompt to fetch
|
||||
version: Specific prompt version to fetch. If None, fetches the latest
|
||||
|
||||
Returns:
|
||||
The prompt string
|
||||
|
||||
Raises:
|
||||
Exception: If the prompt cannot be fetched
|
||||
"""
|
||||
if not self._personal_api_key:
|
||||
raise Exception(
|
||||
"[Insights 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(
|
||||
"[Insights Prompts] project_api_key is required to fetch prompts. "
|
||||
"Please provide it when initializing the Prompts instance."
|
||||
)
|
||||
|
||||
encoded_name = urllib.parse.quote(name, safe="")
|
||||
query_params: Dict[str, Union[str, int]] = {"token": self._project_api_key}
|
||||
if version is not None:
|
||||
query_params["version"] = version
|
||||
encoded_query = urllib.parse.urlencode(query_params)
|
||||
url = f"{self._host}/api/environments/@current/llm_prompts/name/{encoded_name}/?{encoded_query}"
|
||||
prompt_reference = _prompt_reference(name, version)
|
||||
prompt_label = prompt_reference[:1].upper() + prompt_reference[1:]
|
||||
|
||||
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"[Insights Prompts] {prompt_label} not found")
|
||||
|
||||
if response.status_code == 403:
|
||||
raise Exception(
|
||||
f"[Insights Prompts] Access denied for {prompt_reference}. "
|
||||
"Check that your personal_api_key has the correct permissions and the LLM prompts feature is enabled."
|
||||
)
|
||||
|
||||
raise Exception(
|
||||
f"[Insights Prompts] Failed to fetch {prompt_label}: HTTP {response.status_code}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
raise Exception(
|
||||
f"[Insights Prompts] Invalid response format for {prompt_label}"
|
||||
)
|
||||
|
||||
if not _is_prompt_api_response(data):
|
||||
raise Exception(
|
||||
f"[Insights Prompts] Invalid response format for {prompt_label}"
|
||||
)
|
||||
|
||||
return data["prompt"]
|
||||
@@ -0,0 +1,254 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
REDACTED_IMAGE_PLACEHOLDER = "[base64 image redacted]"
|
||||
|
||||
|
||||
def _is_multimodal_enabled() -> bool:
|
||||
"""Check if multimodal capture is enabled via environment variable."""
|
||||
return os.environ.get("_INTERNAL_LLMA_MULTIMODAL", "").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def is_base64_data_url(text: str) -> bool:
|
||||
return re.match(r"^data:([^;]+);base64,", text) is not None
|
||||
|
||||
|
||||
def is_valid_url(text: str) -> bool:
|
||||
try:
|
||||
result = urlparse(text)
|
||||
return bool(result.scheme and result.netloc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return text.startswith(("/", "./", "../"))
|
||||
|
||||
|
||||
def is_raw_base64(text: str) -> bool:
|
||||
if is_valid_url(text):
|
||||
return False
|
||||
|
||||
return len(text) > 20 and re.match(r"^[A-Za-z0-9+/]+=*$", text) is not None
|
||||
|
||||
|
||||
def redact_base64_data_url(value: Any) -> Any:
|
||||
if _is_multimodal_enabled():
|
||||
return value
|
||||
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
if is_base64_data_url(value):
|
||||
return REDACTED_IMAGE_PLACEHOLDER
|
||||
|
||||
if is_raw_base64(value):
|
||||
return REDACTED_IMAGE_PLACEHOLDER
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def process_messages(messages: Any, transform_content_func) -> Any:
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
def process_content(content: Any) -> Any:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
if not content:
|
||||
return content
|
||||
|
||||
if isinstance(content, list):
|
||||
return [transform_content_func(item) for item in content]
|
||||
|
||||
return transform_content_func(content)
|
||||
|
||||
def process_message(msg: Any) -> Any:
|
||||
if not isinstance(msg, dict) or "content" not in msg:
|
||||
return msg
|
||||
return {**msg, "content": process_content(msg["content"])}
|
||||
|
||||
if isinstance(messages, list):
|
||||
return [process_message(msg) for msg in messages]
|
||||
|
||||
return process_message(messages)
|
||||
|
||||
|
||||
def sanitize_openai_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if item.get("type") == "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)
|
||||
and "url" in item["image_url"]
|
||||
):
|
||||
return {
|
||||
**item,
|
||||
"image_url": {
|
||||
**item["image_url"],
|
||||
"url": redact_base64_data_url(item["image_url"]["url"]),
|
||||
},
|
||||
}
|
||||
|
||||
if item.get("type") == "audio" and "data" in item:
|
||||
if _is_multimodal_enabled():
|
||||
return item
|
||||
return {**item, "data": REDACTED_IMAGE_PLACEHOLDER}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_openai_response_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if item.get("type") == "input_image" and "image_url" in item:
|
||||
return {
|
||||
**item,
|
||||
"image_url": redact_base64_data_url(item["image_url"]),
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_anthropic_image(item: Any) -> Any:
|
||||
if _is_multimodal_enabled():
|
||||
return item
|
||||
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if (
|
||||
item.get("type") == "image"
|
||||
and isinstance(item.get("source"), dict)
|
||||
and item["source"].get("type") == "base64"
|
||||
and "data" in item["source"]
|
||||
):
|
||||
return {
|
||||
**item,
|
||||
"source": {
|
||||
**item["source"],
|
||||
"data": REDACTED_IMAGE_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_gemini_part(part: Any) -> Any:
|
||||
if _is_multimodal_enabled():
|
||||
return part
|
||||
|
||||
if not isinstance(part, dict):
|
||||
return part
|
||||
|
||||
if (
|
||||
"inline_data" in part
|
||||
and isinstance(part["inline_data"], dict)
|
||||
and "data" in part["inline_data"]
|
||||
):
|
||||
return {
|
||||
**part,
|
||||
"inline_data": {
|
||||
**part["inline_data"],
|
||||
"data": REDACTED_IMAGE_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
|
||||
return part
|
||||
|
||||
|
||||
def process_gemini_item(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if "parts" in item and item["parts"]:
|
||||
parts = item["parts"]
|
||||
if isinstance(parts, list):
|
||||
parts = [sanitize_gemini_part(part) for part in parts]
|
||||
else:
|
||||
parts = sanitize_gemini_part(parts)
|
||||
|
||||
return {**item, "parts": parts}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_langchain_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if (
|
||||
item.get("type") == "image_url"
|
||||
and isinstance(item.get("image_url"), dict)
|
||||
and "url" in item["image_url"]
|
||||
):
|
||||
return {
|
||||
**item,
|
||||
"image_url": {
|
||||
**item["image_url"],
|
||||
"url": redact_base64_data_url(item["image_url"]["url"]),
|
||||
},
|
||||
}
|
||||
|
||||
if item.get("type") == "image" and "data" in item:
|
||||
return {**item, "data": redact_base64_data_url(item["data"])}
|
||||
|
||||
if (
|
||||
item.get("type") == "image"
|
||||
and isinstance(item.get("source"), dict)
|
||||
and "data" in item["source"]
|
||||
):
|
||||
if _is_multimodal_enabled():
|
||||
return item
|
||||
|
||||
return {
|
||||
**item,
|
||||
"source": {
|
||||
**item["source"],
|
||||
"data": REDACTED_IMAGE_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
|
||||
if item.get("type") == "media" and "data" in item:
|
||||
return {**item, "data": redact_base64_data_url(item["data"])}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_openai(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_openai_image)
|
||||
|
||||
|
||||
def sanitize_openai_response(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_openai_response_image)
|
||||
|
||||
|
||||
def sanitize_anthropic(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_anthropic_image)
|
||||
|
||||
|
||||
def sanitize_gemini(data: Any) -> Any:
|
||||
if not data:
|
||||
return data
|
||||
|
||||
if isinstance(data, list):
|
||||
return [process_gemini_item(item) for item in data]
|
||||
|
||||
return process_gemini_item(data)
|
||||
|
||||
|
||||
def sanitize_langchain(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_langchain_image)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Common type definitions for Insights AI SDK.
|
||||
|
||||
These types are used for formatting messages and responses across different AI providers
|
||||
(Anthropic, OpenAI, Gemini, etc.) to ensure consistency in tracking and data structure.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union
|
||||
|
||||
|
||||
class FormattedTextContent(TypedDict):
|
||||
"""Formatted text content item."""
|
||||
|
||||
type: str # Literal["text"]
|
||||
text: str
|
||||
|
||||
|
||||
class FormattedFunctionCall(TypedDict, total=False):
|
||||
"""Formatted function/tool call content item."""
|
||||
|
||||
type: str # Literal["function"]
|
||||
id: Optional[str]
|
||||
function: Dict[str, Any] # Contains 'name' and 'arguments'
|
||||
|
||||
|
||||
class FormattedImageContent(TypedDict):
|
||||
"""Formatted image content item."""
|
||||
|
||||
type: str # Literal["image"]
|
||||
image: str
|
||||
|
||||
|
||||
# Union type for all formatted content items
|
||||
FormattedContentItem = Union[
|
||||
FormattedTextContent,
|
||||
FormattedFunctionCall,
|
||||
FormattedImageContent,
|
||||
Dict[str, Any], # Fallback for unknown content types
|
||||
]
|
||||
|
||||
|
||||
class FormattedMessage(TypedDict):
|
||||
"""
|
||||
Standardized message format for Insights tracking.
|
||||
|
||||
Used across all providers to ensure consistent message structure
|
||||
when sending events to Insights.
|
||||
"""
|
||||
|
||||
role: str
|
||||
content: Union[str, List[FormattedContentItem], Any]
|
||||
|
||||
|
||||
class TokenUsage(TypedDict, total=False):
|
||||
"""
|
||||
Token usage information for AI model responses.
|
||||
|
||||
Different providers may populate different fields.
|
||||
"""
|
||||
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_read_input_tokens: Optional[int]
|
||||
cache_creation_input_tokens: Optional[int]
|
||||
reasoning_tokens: Optional[int]
|
||||
web_search_count: Optional[int]
|
||||
raw_usage: Optional[Any] # Raw provider usage metadata for backend processing
|
||||
|
||||
|
||||
class ProviderResponse(TypedDict, total=False):
|
||||
"""
|
||||
Standardized provider response format.
|
||||
|
||||
Used for consistent response formatting across all providers.
|
||||
"""
|
||||
|
||||
messages: List[FormattedMessage]
|
||||
usage: TokenUsage
|
||||
error: Optional[str]
|
||||
|
||||
|
||||
class StreamingContentBlock(TypedDict, total=False):
|
||||
"""
|
||||
Content block used during streaming to accumulate content.
|
||||
|
||||
Used for tracking text and function calls as they stream in.
|
||||
"""
|
||||
|
||||
type: str # "text" or "function"
|
||||
text: Optional[str]
|
||||
id: Optional[str]
|
||||
function: Optional[Dict[str, Any]]
|
||||
|
||||
|
||||
class ToolInProgress(TypedDict):
|
||||
"""
|
||||
Tracks a tool/function call being accumulated during streaming.
|
||||
|
||||
Used by Anthropic to accumulate JSON input for tools.
|
||||
"""
|
||||
|
||||
block: StreamingContentBlock
|
||||
input_string: str
|
||||
|
||||
|
||||
class StreamingEventData(TypedDict):
|
||||
"""
|
||||
Standardized data for streaming events across all providers.
|
||||
|
||||
This type ensures consistent data structure when capturing streaming events,
|
||||
with all provider-specific formatting already completed.
|
||||
"""
|
||||
|
||||
provider: str # "openai", "anthropic", "gemini"
|
||||
model: str
|
||||
base_url: str
|
||||
kwargs: Dict[str, Any] # Original kwargs for tool extraction and special handling
|
||||
formatted_input: Any # Provider-formatted input ready for tracking
|
||||
formatted_output: Any # Provider-formatted output ready for tracking
|
||||
usage_stats: TokenUsage
|
||||
latency: float
|
||||
distinct_id: Optional[str]
|
||||
trace_id: Optional[str]
|
||||
properties: Optional[Dict[str, Any]]
|
||||
privacy_mode: bool
|
||||
groups: Optional[Dict[str, Any]]
|
||||
@@ -0,0 +1,757 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional, cast
|
||||
|
||||
from hanzo_insights import get_tags, identify_context, new_context, tag, contexts
|
||||
from hanzo_insights.ai.sanitization import (
|
||||
sanitize_anthropic,
|
||||
sanitize_gemini,
|
||||
sanitize_langchain,
|
||||
sanitize_openai,
|
||||
)
|
||||
from hanzo_insights.ai.types import FormattedMessage, StreamingEventData, TokenUsage
|
||||
from hanzo_insights.client import Client as InsightsClient
|
||||
|
||||
|
||||
_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], insights_properties: Optional[Dict[str, Any]]
|
||||
) -> str:
|
||||
if insights_properties and any(
|
||||
key in insights_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 Insights.
|
||||
|
||||
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(
|
||||
target: TokenUsage, source: TokenUsage, mode: str = "incremental"
|
||||
) -> None:
|
||||
"""
|
||||
Merge streaming usage statistics into target dict, handling None values.
|
||||
|
||||
Supports two modes:
|
||||
- "incremental": Add source values to target (for APIs that report new tokens)
|
||||
- "cumulative": Replace target with source values (for APIs that report totals)
|
||||
|
||||
Args:
|
||||
target: Dictionary to update with usage stats
|
||||
source: TokenUsage that may contain None values
|
||||
mode: Either "incremental" or "cumulative"
|
||||
"""
|
||||
if mode == "incremental":
|
||||
# Add new values to existing totals
|
||||
source_input = source.get("input_tokens")
|
||||
if source_input is not None:
|
||||
current = target.get("input_tokens") or 0
|
||||
target["input_tokens"] = current + source_input
|
||||
|
||||
source_output = source.get("output_tokens")
|
||||
if source_output is not None:
|
||||
current = target.get("output_tokens") or 0
|
||||
target["output_tokens"] = current + source_output
|
||||
|
||||
source_cache_read = source.get("cache_read_input_tokens")
|
||||
if source_cache_read is not None:
|
||||
current = target.get("cache_read_input_tokens") or 0
|
||||
target["cache_read_input_tokens"] = current + source_cache_read
|
||||
|
||||
source_cache_creation = source.get("cache_creation_input_tokens")
|
||||
if source_cache_creation is not None:
|
||||
current = target.get("cache_creation_input_tokens") or 0
|
||||
target["cache_creation_input_tokens"] = current + source_cache_creation
|
||||
|
||||
source_reasoning = source.get("reasoning_tokens")
|
||||
if source_reasoning is not None:
|
||||
current = target.get("reasoning_tokens") or 0
|
||||
target["reasoning_tokens"] = current + source_reasoning
|
||||
|
||||
source_web_search = source.get("web_search_count")
|
||||
if source_web_search is not None:
|
||||
current = target.get("web_search_count") or 0
|
||||
target["web_search_count"] = max(current, source_web_search)
|
||||
|
||||
# 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:
|
||||
target["input_tokens"] = source["input_tokens"]
|
||||
if source.get("output_tokens") is not None:
|
||||
target["output_tokens"] = source["output_tokens"]
|
||||
if source.get("cache_read_input_tokens") is not None:
|
||||
target["cache_read_input_tokens"] = source["cache_read_input_tokens"]
|
||||
if source.get("cache_creation_input_tokens") is not None:
|
||||
target["cache_creation_input_tokens"] = source[
|
||||
"cache_creation_input_tokens"
|
||||
]
|
||||
if source.get("reasoning_tokens") is not None:
|
||||
target["reasoning_tokens"] = source["reasoning_tokens"]
|
||||
if source.get("web_search_count") is not None:
|
||||
target["web_search_count"] = source["web_search_count"]
|
||||
# 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'")
|
||||
|
||||
|
||||
def get_model_params(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Extracts model parameters from the kwargs dictionary.
|
||||
"""
|
||||
model_params = {}
|
||||
for param in [
|
||||
"temperature",
|
||||
"max_tokens", # Deprecated field
|
||||
"max_completion_tokens",
|
||||
"top_p",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"n",
|
||||
"stop",
|
||||
"stream", # OpenAI-specific field
|
||||
"streaming", # Anthropic-specific field
|
||||
]:
|
||||
if param in kwargs and kwargs[param] is not None:
|
||||
model_params[param] = kwargs[param]
|
||||
return model_params
|
||||
|
||||
|
||||
def get_usage(response, provider: str) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from response based on provider.
|
||||
Delegates to provider-specific converter functions.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
from hanzo_insights.ai.anthropic.anthropic_converter import (
|
||||
extract_anthropic_usage_from_response,
|
||||
)
|
||||
|
||||
return extract_anthropic_usage_from_response(response)
|
||||
elif provider == "openai":
|
||||
from hanzo_insights.ai.openai.openai_converter import (
|
||||
extract_openai_usage_from_response,
|
||||
)
|
||||
|
||||
return extract_openai_usage_from_response(response)
|
||||
elif provider == "gemini":
|
||||
from hanzo_insights.ai.gemini.gemini_converter import (
|
||||
extract_gemini_usage_from_response,
|
||||
)
|
||||
|
||||
return extract_gemini_usage_from_response(response)
|
||||
|
||||
return TokenUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
|
||||
def format_response(response, provider: str):
|
||||
"""
|
||||
Format a regular (non-streaming) response.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
from hanzo_insights.ai.anthropic.anthropic_converter import format_anthropic_response
|
||||
|
||||
return format_anthropic_response(response)
|
||||
elif provider == "openai":
|
||||
from hanzo_insights.ai.openai.openai_converter import format_openai_response
|
||||
|
||||
return format_openai_response(response)
|
||||
elif provider == "gemini":
|
||||
from hanzo_insights.ai.gemini.gemini_converter import format_gemini_response
|
||||
|
||||
return format_gemini_response(response)
|
||||
return []
|
||||
|
||||
|
||||
def extract_available_tool_calls(provider: str, kwargs: Dict[str, Any]):
|
||||
"""
|
||||
Extract available tool calls for the given provider.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
from hanzo_insights.ai.anthropic.anthropic_converter import extract_anthropic_tools
|
||||
|
||||
return extract_anthropic_tools(kwargs)
|
||||
elif provider == "gemini":
|
||||
from hanzo_insights.ai.gemini.gemini_converter import extract_gemini_tools
|
||||
|
||||
return extract_gemini_tools(kwargs)
|
||||
elif provider == "openai":
|
||||
from hanzo_insights.ai.openai.openai_converter import extract_openai_tools
|
||||
|
||||
return extract_openai_tools(kwargs)
|
||||
return None
|
||||
|
||||
|
||||
def merge_system_prompt(
|
||||
kwargs: Dict[str, Any], provider: str
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Merge system prompts and format messages for the given provider.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
from hanzo_insights.ai.anthropic.anthropic_converter import format_anthropic_input
|
||||
|
||||
messages = kwargs.get("messages") or []
|
||||
system = kwargs.get("system")
|
||||
return format_anthropic_input(messages, system)
|
||||
elif provider == "gemini":
|
||||
from hanzo_insights.ai.gemini.gemini_converter import format_gemini_input_with_system
|
||||
|
||||
contents = kwargs.get("contents", [])
|
||||
config = kwargs.get("config")
|
||||
return format_gemini_input_with_system(contents, config)
|
||||
elif provider == "openai":
|
||||
from hanzo_insights.ai.openai.openai_converter import format_openai_input
|
||||
|
||||
# For OpenAI, handle both Chat Completions and Responses API
|
||||
messages_param = kwargs.get("messages")
|
||||
input_param = kwargs.get("input")
|
||||
|
||||
# Get base formatted messages
|
||||
messages = format_openai_input(messages_param, input_param)
|
||||
|
||||
# Check if system prompt is provided as a separate parameter
|
||||
if kwargs.get("system") is not None:
|
||||
has_system = any(msg.get("role") == "system" for msg in messages)
|
||||
if not has_system:
|
||||
system_msg = cast(
|
||||
FormattedMessage,
|
||||
{"role": "system", "content": kwargs.get("system")},
|
||||
)
|
||||
messages = [system_msg] + messages
|
||||
|
||||
# For Responses API, add instructions to the system prompt if provided
|
||||
if kwargs.get("instructions") is not None:
|
||||
# Find the system message if it exists
|
||||
system_idx = next(
|
||||
(i for i, msg in enumerate(messages) if msg.get("role") == "system"),
|
||||
None,
|
||||
)
|
||||
|
||||
if system_idx is not None:
|
||||
# Append instructions to existing system message
|
||||
system_content = messages[system_idx].get("content", "")
|
||||
messages[system_idx]["content"] = (
|
||||
f"{system_content}\n\n{kwargs.get('instructions')}"
|
||||
)
|
||||
else:
|
||||
# Create a new system message with instructions
|
||||
instruction_msg = cast(
|
||||
FormattedMessage,
|
||||
{"role": "system", "content": kwargs.get("instructions")},
|
||||
)
|
||||
messages = [instruction_msg] + messages
|
||||
|
||||
return messages
|
||||
|
||||
# Default case - return empty list
|
||||
return []
|
||||
|
||||
|
||||
def call_llm_and_track_usage(
|
||||
insights_distinct_id: Optional[str],
|
||||
ph_client: InsightsClient,
|
||||
provider: str,
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
base_url: str,
|
||||
call_method: Callable[..., Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Common usage-tracking logic for both sync and async calls.
|
||||
call_method: the llm call method (e.g. openai.chat.completions.create)
|
||||
"""
|
||||
start_time = time.time()
|
||||
response = None
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: TokenUsage = TokenUsage()
|
||||
error_params: Dict[str, Any] = {}
|
||||
|
||||
with new_context(client=ph_client, capture_exceptions=False):
|
||||
if insights_distinct_id:
|
||||
identify_context(insights_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__(),
|
||||
}
|
||||
# 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 insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
# Check if we have a real user distinct_id (from param or outer context)
|
||||
has_person_distinct_id = (
|
||||
insights_distinct_id is not None
|
||||
or contexts.get_context_distinct_id() is not None
|
||||
)
|
||||
|
||||
if not has_person_distinct_id:
|
||||
# Fall back to trace_id as distinct_id when no real user id is available.
|
||||
identify_context(insights_trace_id)
|
||||
|
||||
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)
|
||||
|
||||
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, insights_privacy_mode, sanitized_messages),
|
||||
)
|
||||
tag(
|
||||
"$ai_output_choices",
|
||||
with_privacy_mode(
|
||||
ph_client, insights_privacy_mode, format_response(response, provider)
|
||||
),
|
||||
)
|
||||
tag("$ai_http_status", http_status)
|
||||
tag("$ai_input_tokens", usage.get("input_tokens", 0))
|
||||
tag("$ai_output_tokens", usage.get("output_tokens", 0))
|
||||
tag("$ai_latency", latency)
|
||||
tag("$ai_trace_id", insights_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 not has_person_distinct_id:
|
||||
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, insights_privacy_mode, kwargs.get("instructions")
|
||||
),
|
||||
)
|
||||
|
||||
# send the event to Insights
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
sdk_tags = get_tags()
|
||||
merged_properties = {
|
||||
**sdk_tags,
|
||||
**(insights_properties or {}),
|
||||
**(error_params or {}),
|
||||
}
|
||||
merged_properties["$ai_tokens_source"] = _get_tokens_source(
|
||||
sdk_tags, insights_properties
|
||||
)
|
||||
ph_client.capture(
|
||||
distinct_id=contexts.get_context_distinct_id(),
|
||||
event="$ai_generation",
|
||||
properties=merged_properties,
|
||||
groups=insights_groups,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise error
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def call_llm_and_track_usage_async(
|
||||
insights_distinct_id: Optional[str],
|
||||
ph_client: InsightsClient,
|
||||
provider: str,
|
||||
insights_trace_id: Optional[str],
|
||||
insights_properties: Optional[Dict[str, Any]],
|
||||
insights_privacy_mode: bool,
|
||||
insights_groups: Optional[Dict[str, Any]],
|
||||
base_url: str,
|
||||
call_async_method: Callable[..., Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
start_time = time.time()
|
||||
response = None
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: TokenUsage = TokenUsage()
|
||||
error_params: Dict[str, Any] = {}
|
||||
|
||||
with new_context(client=ph_client, capture_exceptions=False):
|
||||
if insights_distinct_id:
|
||||
identify_context(insights_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__(),
|
||||
}
|
||||
# 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 insights_trace_id is None:
|
||||
insights_trace_id = str(uuid.uuid4())
|
||||
|
||||
# Check if we have a real user distinct_id (from param or outer context)
|
||||
has_person_distinct_id = (
|
||||
insights_distinct_id is not None
|
||||
or contexts.get_context_distinct_id() is not None
|
||||
)
|
||||
|
||||
if not has_person_distinct_id:
|
||||
# Fall back to trace_id as distinct_id when no real user id is available.
|
||||
identify_context(insights_trace_id)
|
||||
|
||||
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)
|
||||
|
||||
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, insights_privacy_mode, sanitized_messages),
|
||||
)
|
||||
tag(
|
||||
"$ai_output_choices",
|
||||
with_privacy_mode(
|
||||
ph_client, insights_privacy_mode, format_response(response, provider)
|
||||
),
|
||||
)
|
||||
tag("$ai_http_status", http_status)
|
||||
tag("$ai_input_tokens", usage.get("input_tokens", 0))
|
||||
tag("$ai_output_tokens", usage.get("output_tokens", 0))
|
||||
tag("$ai_latency", latency)
|
||||
tag("$ai_trace_id", insights_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 not has_person_distinct_id:
|
||||
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, insights_privacy_mode, kwargs.get("instructions")
|
||||
),
|
||||
)
|
||||
|
||||
# send the event to Insights
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
sdk_tags = get_tags()
|
||||
merged_properties = {
|
||||
**sdk_tags,
|
||||
**(insights_properties or {}),
|
||||
**(error_params or {}),
|
||||
}
|
||||
merged_properties["$ai_tokens_source"] = _get_tokens_source(
|
||||
sdk_tags, insights_properties
|
||||
)
|
||||
ph_client.capture(
|
||||
distinct_id=contexts.get_context_distinct_id(),
|
||||
event="$ai_generation",
|
||||
properties=merged_properties,
|
||||
groups=insights_groups,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise error
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def sanitize_messages(data: Any, provider: str) -> Any:
|
||||
"""Sanitize messages using provider-specific sanitization functions."""
|
||||
if provider == "anthropic":
|
||||
return sanitize_anthropic(data)
|
||||
elif provider == "openai":
|
||||
return sanitize_openai(data)
|
||||
elif provider == "gemini":
|
||||
return sanitize_gemini(data)
|
||||
elif provider == "langchain":
|
||||
return sanitize_langchain(data)
|
||||
return data
|
||||
|
||||
|
||||
def with_privacy_mode(ph_client: InsightsClient, privacy_mode: bool, value: Any):
|
||||
if ph_client.privacy_mode or privacy_mode:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def capture_streaming_event(
|
||||
ph_client: InsightsClient,
|
||||
event_data: StreamingEventData,
|
||||
):
|
||||
"""
|
||||
Unified streaming event capture for all LLM providers.
|
||||
|
||||
This function handles the common logic for capturing streaming events across all providers.
|
||||
All provider-specific formatting should be done BEFORE calling this function.
|
||||
|
||||
The function handles:
|
||||
- Building Insights event properties
|
||||
- Extracting and adding tools based on provider
|
||||
- Applying privacy mode
|
||||
- Adding special token fields (cache, reasoning)
|
||||
- Provider-specific fields (e.g., OpenAI instructions)
|
||||
- Sending the event to Insights
|
||||
|
||||
Args:
|
||||
ph_client: Insights client instance
|
||||
event_data: Standardized streaming event data containing all necessary information
|
||||
"""
|
||||
trace_id = event_data.get("trace_id") or str(uuid.uuid4())
|
||||
|
||||
# Build base event properties
|
||||
event_properties = {
|
||||
"$ai_provider": event_data["provider"],
|
||||
"$ai_model": event_data["model"],
|
||||
"$ai_model_parameters": get_model_params(event_data["kwargs"]),
|
||||
"$ai_input": with_privacy_mode(
|
||||
ph_client,
|
||||
event_data["privacy_mode"],
|
||||
event_data["formatted_input"],
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
ph_client,
|
||||
event_data["privacy_mode"],
|
||||
event_data["formatted_output"],
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": event_data["usage_stats"].get("input_tokens", 0),
|
||||
"$ai_output_tokens": event_data["usage_stats"].get("output_tokens", 0),
|
||||
"$ai_latency": event_data["latency"],
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_base_url": str(event_data["base_url"]),
|
||||
**(event_data.get("properties") or {}),
|
||||
}
|
||||
|
||||
# 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"],
|
||||
event_data["kwargs"],
|
||||
)
|
||||
if available_tools:
|
||||
event_properties["$ai_tools"] = available_tools
|
||||
|
||||
# Add optional token fields
|
||||
# For Anthropic, always include cache fields even if 0 (backward compatibility)
|
||||
# For others, only include if present and non-zero
|
||||
if event_data["provider"] == "anthropic":
|
||||
# Anthropic always includes cache fields
|
||||
cache_read = event_data["usage_stats"].get("cache_read_input_tokens", 0)
|
||||
cache_creation = event_data["usage_stats"].get("cache_creation_input_tokens", 0)
|
||||
event_properties["$ai_cache_read_input_tokens"] = cache_read
|
||||
event_properties["$ai_cache_creation_input_tokens"] = cache_creation
|
||||
else:
|
||||
# Other providers only include if non-zero
|
||||
optional_token_fields = [
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"reasoning_tokens",
|
||||
]
|
||||
|
||||
for field in optional_token_fields:
|
||||
value = event_data["usage_stats"].get(field)
|
||||
if value is not None and isinstance(value, int) and value > 0:
|
||||
event_properties[f"$ai_{field}"] = value
|
||||
|
||||
# Add web search count if present (all providers)
|
||||
web_search_count = event_data["usage_stats"].get("web_search_count")
|
||||
if (
|
||||
web_search_count is not None
|
||||
and isinstance(web_search_count, int)
|
||||
and web_search_count > 0
|
||||
):
|
||||
event_properties["$ai_web_search_count"] = web_search_count
|
||||
|
||||
# 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"
|
||||
and event_data["kwargs"].get("instructions") is not None
|
||||
):
|
||||
event_properties["$ai_instructions"] = with_privacy_mode(
|
||||
ph_client,
|
||||
event_data["privacy_mode"],
|
||||
event_data["kwargs"]["instructions"],
|
||||
)
|
||||
|
||||
if event_data.get("distinct_id") is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Send event to Insights
|
||||
if hasattr(ph_client, "capture"):
|
||||
ph_client.capture(
|
||||
distinct_id=event_data.get("distinct_id") or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=event_data.get("groups"),
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
from typing import TypedDict, Optional, Any, Dict, Union, Tuple, Type
|
||||
from types import TracebackType
|
||||
from typing_extensions import NotRequired # For Python < 3.11 compatibility
|
||||
from datetime import datetime
|
||||
import numbers
|
||||
from uuid import UUID
|
||||
|
||||
from hanzo_insights.types import SendFeatureFlagsOptions
|
||||
|
||||
ID_TYPES = Union[numbers.Number, str, UUID, int]
|
||||
|
||||
|
||||
class OptionalCaptureArgs(TypedDict):
|
||||
"""Optional arguments for the capture method.
|
||||
|
||||
Args:
|
||||
distinct_id: Unique identifier for the person associated with this event. If not set, the context
|
||||
distinct_id is used, if available, otherwise a UUID is generated, and the event is marked
|
||||
as personless. Setting context-level distinct_id's is recommended.
|
||||
properties: Dictionary of properties to track with the event
|
||||
timestamp: When the event occurred (defaults to current time)
|
||||
uuid: Unique identifier for this specific event. If not provided, one is generated. The event
|
||||
UUID is returned, so you can correlate it with actions in your app (like showing users an
|
||||
error ID if you capture an exception).
|
||||
groups: Group identifiers to associate with this event (format: {group_type: group_key})
|
||||
send_feature_flags: Whether to include currently active feature flags in the event properties.
|
||||
Can be a boolean (True/False) or a SendFeatureFlagsOptions object for advanced configuration.
|
||||
Defaults to False.
|
||||
disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False.
|
||||
"""
|
||||
|
||||
distinct_id: NotRequired[Optional[ID_TYPES]]
|
||||
properties: NotRequired[Optional[Dict[str, Any]]]
|
||||
timestamp: NotRequired[Optional[Union[datetime, str]]]
|
||||
uuid: NotRequired[Optional[str]]
|
||||
groups: NotRequired[Optional[Dict[str, str]]]
|
||||
send_feature_flags: NotRequired[
|
||||
Optional[Union[bool, SendFeatureFlagsOptions]]
|
||||
] # Updated to support both boolean and options object
|
||||
disable_geoip: NotRequired[
|
||||
Optional[bool]
|
||||
] # As above, optional so we can tell if the user is intentionally overriding a client setting or not
|
||||
|
||||
|
||||
class OptionalSetArgs(TypedDict):
|
||||
"""Optional arguments for the set method.
|
||||
|
||||
Args:
|
||||
distinct_id: Unique identifier for the user to set properties on. If not set, the context
|
||||
distinct_id is used, if available, otherwise this function does nothing. Setting
|
||||
context-level distinct_id's is recommended.
|
||||
properties: Dictionary of properties to set on the person
|
||||
timestamp: When the properties were set (defaults to current time)
|
||||
uuid: Unique identifier for this operation. If not provided, one is generated. This
|
||||
UUID is returned, so you can correlate it with actions in your app.
|
||||
disable_geoip: Whether to disable GeoIP lookup for this operation. Defaults to False.
|
||||
"""
|
||||
|
||||
distinct_id: NotRequired[Optional[ID_TYPES]]
|
||||
properties: NotRequired[Optional[Dict[str, Any]]]
|
||||
timestamp: NotRequired[Optional[Union[datetime, str]]]
|
||||
uuid: NotRequired[Optional[str]]
|
||||
disable_geoip: NotRequired[Optional[bool]]
|
||||
|
||||
|
||||
ExcInfo = Union[
|
||||
Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
|
||||
Tuple[None, None, None],
|
||||
]
|
||||
|
||||
ExceptionArg = Union[BaseException, ExcInfo]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from threading import Thread
|
||||
|
||||
import backoff
|
||||
import monotonic
|
||||
|
||||
from posthog.request import APIError, DatetimeSerializer, batch_post
|
||||
from hanzo_insights.request import APIError, DatetimeSerializer, batch_post
|
||||
|
||||
try:
|
||||
from queue import Empty
|
||||
@@ -23,7 +21,7 @@ BATCH_SIZE_LIMIT = 5 * 1024 * 1024
|
||||
class Consumer(Thread):
|
||||
"""Consumes the messages from the client's queue."""
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
log = logging.getLogger("hanzo_insights")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -84,30 +82,36 @@ class Consumer(Thread):
|
||||
self.log.error("error uploading: %s", e)
|
||||
success = False
|
||||
if self.on_error:
|
||||
self.on_error(e, batch)
|
||||
try:
|
||||
self.on_error(e, batch)
|
||||
except Exception as e:
|
||||
self.log.error("on_error handler failed: %s", e)
|
||||
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."""
|
||||
queue = self.queue
|
||||
items = []
|
||||
|
||||
start_time = monotonic.monotonic()
|
||||
start_time = time.monotonic()
|
||||
total_size = 0
|
||||
|
||||
while len(items) < self.flush_at:
|
||||
elapsed = monotonic.monotonic() - start_time
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= self.flush_interval:
|
||||
break
|
||||
try:
|
||||
item = queue.get(block=True, timeout=self.flush_interval - elapsed)
|
||||
item_size = len(json.dumps(item, cls=DatetimeSerializer).encode())
|
||||
if item_size > MAX_MSG_SIZE:
|
||||
self.log.error("Item exceeds 900kib limit, dropping. (%s)", str(item))
|
||||
self.log.error(
|
||||
"Item exceeds 900kib limit, dropping. (%s)", str(item)
|
||||
)
|
||||
continue
|
||||
items.append(item)
|
||||
total_size += item_size
|
||||
@@ -122,27 +126,41 @@ class Consumer(Thread):
|
||||
def request(self, batch):
|
||||
"""Attempt to upload the batch and retry before raising an error"""
|
||||
|
||||
def fatal_exception(exc):
|
||||
def is_retryable(exc):
|
||||
if isinstance(exc, APIError):
|
||||
# retry on server errors and client errors
|
||||
# with 429 status code (rate limited),
|
||||
# with 408 (request timeout) or 429 (rate limited),
|
||||
# don't retry on other client errors
|
||||
if exc.status == "N/A":
|
||||
return False
|
||||
return (400 <= exc.status < 500) and exc.status != 429
|
||||
return not ((400 <= exc.status < 500) and exc.status not in (408, 429))
|
||||
else:
|
||||
# retry on all other errors (eg. network)
|
||||
return False
|
||||
return True
|
||||
|
||||
@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,
|
||||
)
|
||||
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))
|
||||
|
||||
send_request()
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
@@ -0,0 +1,408 @@
|
||||
import contextvars
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional, Any, Callable, Dict, TypeVar, cast, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# To avoid circular imports
|
||||
from hanzo_insights.client import Client
|
||||
|
||||
|
||||
class ContextScope:
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
fresh: bool = False,
|
||||
capture_exceptions: bool = True,
|
||||
client: Optional["Client"] = None,
|
||||
):
|
||||
self.client: Optional[Client] = client
|
||||
self.parent = parent
|
||||
self.fresh = fresh
|
||||
self.capture_exceptions = capture_exceptions
|
||||
self.session_id: Optional[str] = None
|
||||
self.distinct_id: Optional[str] = None
|
||||
self.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
|
||||
|
||||
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
|
||||
|
||||
def get_session_id(self) -> Optional[str]:
|
||||
if self.session_id is not None:
|
||||
return self.session_id
|
||||
if self.parent is not None and not self.fresh:
|
||||
return self.parent.get_session_id()
|
||||
return None
|
||||
|
||||
def get_distinct_id(self) -> Optional[str]:
|
||||
if self.distinct_id is not None:
|
||||
return self.distinct_id
|
||||
if self.parent is not None and not self.fresh:
|
||||
return self.parent.get_distinct_id()
|
||||
return None
|
||||
|
||||
def 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]:
|
||||
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
|
||||
|
||||
|
||||
_context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar(
|
||||
"insights_context_stack", default=None
|
||||
)
|
||||
|
||||
|
||||
def _get_current_context() -> Optional[ContextScope]:
|
||||
return _context_stack.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def new_context(
|
||||
fresh: bool = False,
|
||||
capture_exceptions: bool = True,
|
||||
client: Optional["Client"] = None,
|
||||
):
|
||||
"""
|
||||
Create a new context scope that will be active for the duration of the with block.
|
||||
Any tags set within this scope will be isolated to this context. Any exceptions raised
|
||||
or events captured within the context will be tagged with the context tags.
|
||||
|
||||
Args:
|
||||
fresh: Whether to start with a fresh context (default: False).
|
||||
If False, inherits tags, identity and session id's from parent context.
|
||||
If True, starts with no state
|
||||
capture_exceptions: Whether to capture exceptions raised within the context (default: True).
|
||||
If True, captures exceptions and tags them with the context tags before propagating them.
|
||||
If False, exceptions will propagate without being tagged or captured.
|
||||
client: Optional client instance to use for capturing exceptions (default: None).
|
||||
If provided, the client will be used to capture exceptions within the context.
|
||||
If not provided, the default (global) client will be used. Note that the passed
|
||||
client is only used to capture exceptions within the context - other events captured
|
||||
within the context via `Client.capture` or `hanzo_insights.capture` will still carry the context
|
||||
state (tags, identity, session id), but will be captured by the client directly used (or
|
||||
the global one, in the case of `hanzo_insights.capture`)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Inherit parent context tags
|
||||
with hanzo_insights.new_context():
|
||||
hanzo_insights.tag("request_id", "123")
|
||||
# Both this event and the exception will be tagged with the context tags
|
||||
hanzo_insights.capture("event_name", {"property": "value"})
|
||||
raise ValueError("Something went wrong")
|
||||
```
|
||||
```python
|
||||
# Start with fresh context (no inherited tags)
|
||||
with hanzo_insights.new_context(fresh=True):
|
||||
hanzo_insights.tag("request_id", "123")
|
||||
# Both this event and the exception will be tagged with the context tags
|
||||
hanzo_insights.capture("event_name", {"property": "value"})
|
||||
raise ValueError("Something went wrong")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
from hanzo_insights import capture_exception
|
||||
|
||||
current_context = _get_current_context()
|
||||
new_context = ContextScope(current_context, fresh, capture_exceptions, client)
|
||||
_context_stack.set(new_context)
|
||||
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
if new_context.capture_exceptions:
|
||||
if new_context.client:
|
||||
new_context.client.capture_exception(e)
|
||||
else:
|
||||
capture_exception(e)
|
||||
raise
|
||||
finally:
|
||||
_context_stack.set(new_context.get_parent())
|
||||
|
||||
|
||||
def tag(key: str, value: Any) -> None:
|
||||
"""
|
||||
Add a tag to the current context. All tags are added as properties to any event, including exceptions, captured
|
||||
within the context.
|
||||
|
||||
Args:
|
||||
key: The tag key
|
||||
value: The tag value
|
||||
|
||||
Example:
|
||||
```python
|
||||
hanzo_insights.tag("user_id", "123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
current_context.add_tag(key, value)
|
||||
|
||||
|
||||
def get_tags() -> Dict[str, Any]:
|
||||
"""
|
||||
Get all tags from the current context. Note, modifying
|
||||
the returned dictionary will not affect the current context.
|
||||
|
||||
Returns:
|
||||
Dict of all tags in the current context
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
return current_context.collect_tags()
|
||||
return {}
|
||||
|
||||
|
||||
def identify_context(distinct_id: str) -> None:
|
||||
"""
|
||||
Identify the current context with a distinct ID, associating all events captured in this or
|
||||
child contexts with the given distinct ID (unless identify_context is called again). This is overridden by
|
||||
distinct id's passed directly to hanzo_insights.capture and related methods (identify, set etc). Entering a
|
||||
fresh context will clear the context-level distinct ID. The distinct-id passed should be uniquely associated
|
||||
with one of your users. Events captured outside of a context, or in a context with no associated distinct
|
||||
ID, will be assigned a random UUID, and captured as "personless".
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID to associate with the current context and its children.
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
current_context.set_distinct_id(distinct_id)
|
||||
|
||||
|
||||
def set_context_session(session_id: str) -> None:
|
||||
"""
|
||||
Set the session ID for the current context, associating all events captured in this or
|
||||
child contexts with the given session ID (unless set_context_session is called again).
|
||||
Entering a fresh context will clear the context-level session ID.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to associate with the current context and its children. See https://insights.hanzo.ai/docs/data/sessions
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
current_context.set_session_id(session_id)
|
||||
|
||||
|
||||
def get_context_session_id() -> Optional[str]:
|
||||
"""
|
||||
Get the session ID for the current context.
|
||||
|
||||
Returns:
|
||||
The session ID if set, None otherwise
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
return current_context.get_session_id()
|
||||
return None
|
||||
|
||||
|
||||
def get_context_distinct_id() -> Optional[str]:
|
||||
"""
|
||||
Get the distinct ID for the current context.
|
||||
|
||||
Returns:
|
||||
The distinct ID if set, None otherwise
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
return current_context.get_distinct_id()
|
||||
return None
|
||||
|
||||
|
||||
def set_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])
|
||||
|
||||
|
||||
def scoped(fresh: bool = False, capture_exceptions: bool = True):
|
||||
"""
|
||||
Decorator that creates a new context for the function. Simply wraps
|
||||
the function in a with hanzo_insights.new_context(): block.
|
||||
|
||||
Args:
|
||||
fresh: Whether to start with a fresh context (default: False)
|
||||
capture_exceptions: Whether to capture and track exceptions with Insights error tracking (default: True)
|
||||
|
||||
Example:
|
||||
@hanzo_insights.scoped()
|
||||
def process_payment(payment_id):
|
||||
hanzo_insights.tag("payment_id", payment_id)
|
||||
hanzo_insights.tag("payment_method", "credit_card")
|
||||
|
||||
# This event will be captured with tags
|
||||
hanzo_insights.capture("payment_started")
|
||||
# If this raises an exception, it will be captured with tags
|
||||
# and then re-raised
|
||||
some_risky_function()
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
from functools import wraps
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
with new_context(fresh=fresh, capture_exceptions=capture_exceptions):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return cast(F, wrapper)
|
||||
|
||||
return decorator
|
||||
@@ -1,45 +1,30 @@
|
||||
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
|
||||
# Licensed under the MIT License
|
||||
|
||||
# 💖open source (under MIT License)
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from posthog.client import Client
|
||||
|
||||
|
||||
class Integrations(str, Enum):
|
||||
Django = "django"
|
||||
from hanzo_insights.client import Client
|
||||
|
||||
|
||||
class ExceptionCapture:
|
||||
# TODO: Add client side rate limiting to prevent spamming the server with exceptions
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
log = logging.getLogger("hanzo_insights")
|
||||
|
||||
def __init__(self, client: "Client", integrations: Optional[List[Integrations]] = None):
|
||||
def __init__(self, client: "Client"):
|
||||
self.client = client
|
||||
self.original_excepthook = sys.excepthook
|
||||
sys.excepthook = self.exception_handler
|
||||
threading.excepthook = self.thread_exception_handler
|
||||
self.enabled_integrations = []
|
||||
|
||||
for integration in integrations or []:
|
||||
# TODO: Maybe find a better way of enabling integrations
|
||||
# This is very annoying currently if we had to add any configuration per integration
|
||||
if integration == Integrations.Django:
|
||||
try:
|
||||
from posthog.exception_integrations.django import DjangoIntegration
|
||||
|
||||
enabled_integration = DjangoIntegration(self.exception_receiver)
|
||||
self.enabled_integrations.append(enabled_integration)
|
||||
except Exception as e:
|
||||
self.log.exception(f"Failed to enable Django integration: {e}")
|
||||
|
||||
def close(self):
|
||||
sys.excepthook = self.original_excepthook
|
||||
for integration in self.enabled_integrations:
|
||||
integration.uninstall()
|
||||
|
||||
def exception_handler(self, exc_type, exc_value, exc_traceback):
|
||||
# don't affect default behaviour.
|
||||
@@ -59,6 +44,6 @@ class ExceptionCapture:
|
||||
def capture_exception(self, exception, metadata=None):
|
||||
try:
|
||||
distinct_id = metadata.get("distinct_id") if metadata else None
|
||||
self.client.capture_exception(exception, distinct_id)
|
||||
self.client.capture_exception(exception, distinct_id=distinct_id)
|
||||
except Exception as e:
|
||||
self.log.exception(f"Failed to capture exception: {e}")
|
||||
@@ -1,13 +1,36 @@
|
||||
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
|
||||
# Licensed under the MIT License
|
||||
|
||||
# copied and adapted from https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/utils.py#L689
|
||||
# 💖open source (under MIT License)
|
||||
# We want to keep payloads as similar to Sentry as possible for easy interoperability
|
||||
|
||||
import json
|
||||
import linecache
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from types import FrameType, TracebackType # noqa: F401
|
||||
from typing import ( # noqa: F401
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Pattern,
|
||||
Set,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from hanzo_insights.args import ExceptionArg, ExcInfo # noqa: F401
|
||||
|
||||
try:
|
||||
# Python 3.11
|
||||
@@ -19,78 +42,106 @@ 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",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
|
||||
|
||||
from types import FrameType, TracebackType
|
||||
from typing import ( # noqa: F401
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Type,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
CODE_VARIABLES_REDACTED_VALUE = "$$_insights_redacted_based_on_masking_rules_$$"
|
||||
CODE_VARIABLES_TOO_LONG_VALUE = "$$_insights_value_too_long_$$"
|
||||
|
||||
ExcInfo = Union[
|
||||
Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
|
||||
Tuple[None, None, None],
|
||||
]
|
||||
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
|
||||
_MAX_VALUE_LENGTH_FOR_PATTERN_MATCH = 5_000
|
||||
_MAX_COLLECTION_ITEMS_TO_SCAN = 100
|
||||
_REGEX_METACHARACTERS = frozenset(r"\.^$*+?{}[]|()")
|
||||
|
||||
Event = TypedDict(
|
||||
"Event",
|
||||
{
|
||||
"breadcrumbs": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
|
||||
"check_in_id": str,
|
||||
"contexts": Dict[str, Dict[str, object]],
|
||||
"dist": str,
|
||||
"duration": Optional[float],
|
||||
"environment": str,
|
||||
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
|
||||
"event_id": str,
|
||||
"exception": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
|
||||
# "extra": MutableMapping[str, object],
|
||||
# "fingerprint": List[str],
|
||||
"level": LogLevelStr,
|
||||
# "logentry": Mapping[str, object],
|
||||
"logger": str,
|
||||
# "measurements": Dict[str, MeasurementValue],
|
||||
"message": str,
|
||||
"modules": Dict[str, str],
|
||||
# "monitor_config": Mapping[str, object],
|
||||
"monitor_slug": Optional[str],
|
||||
"platform": Literal["python"],
|
||||
"profile": object, # Should be sentry_sdk.profiler.Profile, but we can't import that here due to circular imports
|
||||
"release": str,
|
||||
"request": Dict[str, object],
|
||||
# "sdk": Mapping[str, object],
|
||||
"server_name": str,
|
||||
"spans": List[Dict[str, object]],
|
||||
"stacktrace": Dict[str, object], # We access this key in the code, but I am unsure whether we ever set it
|
||||
"start_timestamp": datetime,
|
||||
"status": Optional[str],
|
||||
# "tags": MutableMapping[
|
||||
# str, str
|
||||
# ], # Tags must be less than 200 characters each
|
||||
"threads": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
|
||||
"timestamp": Optional[datetime], # Must be set before sending the event
|
||||
"transaction": str,
|
||||
# "transaction_info": Mapping[str, Any], # TODO: We can expand on this type
|
||||
"type": Literal["check_in", "transaction"],
|
||||
"user": Dict[str, object],
|
||||
"_metrics_summary": Dict[str, object],
|
||||
},
|
||||
total=False,
|
||||
)
|
||||
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(
|
||||
"Event",
|
||||
{
|
||||
"breadcrumbs": Dict[
|
||||
Literal["values"], List[Dict[str, Any]]
|
||||
], # TODO: We can expand on this type
|
||||
"check_in_id": str,
|
||||
"contexts": Dict[str, Dict[str, object]],
|
||||
"dist": str,
|
||||
"duration": Optional[float],
|
||||
"environment": str,
|
||||
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
|
||||
"event_id": str,
|
||||
"exception": Dict[
|
||||
Literal["values"], List[Dict[str, Any]]
|
||||
], # TODO: We can expand on this type
|
||||
# "extra": MutableMapping[str, object],
|
||||
# "fingerprint": List[str],
|
||||
"level": LogLevelStr,
|
||||
# "logentry": Mapping[str, object],
|
||||
"logger": str,
|
||||
# "measurements": Dict[str, MeasurementValue],
|
||||
"message": str,
|
||||
"modules": Dict[str, str],
|
||||
# "monitor_config": Mapping[str, object],
|
||||
"monitor_slug": Optional[str],
|
||||
"platform": Literal["python"],
|
||||
"profile": object,
|
||||
"release": str,
|
||||
"request": Dict[str, object],
|
||||
# "sdk": Mapping[str, object],
|
||||
"server_name": str,
|
||||
"spans": List[Dict[str, object]],
|
||||
"stacktrace": Dict[
|
||||
str, object
|
||||
], # We access this key in the code, but I am unsure whether we ever set it
|
||||
"start_timestamp": datetime,
|
||||
"status": Optional[str],
|
||||
# "tags": MutableMapping[
|
||||
# str, str
|
||||
# ], # Tags must be less than 200 characters each
|
||||
"threads": Dict[
|
||||
Literal["values"], List[Dict[str, Any]]
|
||||
], # TODO: We can expand on this type
|
||||
"timestamp": Optional[datetime], # Must be set before sending the event
|
||||
"transaction": str,
|
||||
# "transaction_info": Mapping[str, Any], # TODO: We can expand on this type
|
||||
"type": Literal["check_in", "transaction"],
|
||||
"user": Dict[str, object],
|
||||
"_metrics_summary": Dict[str, object],
|
||||
},
|
||||
total=False,
|
||||
)
|
||||
|
||||
|
||||
epoch = datetime(1970, 1, 1)
|
||||
@@ -126,9 +177,6 @@ def event_hint_with_exc_info(exc_info=None):
|
||||
class AnnotatedValue:
|
||||
"""
|
||||
Meta information for a data field in the event payload.
|
||||
This is to tell Relay that we have tampered with the fields value.
|
||||
See:
|
||||
https://github.com/getsentry/relay/blob/be12cd49a0f06ea932ed9b9f93a655de5d6ad6d1/relay-general/src/types/meta.rs#L407-L423
|
||||
"""
|
||||
|
||||
__slots__ = ("value", "metadata")
|
||||
@@ -270,7 +318,10 @@ def get_lines_from_file(
|
||||
upper_bound = min(lineno + 1 + context_lines, len(source))
|
||||
|
||||
try:
|
||||
pre_context = [strip_string(line.strip("\r\n"), max_length=max_length) for line in source[lower_bound:lineno]]
|
||||
pre_context = [
|
||||
strip_string(line.strip("\r\n"), max_length=max_length)
|
||||
for line in source[lower_bound:lineno]
|
||||
]
|
||||
context_line = strip_string(source[lineno].strip("\r\n"), max_length=max_length)
|
||||
post_context = [
|
||||
strip_string(line.strip("\r\n"), max_length=max_length)
|
||||
@@ -302,7 +353,9 @@ def get_source_context(
|
||||
loader = None
|
||||
lineno = tb_lineno - 1
|
||||
if lineno is not None and abs_path:
|
||||
return get_lines_from_file(abs_path, lineno, max_value_length, loader=loader, module=module)
|
||||
return get_lines_from_file(
|
||||
abs_path, lineno, max_value_length, loader=loader, module=module
|
||||
)
|
||||
return [], None, []
|
||||
|
||||
|
||||
@@ -339,7 +392,9 @@ def filename_for_module(module, abs_path):
|
||||
if not base_module_path:
|
||||
return abs_path
|
||||
|
||||
return abs_path.split(base_module_path.rsplit(os.sep, 2)[0], 1)[-1].lstrip(os.sep)
|
||||
return abs_path.split(base_module_path.rsplit(os.sep, 2)[0], 1)[-1].lstrip(
|
||||
os.sep
|
||||
)
|
||||
except Exception:
|
||||
return abs_path
|
||||
|
||||
@@ -347,12 +402,9 @@ def filename_for_module(module, abs_path):
|
||||
def serialize_frame(
|
||||
frame,
|
||||
tb_lineno=None,
|
||||
include_local_variables=True,
|
||||
include_source_context=True,
|
||||
max_value_length=None,
|
||||
custom_repr=None,
|
||||
):
|
||||
# type: (FrameType, Optional[int], bool, bool, Optional[int], Optional[Callable[..., Optional[str]]]) -> Dict[str, Any]
|
||||
# type: (FrameType, Optional[int], Optional[int]) -> Dict[str, Any]
|
||||
f_code = getattr(frame, "f_code", None)
|
||||
if not f_code:
|
||||
abs_path = None
|
||||
@@ -377,50 +429,13 @@ def serialize_frame(
|
||||
"lineno": tb_lineno,
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if include_source_context:
|
||||
rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context(
|
||||
frame, tb_lineno, max_value_length
|
||||
)
|
||||
|
||||
if include_local_variables:
|
||||
# TODO(nk): Sort out this current invalid import
|
||||
# from sentry_sdk.serializer import serialize
|
||||
|
||||
# rv["vars"] = serialize(
|
||||
# dict(frame.f_locals), is_vars=True, custom_repr=custom_repr
|
||||
# )
|
||||
pass
|
||||
rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context(
|
||||
frame, tb_lineno, max_value_length
|
||||
)
|
||||
|
||||
return rv
|
||||
|
||||
|
||||
def current_stacktrace(
|
||||
include_local_variables=True, # type: bool
|
||||
include_source_context=True, # type: bool
|
||||
max_value_length=None, # type: Optional[int]
|
||||
):
|
||||
# type: (...) -> Dict[str, Any]
|
||||
__tracebackhide__ = True
|
||||
frames = []
|
||||
|
||||
f = sys._getframe() # type: Optional[FrameType]
|
||||
while f is not None:
|
||||
if not should_hide_frame(f):
|
||||
frames.append(
|
||||
serialize_frame(
|
||||
f,
|
||||
include_local_variables=include_local_variables,
|
||||
include_source_context=include_source_context,
|
||||
max_value_length=max_value_length,
|
||||
)
|
||||
)
|
||||
f = f.f_back
|
||||
|
||||
frames.reverse()
|
||||
|
||||
return {"frames": frames, "type": "raw"}
|
||||
|
||||
|
||||
def get_errno(exc_value):
|
||||
# type: (BaseException) -> Optional[Any]
|
||||
return getattr(exc_value, "errno", None)
|
||||
@@ -428,14 +443,19 @@ def get_errno(exc_value):
|
||||
|
||||
def get_error_message(exc_value):
|
||||
# type: (Optional[BaseException]) -> str
|
||||
return getattr(exc_value, "message", "") or getattr(exc_value, "detail", "") or safe_str(exc_value)
|
||||
message = (
|
||||
getattr(exc_value, "message", "")
|
||||
or getattr(exc_value, "detail", "")
|
||||
or exc_value
|
||||
)
|
||||
|
||||
return safe_str(message)
|
||||
|
||||
|
||||
def single_exception_from_error_tuple(
|
||||
exc_type, # type: Optional[type]
|
||||
exc_value, # type: Optional[BaseException]
|
||||
tb, # type: Optional[TracebackType]
|
||||
client_options=None, # type: Optional[Dict[str, Any]]
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
exception_id=None, # type: Optional[int]
|
||||
parent_id=None, # type: Optional[int]
|
||||
@@ -443,13 +463,12 @@ def single_exception_from_error_tuple(
|
||||
):
|
||||
# type: (...) -> Dict[str, Any]
|
||||
"""
|
||||
Creates a dict that goes into the events `exception.values` list and is ingestible by Sentry.
|
||||
|
||||
See the Exception Interface documentation for more details:
|
||||
https://develop.sentry.dev/sdk/event-payloads/exception/
|
||||
Creates a dict that goes into the events `exception.values` list
|
||||
"""
|
||||
exception_value = {} # type: Dict[str, Any]
|
||||
exception_value["mechanism"] = mechanism.copy() if mechanism else {"type": "generic", "handled": True}
|
||||
exception_value["mechanism"] = (
|
||||
mechanism.copy() if mechanism else {"type": "generic", "handled": True}
|
||||
)
|
||||
if exception_id is not None:
|
||||
exception_value["mechanism"]["exception_id"] = exception_id
|
||||
|
||||
@@ -459,7 +478,9 @@ def single_exception_from_error_tuple(
|
||||
errno = None
|
||||
|
||||
if errno is not None:
|
||||
exception_value["mechanism"].setdefault("meta", {}).setdefault("errno", {}).setdefault("number", errno)
|
||||
exception_value["mechanism"].setdefault("meta", {}).setdefault(
|
||||
"errno", {}
|
||||
).setdefault("number", errno)
|
||||
|
||||
if source is not None:
|
||||
exception_value["mechanism"]["source"] = source
|
||||
@@ -472,7 +493,9 @@ def single_exception_from_error_tuple(
|
||||
if is_root_exception and "type" not in exception_value["mechanism"]:
|
||||
exception_value["mechanism"]["type"] = "generic"
|
||||
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(exc_value, BaseExceptionGroup)
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(
|
||||
exc_value, BaseExceptionGroup
|
||||
)
|
||||
if is_exception_group:
|
||||
exception_value["mechanism"]["is_exception_group"] = True
|
||||
|
||||
@@ -480,25 +503,13 @@ def single_exception_from_error_tuple(
|
||||
exception_value["type"] = get_type_name(exc_type)
|
||||
exception_value["value"] = get_error_message(exc_value)
|
||||
|
||||
if client_options is None:
|
||||
include_local_variables = True
|
||||
include_source_context = True
|
||||
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
|
||||
custom_repr = None
|
||||
else:
|
||||
include_local_variables = client_options["include_local_variables"]
|
||||
include_source_context = client_options["include_source_context"]
|
||||
max_value_length = client_options["max_value_length"]
|
||||
custom_repr = client_options.get("custom_repr")
|
||||
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
|
||||
|
||||
frames = [
|
||||
serialize_frame(
|
||||
tb.tb_frame,
|
||||
tb_lineno=tb.tb_lineno,
|
||||
include_local_variables=include_local_variables,
|
||||
include_source_context=include_source_context,
|
||||
max_value_length=max_value_length,
|
||||
custom_repr=custom_repr,
|
||||
)
|
||||
for tb in iter_stacks(tb)
|
||||
]
|
||||
@@ -520,7 +531,11 @@ if HAS_CHAINED_EXCEPTIONS:
|
||||
seen_exceptions = []
|
||||
seen_exception_ids = set() # type: Set[int]
|
||||
|
||||
while exc_type is not None and exc_value is not None and id(exc_value) not in seen_exception_ids:
|
||||
while (
|
||||
exc_type is not None
|
||||
and exc_value is not None
|
||||
and id(exc_value) not in seen_exception_ids
|
||||
):
|
||||
yield exc_type, exc_value, tb
|
||||
|
||||
# Avoid hashing random types we don't know anything
|
||||
@@ -550,7 +565,6 @@ def exceptions_from_error(
|
||||
exc_type, # type: Optional[type]
|
||||
exc_value, # type: Optional[BaseException]
|
||||
tb, # type: Optional[TracebackType]
|
||||
client_options=None, # type: Optional[Dict[str, Any]]
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
exception_id=0, # type: int
|
||||
parent_id=0, # type: int
|
||||
@@ -560,16 +574,12 @@ def exceptions_from_error(
|
||||
"""
|
||||
Creates the list of exceptions.
|
||||
This can include chained exceptions and exceptions from an ExceptionGroup.
|
||||
|
||||
See the Exception Interface documentation for more details:
|
||||
https://develop.sentry.dev/sdk/event-payloads/exception/
|
||||
"""
|
||||
|
||||
parent = single_exception_from_error_tuple(
|
||||
exc_type=exc_type,
|
||||
exc_value=exc_value,
|
||||
tb=tb,
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
parent_id=parent_id,
|
||||
@@ -580,18 +590,23 @@ def exceptions_from_error(
|
||||
parent_id = exception_id
|
||||
exception_id += 1
|
||||
|
||||
should_supress_context = hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
|
||||
should_supress_context = (
|
||||
hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
|
||||
)
|
||||
if should_supress_context:
|
||||
# Add direct cause.
|
||||
# The field `__cause__` is set when raised with the exception (using the `from` keyword).
|
||||
exception_has_cause = exc_value and hasattr(exc_value, "__cause__") and exc_value.__cause__ is not None
|
||||
exception_has_cause = (
|
||||
exc_value
|
||||
and hasattr(exc_value, "__cause__")
|
||||
and exc_value.__cause__ is not None
|
||||
)
|
||||
if exception_has_cause:
|
||||
cause = exc_value.__cause__ # type: ignore
|
||||
(exception_id, child_exceptions) = exceptions_from_error(
|
||||
exc_type=type(cause),
|
||||
exc_value=cause,
|
||||
tb=getattr(cause, "__traceback__", None),
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
source="__cause__",
|
||||
@@ -601,14 +616,17 @@ def exceptions_from_error(
|
||||
else:
|
||||
# Add indirect cause.
|
||||
# The field `__context__` is assigned if another exception occurs while handling the exception.
|
||||
exception_has_content = exc_value and hasattr(exc_value, "__context__") and exc_value.__context__ is not None
|
||||
exception_has_content = (
|
||||
exc_value
|
||||
and hasattr(exc_value, "__context__")
|
||||
and exc_value.__context__ is not None
|
||||
)
|
||||
if exception_has_content:
|
||||
context = exc_value.__context__ # type: ignore
|
||||
(exception_id, child_exceptions) = exceptions_from_error(
|
||||
exc_type=type(context),
|
||||
exc_value=context,
|
||||
tb=getattr(context, "__traceback__", None),
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
source="__context__",
|
||||
@@ -623,7 +641,6 @@ def exceptions_from_error(
|
||||
exc_type=type(e),
|
||||
exc_value=e,
|
||||
tb=getattr(e, "__traceback__", None),
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
parent_id=parent_id,
|
||||
@@ -636,20 +653,20 @@ def exceptions_from_error(
|
||||
|
||||
def exceptions_from_error_tuple(
|
||||
exc_info, # type: ExcInfo
|
||||
client_options=None, # type: Optional[Dict[str, Any]]
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
):
|
||||
# type: (...) -> List[Dict[str, Any]]
|
||||
exc_type, exc_value, tb = exc_info
|
||||
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(exc_value, BaseExceptionGroup)
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(
|
||||
exc_value, BaseExceptionGroup
|
||||
)
|
||||
|
||||
if is_exception_group:
|
||||
(_, exceptions) = exceptions_from_error(
|
||||
exc_type=exc_type,
|
||||
exc_value=exc_value,
|
||||
tb=tb,
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=0,
|
||||
parent_id=0,
|
||||
@@ -658,7 +675,9 @@ def exceptions_from_error_tuple(
|
||||
else:
|
||||
exceptions = []
|
||||
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
|
||||
exceptions.append(single_exception_from_error_tuple(exc_type, exc_value, tb, client_options, mechanism))
|
||||
exceptions.append(
|
||||
single_exception_from_error_tuple(exc_type, exc_value, tb, mechanism)
|
||||
)
|
||||
|
||||
exceptions.reverse()
|
||||
|
||||
@@ -746,11 +765,42 @@ def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=No
|
||||
return frames
|
||||
|
||||
|
||||
def exception_is_already_captured(error):
|
||||
# type: (ExceptionArg) -> bool
|
||||
if isinstance(error, BaseException):
|
||||
return hasattr(error, "__insights_exception_captured")
|
||||
# Autocaptured exceptions are passed as a tuple from our system hooks,
|
||||
# the second item is the exception value (the first is the exception type)
|
||||
elif isinstance(error, tuple) and len(error) > 1:
|
||||
return error[1] is not None and hasattr(
|
||||
error[1], "__insights_exception_captured"
|
||||
)
|
||||
else:
|
||||
return False # type: ignore[unreachable]
|
||||
|
||||
|
||||
def mark_exception_as_captured(error, uuid):
|
||||
# type: (ExceptionArg, str) -> None
|
||||
if isinstance(error, BaseException):
|
||||
setattr(error, "__insights_exception_captured", True)
|
||||
setattr(error, "__insights_exception_uuid", uuid)
|
||||
# Autocaptured exceptions are passed as a tuple from our system hooks,
|
||||
# the second item is the exception value (the first is the exception type)
|
||||
elif isinstance(error, tuple) and len(error) > 1:
|
||||
if error[1] is not None:
|
||||
setattr(error[1], "__insights_exception_captured", True)
|
||||
setattr(error[1], "__insights_exception_uuid", uuid)
|
||||
|
||||
|
||||
def exc_info_from_error(error):
|
||||
# type: (Union[BaseException, ExcInfo]) -> ExcInfo
|
||||
# type: (ExceptionArg) -> ExcInfo
|
||||
if isinstance(error, tuple) and len(error) == 3:
|
||||
exc_type, exc_value, tb = error
|
||||
elif isinstance(error, BaseException):
|
||||
try:
|
||||
construct_artificial_traceback(error)
|
||||
except Exception:
|
||||
pass
|
||||
tb = getattr(error, "__traceback__", None)
|
||||
if tb is not None:
|
||||
exc_type = type(error)
|
||||
@@ -775,25 +825,33 @@ def exc_info_from_error(error):
|
||||
return exc_info
|
||||
|
||||
|
||||
def event_from_exception(
|
||||
exc_info, # type: Union[BaseException, ExcInfo]
|
||||
client_options=None, # type: Optional[Dict[str, Any]]
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
):
|
||||
# type: (...) -> Tuple[Event, Dict[str, Any]]
|
||||
exc_info = exc_info_from_error(exc_info)
|
||||
hint = event_hint_with_exc_info(exc_info)
|
||||
return (
|
||||
{
|
||||
"level": "error",
|
||||
"exception": {"values": exceptions_from_error_tuple(exc_info, client_options, mechanism)},
|
||||
},
|
||||
hint,
|
||||
)
|
||||
def construct_artificial_traceback(e):
|
||||
# type: (BaseException) -> None
|
||||
if getattr(e, "__traceback__", None) is not None:
|
||||
return
|
||||
|
||||
depth = 0
|
||||
frames = []
|
||||
while True:
|
||||
try:
|
||||
frame = sys._getframe(depth)
|
||||
depth += 1
|
||||
except ValueError:
|
||||
break
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
frames.reverse()
|
||||
|
||||
tb = None
|
||||
for frame in frames:
|
||||
tb = types.TracebackType(tb, frame, frame.f_lasti, frame.f_lineno)
|
||||
|
||||
setattr(e, "__traceback__", tb)
|
||||
|
||||
|
||||
def _module_in_list(name, items):
|
||||
# type: (str, Optional[List[str]]) -> bool
|
||||
# type: (str | None, Optional[List[str]]) -> bool
|
||||
if name is None:
|
||||
return False
|
||||
|
||||
@@ -810,7 +868,9 @@ def _module_in_list(name, items):
|
||||
def _is_external_source(abs_path):
|
||||
# type: (str) -> bool
|
||||
# check if frame is in 'site-packages' or 'dist-packages'
|
||||
external_source = re.search(r"[\\/](?:dist|site)-packages[\\/]", abs_path) is not None
|
||||
external_source = (
|
||||
re.search(r"[\\/](?:dist|site)-packages[\\/]", abs_path) is not None
|
||||
)
|
||||
return external_source
|
||||
|
||||
|
||||
@@ -871,3 +931,258 @@ 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
|
||||
@@ -0,0 +1,844 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import warnings
|
||||
from typing import Optional
|
||||
|
||||
from dateutil import parser
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from hanzo_insights import utils
|
||||
from hanzo_insights.types import FlagValue
|
||||
from hanzo_insights.utils import convert_to_datetime_aware, is_valid_regex
|
||||
|
||||
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
|
||||
log = logging.getLogger("hanzo_insights")
|
||||
|
||||
NONE_VALUES_ALLOWED_OPERATORS = ["is_not"]
|
||||
|
||||
# All operators supported by match_property, grouped by category.
|
||||
EQUALITY_OPERATORS = ("exact", "is_not", "is_set", "is_not_set")
|
||||
STRING_OPERATORS = ("icontains", "not_icontains", "regex", "not_regex")
|
||||
NUMERIC_OPERATORS = ("gt", "gte", "lt", "lte")
|
||||
DATE_OPERATORS = ("is_date_before", "is_date_after")
|
||||
SEMVER_COMPARISON_OPERATORS = (
|
||||
"semver_eq",
|
||||
"semver_neq",
|
||||
"semver_gt",
|
||||
"semver_gte",
|
||||
"semver_lt",
|
||||
"semver_lte",
|
||||
)
|
||||
SEMVER_RANGE_OPERATORS = ("semver_tilde", "semver_caret", "semver_wildcard")
|
||||
SEMVER_OPERATORS = SEMVER_COMPARISON_OPERATORS + SEMVER_RANGE_OPERATORS
|
||||
|
||||
PROPERTY_OPERATORS = (
|
||||
EQUALITY_OPERATORS
|
||||
+ STRING_OPERATORS
|
||||
+ NUMERIC_OPERATORS
|
||||
+ DATE_OPERATORS
|
||||
+ SEMVER_OPERATORS
|
||||
)
|
||||
|
||||
|
||||
class InconclusiveMatchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RequiresServerEvaluation(Exception):
|
||||
"""
|
||||
Raised when feature flag evaluation requires server-side data that is not
|
||||
available locally (e.g., static cohorts, experience continuity).
|
||||
|
||||
This error should propagate immediately to trigger API fallback, unlike
|
||||
InconclusiveMatchError which allows trying other conditions.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# This function takes a 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
|
||||
# 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}"
|
||||
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")
|
||||
for variant in variant_lookup_table(flag):
|
||||
if hash_value >= variant["value_min"] and hash_value < variant["value_max"]:
|
||||
return variant["key"]
|
||||
return None
|
||||
|
||||
|
||||
def variant_lookup_table(feature_flag):
|
||||
lookup_table = []
|
||||
value_min = 0
|
||||
multivariates = ((feature_flag.get("filters") or {}).get("multivariate") or {}).get(
|
||||
"variants"
|
||||
) or []
|
||||
for variant in multivariates:
|
||||
value_max = value_min + variant["rollout_percentage"] / 100
|
||||
lookup_table.append(
|
||||
{"value_min": value_min, "value_max": value_max, "key": variant["key"]}
|
||||
)
|
||||
value_min = value_max
|
||||
return lookup_table
|
||||
|
||||
|
||||
def evaluate_flag_dependency(
|
||||
property,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
properties,
|
||||
cohort_properties,
|
||||
device_id=None,
|
||||
):
|
||||
"""
|
||||
Evaluate a flag dependency property according to the dependency chain algorithm.
|
||||
|
||||
Args:
|
||||
property: Flag property with type="flag" and dependency_chain
|
||||
flags_by_key: Dictionary of all flags by their key
|
||||
evaluation_cache: Cache for storing evaluation results
|
||||
distinct_id: The distinct ID being evaluated
|
||||
properties: Person properties for evaluation
|
||||
cohort_properties: Cohort properties for evaluation
|
||||
device_id: The device ID for bucketing (optional)
|
||||
|
||||
Returns:
|
||||
bool: True if all dependencies in the chain evaluate to True, False otherwise
|
||||
"""
|
||||
if flags_by_key is None or evaluation_cache is None:
|
||||
# Cannot evaluate flag dependencies without required context
|
||||
raise InconclusiveMatchError(
|
||||
f"Cannot evaluate flag dependency on '{property.get('key', 'unknown')}' without flags_by_key and evaluation_cache"
|
||||
)
|
||||
|
||||
# Check if dependency_chain is present - it should always be provided for flag dependencies
|
||||
if "dependency_chain" not in property:
|
||||
# Missing dependency_chain indicates malformed server data
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag dependency property for '{property.get('key', 'unknown')}' is missing required 'dependency_chain' field"
|
||||
)
|
||||
|
||||
dependency_chain = property["dependency_chain"]
|
||||
|
||||
# Handle circular dependency (empty chain means circular)
|
||||
if len(dependency_chain) == 0:
|
||||
log.debug(f"Circular dependency detected for flag: {property.get('key')}")
|
||||
raise InconclusiveMatchError(
|
||||
f"Circular dependency detected for flag '{property.get('key', 'unknown')}'"
|
||||
)
|
||||
|
||||
# Evaluate all dependencies in the chain order
|
||||
for dep_flag_key in dependency_chain:
|
||||
if dep_flag_key not in evaluation_cache:
|
||||
# Need to evaluate this dependency first
|
||||
dep_flag = flags_by_key.get(dep_flag_key)
|
||||
if not dep_flag:
|
||||
# Missing flag dependency - cannot evaluate locally
|
||||
evaluation_cache[dep_flag_key] = None
|
||||
raise InconclusiveMatchError(
|
||||
f"Cannot evaluate flag dependency '{dep_flag_key}' - flag not found in local flags"
|
||||
)
|
||||
else:
|
||||
# Check if the flag is active (same check as in client._compute_flag_locally)
|
||||
if not dep_flag.get("active"):
|
||||
evaluation_cache[dep_flag_key] = False
|
||||
else:
|
||||
# Recursively evaluate the dependency
|
||||
try:
|
||||
dep_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,
|
||||
)
|
||||
evaluation_cache[dep_flag_key] = dep_result
|
||||
except InconclusiveMatchError as e:
|
||||
# If we can't evaluate a dependency, store None and propagate the error
|
||||
evaluation_cache[dep_flag_key] = None
|
||||
raise InconclusiveMatchError(
|
||||
f"Cannot evaluate flag dependency '{dep_flag_key}': {e}"
|
||||
) from e
|
||||
|
||||
# Check the cached result
|
||||
cached_result = evaluation_cache[dep_flag_key]
|
||||
if cached_result is None:
|
||||
# Previously inconclusive - raise error again
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag dependency '{dep_flag_key}' was previously inconclusive"
|
||||
)
|
||||
elif not cached_result:
|
||||
# Definitive False result - dependency failed
|
||||
return False
|
||||
|
||||
# All dependencies in the chain have been evaluated successfully
|
||||
# Now check if the final flag value matches the expected value in the property
|
||||
flag_key = property.get("key")
|
||||
expected_value = property.get("value")
|
||||
operator = property.get("operator", "exact")
|
||||
|
||||
if flag_key and expected_value is not None:
|
||||
# Get the actual value of the flag we're checking
|
||||
actual_value = evaluation_cache.get(flag_key)
|
||||
|
||||
if actual_value is None:
|
||||
# Flag wasn't evaluated - this shouldn't happen if dependency chain is correct
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag '{flag_key}' was not evaluated despite being in dependency chain"
|
||||
)
|
||||
|
||||
# For flag dependencies, we need to compare the actual flag result with expected value
|
||||
# using the flag_evaluates_to operator logic
|
||||
if operator == "flag_evaluates_to":
|
||||
return matches_dependency_value(expected_value, actual_value)
|
||||
else:
|
||||
# This should never happen, but just to be defensive.
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag dependency property for '{property.get('key', 'unknown')}' has invalid operator '{operator}'"
|
||||
)
|
||||
|
||||
# If no value check needed, return True (all dependencies passed)
|
||||
return True
|
||||
|
||||
|
||||
def matches_dependency_value(expected_value, actual_value):
|
||||
"""
|
||||
Check if the actual flag value matches the expected dependency value.
|
||||
|
||||
This follows the same logic as the C# MatchesDependencyValue function:
|
||||
- String variant case: check for exact match or boolean true
|
||||
- Boolean case: must match expected boolean value
|
||||
|
||||
Args:
|
||||
expected_value: The expected value from the property
|
||||
actual_value: The actual value returned by the flag evaluation
|
||||
|
||||
Returns:
|
||||
bool: True if the values match according to flag dependency rules
|
||||
"""
|
||||
# String variant case - check for exact match or boolean true
|
||||
if isinstance(actual_value, str) and len(actual_value) > 0:
|
||||
if isinstance(expected_value, bool):
|
||||
# Any variant matches boolean true
|
||||
return expected_value
|
||||
elif isinstance(expected_value, str):
|
||||
# variants are case-sensitive, hence our comparison is too
|
||||
return actual_value == expected_value
|
||||
else:
|
||||
return False
|
||||
|
||||
# Boolean case - must match expected boolean value
|
||||
elif isinstance(actual_value, bool) and isinstance(expected_value, bool):
|
||||
return actual_value == expected_value
|
||||
|
||||
# Default case
|
||||
return False
|
||||
|
||||
|
||||
def 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 []
|
||||
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 []
|
||||
valid_variant_keys = [variant["key"] for variant in flag_variants]
|
||||
|
||||
for condition in flag_conditions:
|
||||
try:
|
||||
# if any one condition resolves to True, we can shortcircuit and return
|
||||
# the matching variant
|
||||
if is_condition_match(
|
||||
flag,
|
||||
distinct_id,
|
||||
condition,
|
||||
properties,
|
||||
cohort_properties,
|
||||
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)
|
||||
return variant or True
|
||||
except RequiresServerEvaluation:
|
||||
# Static cohort or other missing server-side data - must fallback to API
|
||||
raise
|
||||
except InconclusiveMatchError:
|
||||
# Evaluation error (bad regex, invalid date, missing property, etc.)
|
||||
# Track that we had an inconclusive match, but try other conditions
|
||||
is_inconclusive = True
|
||||
|
||||
if is_inconclusive:
|
||||
raise InconclusiveMatchError(
|
||||
"Can't determine if feature flag is enabled or not with given properties"
|
||||
)
|
||||
|
||||
# We can only return False when either all conditions are False, or
|
||||
# no condition was inconclusive.
|
||||
return False
|
||||
|
||||
|
||||
def is_condition_match(
|
||||
feature_flag,
|
||||
distinct_id,
|
||||
condition,
|
||||
properties,
|
||||
cohort_properties,
|
||||
flags_by_key=None,
|
||||
evaluation_cache=None,
|
||||
*,
|
||||
bucketing_value,
|
||||
device_id=None,
|
||||
) -> bool:
|
||||
rollout_percentage = condition.get("rollout_percentage")
|
||||
if len(condition.get("properties") or []) > 0:
|
||||
for prop in condition.get("properties"):
|
||||
property_type = prop.get("type")
|
||||
if property_type == "cohort":
|
||||
matches = match_cohort(
|
||||
prop,
|
||||
properties,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
device_id=device_id,
|
||||
)
|
||||
elif property_type == "flag":
|
||||
matches = evaluate_flag_dependency(
|
||||
prop,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
properties,
|
||||
cohort_properties,
|
||||
device_id=device_id,
|
||||
)
|
||||
else:
|
||||
matches = match_property(prop, properties)
|
||||
if not matches:
|
||||
return False
|
||||
|
||||
if rollout_percentage is None:
|
||||
return True
|
||||
|
||||
if rollout_percentage is not None and _hash(
|
||||
feature_flag["key"], bucketing_value
|
||||
) > (rollout_percentage / 100):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def match_property(property, property_values) -> bool:
|
||||
# only looks for matches where key exists in override_property_values
|
||||
# doesn't support operator is_not_set
|
||||
key = property.get("key")
|
||||
operator = property.get("operator") or "exact"
|
||||
value = property.get("value")
|
||||
|
||||
if operator not in PROPERTY_OPERATORS:
|
||||
raise InconclusiveMatchError(f"Unknown operator {operator}")
|
||||
|
||||
if key not in property_values:
|
||||
raise InconclusiveMatchError(
|
||||
"can't match properties without a given property value"
|
||||
)
|
||||
|
||||
if operator == "is_not_set":
|
||||
raise InconclusiveMatchError("can't match properties with operator is_not_set")
|
||||
|
||||
override_value = property_values[key]
|
||||
|
||||
if (operator not in NONE_VALUES_ALLOWED_OPERATORS) and override_value is None:
|
||||
return False
|
||||
|
||||
if operator in ("exact", "is_not"):
|
||||
|
||||
def compute_exact_match(value, override_value):
|
||||
if isinstance(value, list):
|
||||
return str(override_value).casefold() in [
|
||||
str(val).casefold() for val in value
|
||||
]
|
||||
return utils.str_iequals(value, override_value)
|
||||
|
||||
if operator == "exact":
|
||||
return compute_exact_match(value, override_value)
|
||||
else:
|
||||
return not compute_exact_match(value, override_value)
|
||||
|
||||
if operator == "is_set":
|
||||
return key in property_values
|
||||
|
||||
if operator == "icontains":
|
||||
return utils.str_icontains(override_value, value)
|
||||
|
||||
if operator == "not_icontains":
|
||||
return not utils.str_icontains(override_value, value)
|
||||
|
||||
if operator == "regex":
|
||||
return (
|
||||
is_valid_regex(str(value))
|
||||
and re.compile(str(value)).search(str(override_value)) is not None
|
||||
)
|
||||
|
||||
if operator == "not_regex":
|
||||
return (
|
||||
is_valid_regex(str(value))
|
||||
and re.compile(str(value)).search(str(override_value)) is None
|
||||
)
|
||||
|
||||
if operator in ("gt", "gte", "lt", "lte"):
|
||||
# :TRICKY: We adjust comparison based on the override value passed in,
|
||||
# to make sure we handle both numeric and string comparisons appropriately.
|
||||
def compare(lhs, rhs, operator):
|
||||
if operator == "gt":
|
||||
return lhs > rhs
|
||||
elif operator == "gte":
|
||||
return lhs >= rhs
|
||||
elif operator == "lt":
|
||||
return lhs < rhs
|
||||
elif operator == "lte":
|
||||
return lhs <= rhs
|
||||
else:
|
||||
raise ValueError(f"Invalid operator: {operator}")
|
||||
|
||||
parsed_value = None
|
||||
try:
|
||||
parsed_value = float(value) # type: ignore
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if parsed_value is not None and override_value is not None:
|
||||
if isinstance(override_value, str):
|
||||
return compare(override_value, str(value), operator)
|
||||
else:
|
||||
return compare(override_value, parsed_value, operator)
|
||||
else:
|
||||
return compare(str(override_value), str(value), operator)
|
||||
|
||||
if operator in ["is_date_before", "is_date_after"]:
|
||||
try:
|
||||
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
|
||||
|
||||
if not parsed_date:
|
||||
parsed_date = parser.parse(str(value))
|
||||
parsed_date = convert_to_datetime_aware(parsed_date)
|
||||
except Exception as e:
|
||||
raise InconclusiveMatchError(
|
||||
"The date set on the flag is not a valid format"
|
||||
) from e
|
||||
|
||||
if not parsed_date:
|
||||
raise InconclusiveMatchError(
|
||||
"The date set on the flag is not a valid format"
|
||||
)
|
||||
|
||||
if isinstance(override_value, datetime.datetime):
|
||||
override_date = convert_to_datetime_aware(override_value)
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
elif isinstance(override_value, datetime.date):
|
||||
if operator == "is_date_before":
|
||||
return override_value < parsed_date.date()
|
||||
else:
|
||||
return override_value > parsed_date.date()
|
||||
elif isinstance(override_value, str):
|
||||
try:
|
||||
override_date = parser.parse(override_value)
|
||||
override_date = convert_to_datetime_aware(override_date)
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
except Exception:
|
||||
raise InconclusiveMatchError("The date provided is not a valid format")
|
||||
else:
|
||||
raise InconclusiveMatchError(
|
||||
"The date provided must be a string or date object"
|
||||
)
|
||||
|
||||
if operator in SEMVER_OPERATORS:
|
||||
try:
|
||||
override_parsed = parse_semver(override_value)
|
||||
except (ValueError, TypeError):
|
||||
raise InconclusiveMatchError(
|
||||
f"Person property value '{override_value}' is not a valid semver"
|
||||
)
|
||||
|
||||
if operator in SEMVER_COMPARISON_OPERATORS:
|
||||
try:
|
||||
flag_parsed = parse_semver(value)
|
||||
except (ValueError, TypeError):
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag semver value '{value}' is not a valid semver"
|
||||
)
|
||||
|
||||
if operator == "semver_eq":
|
||||
return override_parsed == flag_parsed
|
||||
elif operator == "semver_neq":
|
||||
return override_parsed != flag_parsed
|
||||
elif operator == "semver_gt":
|
||||
return override_parsed > flag_parsed
|
||||
elif operator == "semver_gte":
|
||||
return override_parsed >= flag_parsed
|
||||
elif operator == "semver_lt":
|
||||
return override_parsed < flag_parsed
|
||||
elif operator == "semver_lte":
|
||||
return override_parsed <= flag_parsed
|
||||
|
||||
elif operator == "semver_tilde":
|
||||
try:
|
||||
lower, upper = _tilde_bounds(str(value))
|
||||
except (ValueError, TypeError):
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag semver value '{value}' is not valid for tilde operator"
|
||||
)
|
||||
return lower <= override_parsed < upper
|
||||
|
||||
elif operator == "semver_caret":
|
||||
try:
|
||||
lower, upper = _caret_bounds(str(value))
|
||||
except (ValueError, TypeError):
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag semver value '{value}' is not valid for caret operator"
|
||||
)
|
||||
return lower <= override_parsed < upper
|
||||
|
||||
elif operator == "semver_wildcard":
|
||||
try:
|
||||
lower, upper = _wildcard_bounds(str(value))
|
||||
except (ValueError, TypeError):
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag semver value '{value}' is not valid for wildcard operator"
|
||||
)
|
||||
return lower <= override_parsed < upper
|
||||
|
||||
# Unreachable: all operators in PROPERTY_OPERATORS are handled above,
|
||||
# and unknown operators are rejected at the top of this function.
|
||||
raise InconclusiveMatchError(f"Unknown operator {operator}")
|
||||
|
||||
|
||||
def match_cohort(
|
||||
property,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
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:
|
||||
# {
|
||||
# "cohort_id": {
|
||||
# "type": "AND|OR",
|
||||
# "values": [{
|
||||
# "key": "property_name", "value": "property_value"
|
||||
# }]
|
||||
# }
|
||||
# }
|
||||
cohort_id = str(property.get("value"))
|
||||
if cohort_id not in cohort_properties:
|
||||
raise RequiresServerEvaluation(
|
||||
f"cohort {cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
|
||||
)
|
||||
|
||||
property_group = cohort_properties[cohort_id]
|
||||
return match_property_group(
|
||||
property_group,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
|
||||
def match_property_group(
|
||||
property_group,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
flags_by_key=None,
|
||||
evaluation_cache=None,
|
||||
distinct_id=None,
|
||||
device_id=None,
|
||||
) -> bool:
|
||||
if not property_group:
|
||||
return True
|
||||
|
||||
property_group_type = property_group.get("type")
|
||||
properties = property_group.get("values")
|
||||
|
||||
if not properties or len(properties) == 0:
|
||||
# empty groups are no-ops, always match
|
||||
return True
|
||||
|
||||
error_matching_locally = False
|
||||
|
||||
if "values" in properties[0]:
|
||||
# a nested property group
|
||||
for prop in properties:
|
||||
try:
|
||||
matches = match_property_group(
|
||||
prop,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
device_id=device_id,
|
||||
)
|
||||
if property_group_type == "AND":
|
||||
if not matches:
|
||||
return False
|
||||
else:
|
||||
# OR group
|
||||
if matches:
|
||||
return True
|
||||
except RequiresServerEvaluation:
|
||||
# Immediately propagate - this condition requires server-side data
|
||||
raise
|
||||
except InconclusiveMatchError as e:
|
||||
log.debug(f"Failed to compute property {prop} locally: {e}")
|
||||
error_matching_locally = True
|
||||
|
||||
if error_matching_locally:
|
||||
raise InconclusiveMatchError(
|
||||
"Can't match cohort without a given cohort property value"
|
||||
)
|
||||
# if we get here, all matched in AND case, or none matched in OR case
|
||||
return property_group_type == "AND"
|
||||
|
||||
else:
|
||||
for prop in properties:
|
||||
try:
|
||||
if prop.get("type") == "cohort":
|
||||
matches = match_cohort(
|
||||
prop,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
device_id=device_id,
|
||||
)
|
||||
elif prop.get("type") == "flag":
|
||||
matches = evaluate_flag_dependency(
|
||||
prop,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
device_id=device_id,
|
||||
)
|
||||
else:
|
||||
matches = match_property(prop, property_values)
|
||||
|
||||
negation = prop.get("negation", False)
|
||||
|
||||
if property_group_type == "AND":
|
||||
# if negated property, do the inverse
|
||||
if not matches and not negation:
|
||||
return False
|
||||
if matches and negation:
|
||||
return False
|
||||
else:
|
||||
# OR group
|
||||
if matches and not negation:
|
||||
return True
|
||||
if not matches and negation:
|
||||
return True
|
||||
except RequiresServerEvaluation:
|
||||
# Immediately propagate - this condition requires server-side data
|
||||
raise
|
||||
except InconclusiveMatchError as e:
|
||||
log.debug(f"Failed to compute property {prop} locally: {e}")
|
||||
error_matching_locally = True
|
||||
|
||||
if error_matching_locally:
|
||||
raise InconclusiveMatchError(
|
||||
"can't match cohort without a given cohort property value"
|
||||
)
|
||||
|
||||
# if we get here, all matched in AND case, or none matched in OR case
|
||||
return property_group_type == "AND"
|
||||
|
||||
|
||||
def relative_date_parse_for_feature_flag_matching(
|
||||
value: str,
|
||||
) -> Optional[datetime.datetime]:
|
||||
regex = r"^-?(?P<number>[0-9]+)(?P<interval>[a-z])$"
|
||||
match = re.search(regex, value)
|
||||
parsed_dt = datetime.datetime.now(datetime.timezone.utc)
|
||||
if match:
|
||||
number = int(match.group("number"))
|
||||
|
||||
if number >= 10_000:
|
||||
# Guard against overflow, disallow numbers greater than 10_000
|
||||
return None
|
||||
|
||||
interval = match.group("interval")
|
||||
if interval == "h":
|
||||
parsed_dt = parsed_dt - relativedelta(hours=number)
|
||||
elif interval == "d":
|
||||
parsed_dt = parsed_dt - relativedelta(days=number)
|
||||
elif interval == "w":
|
||||
parsed_dt = parsed_dt - relativedelta(weeks=number)
|
||||
elif interval == "m":
|
||||
parsed_dt = parsed_dt - relativedelta(months=number)
|
||||
elif interval == "y":
|
||||
parsed_dt = parsed_dt - relativedelta(years=number)
|
||||
else:
|
||||
return None
|
||||
|
||||
return parsed_dt
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def parse_semver(value: str) -> tuple:
|
||||
"""Parse a semver string into a comparable (major, minor, patch) integer tuple.
|
||||
|
||||
Matches the behavior of the sortableSemver HogQL function:
|
||||
- Handles v-prefix, whitespace, pre-release suffixes
|
||||
- Defaults missing components to 0 (e.g., 1.2 -> 1.2.0)
|
||||
Raises ValueError if parsing fails.
|
||||
"""
|
||||
text = str(value).strip().lstrip("vV")
|
||||
# Strip pre-release/build metadata suffix
|
||||
text = text.split("-")[0].split("+")[0]
|
||||
parts = text.split(".")
|
||||
|
||||
if not parts or not parts[0]:
|
||||
raise ValueError("Invalid semver format")
|
||||
|
||||
major = int(parts[0])
|
||||
minor = int(parts[1]) if len(parts) > 1 and parts[1] else 0
|
||||
patch = int(parts[2]) if len(parts) > 2 and parts[2] else 0
|
||||
|
||||
return (major, minor, patch)
|
||||
|
||||
|
||||
def _tilde_bounds(value: str) -> tuple:
|
||||
"""~1.2.3 means >=1.2.3 <1.3.0 (allows patch-level changes)."""
|
||||
major, minor, patch = parse_semver(value)
|
||||
return (major, minor, patch), (major, minor + 1, 0)
|
||||
|
||||
|
||||
def _caret_bounds(value: str) -> tuple:
|
||||
"""Caret follows semver spec:
|
||||
^1.2.3 means >=1.2.3 <2.0.0
|
||||
^0.2.3 means >=0.2.3 <0.3.0
|
||||
^0.0.3 means >=0.0.3 <0.0.4
|
||||
"""
|
||||
major, minor, patch = parse_semver(value)
|
||||
lower = (major, minor, patch)
|
||||
|
||||
if major > 0:
|
||||
upper = (major + 1, 0, 0)
|
||||
elif minor > 0:
|
||||
upper = (0, minor + 1, 0)
|
||||
else:
|
||||
upper = (0, 0, patch + 1)
|
||||
|
||||
return lower, upper
|
||||
|
||||
|
||||
def _wildcard_bounds(value: str) -> tuple:
|
||||
"""Wildcard matching:
|
||||
1.* means >=1.0.0 <2.0.0
|
||||
1.2.* means >=1.2.0 <1.3.0
|
||||
"""
|
||||
cleaned = str(value).strip().lstrip("vV").replace("*", "").rstrip(".")
|
||||
if not cleaned:
|
||||
raise ValueError("Invalid wildcard pattern")
|
||||
|
||||
parts = [p for p in cleaned.split(".") if p]
|
||||
if not parts:
|
||||
raise ValueError("Invalid wildcard pattern")
|
||||
|
||||
if len(parts) == 1:
|
||||
major = int(parts[0])
|
||||
return (major, 0, 0), (major + 1, 0, 0)
|
||||
elif len(parts) == 2:
|
||||
major, minor = int(parts[0]), int(parts[1])
|
||||
return (major, minor, 0), (major, minor + 1, 0)
|
||||
else:
|
||||
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
return (major, minor, patch), (major, minor, patch + 1)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Flag Definition Cache Provider interface for multi-worker environments.
|
||||
|
||||
EXPERIMENTAL: This API may change in future minor version bumps.
|
||||
|
||||
This module provides an interface for external caching of feature flag definitions,
|
||||
enabling multi-worker environments (Kubernetes, load-balanced servers, serverless
|
||||
functions) to share flag definitions and reduce API calls.
|
||||
|
||||
Usage:
|
||||
|
||||
from hanzo_insights import Insights
|
||||
from hanzo_insights.flag_definition_cache import FlagDefinitionCacheProvider
|
||||
|
||||
cache = RedisFlagDefinitionCache(redis_client, "my-team")
|
||||
client = Insights(
|
||||
"<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 Insights 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 Insights.
|
||||
|
||||
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 Insights client shuts down.
|
||||
|
||||
Use this to release any distributed locks and clean up resources.
|
||||
This method is called even if `should_fetch_flag_definitions()`
|
||||
returned False, so implementations should handle the case where
|
||||
no lock was acquired.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,321 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from hanzo_insights import contexts
|
||||
from hanzo_insights.client import Client
|
||||
|
||||
try:
|
||||
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
||||
except ImportError:
|
||||
# Fallback for older Django versions without asgiref
|
||||
import asyncio
|
||||
|
||||
iscoroutinefunction = asyncio.iscoroutinefunction
|
||||
|
||||
# No-op fallback for markcoroutinefunction
|
||||
# Older Django versions without asgiref typically don't support async middleware anyway
|
||||
def markcoroutinefunction(func):
|
||||
return func
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.http import HttpRequest, HttpResponse # noqa: F401
|
||||
from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
|
||||
|
||||
|
||||
class InsightsContextMiddleware:
|
||||
"""Middleware to automatically track Django requests.
|
||||
|
||||
This middleware wraps all calls with an Insights context. It attempts to extract the following from the request headers:
|
||||
- Session ID, (extracted from `X-INSIGHTS-SESSION-ID`)
|
||||
- Distinct ID, (extracted from `X-INSIGHTS-DISTINCT-ID`)
|
||||
- Request URL as $current_url
|
||||
- Request Method as $request_method
|
||||
|
||||
The context will also auto-capture exceptions and send them to Insights, unless you disable it by setting
|
||||
`INSIGHTS_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings.
|
||||
The exceptions are captured using the global client, unless the setting `INSIGHTS_MW_CLIENT`
|
||||
is set to a custom client instance.
|
||||
|
||||
The middleware behaviour is customisable through 3 additional functions:
|
||||
- `INSIGHTS_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
|
||||
- `INSIGHTS_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked.
|
||||
- `INSIGHTS_MW_TAG_MAP`, which is a Callable[[Dict[str, Any]], Dict[str, Any]], which you can use to modify the tags before they're added to the context.
|
||||
|
||||
You can use the `INSIGHTS_MW_TAG_MAP` function to remove any default tags you don't want to capture, or override them with your own values.
|
||||
|
||||
Context tags are automatically included as properties on all events captured within a context, including exceptions.
|
||||
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
|
||||
associate all events captured in the middleware context with the same distinct ID and session as currently active on the
|
||||
frontend. See the documentation for `set_context_session` and `identify_context` for more details.
|
||||
|
||||
This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware
|
||||
detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly.
|
||||
This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode.
|
||||
"""
|
||||
|
||||
sync_capable = True
|
||||
async_capable = True
|
||||
|
||||
def __init__(self, get_response):
|
||||
# type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None
|
||||
self.get_response = get_response
|
||||
self._is_coroutine = iscoroutinefunction(get_response)
|
||||
|
||||
# Mark this instance as a coroutine function if get_response is async
|
||||
# This is required for Django to correctly detect async middleware
|
||||
if self._is_coroutine:
|
||||
markcoroutinefunction(self)
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
def _get_setting(name):
|
||||
insights_name = f"INSIGHTS_MW_{name}"
|
||||
if hasattr(settings, insights_name):
|
||||
return getattr(settings, insights_name)
|
||||
return None
|
||||
|
||||
extra_tags = _get_setting("EXTRA_TAGS")
|
||||
if extra_tags and callable(extra_tags):
|
||||
self.extra_tags = cast(
|
||||
"Optional[Callable[[HttpRequest], Dict[str, Any]]]",
|
||||
extra_tags,
|
||||
)
|
||||
else:
|
||||
self.extra_tags = None
|
||||
|
||||
request_filter = _get_setting("REQUEST_FILTER")
|
||||
if request_filter and callable(request_filter):
|
||||
self.request_filter = cast(
|
||||
"Optional[Callable[[HttpRequest], bool]]",
|
||||
request_filter,
|
||||
)
|
||||
else:
|
||||
self.request_filter = None
|
||||
|
||||
tag_map = _get_setting("TAG_MAP")
|
||||
if tag_map and callable(tag_map):
|
||||
self.tag_map = cast(
|
||||
"Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]",
|
||||
tag_map,
|
||||
)
|
||||
else:
|
||||
self.tag_map = None
|
||||
|
||||
capture_exceptions = _get_setting("CAPTURE_EXCEPTIONS")
|
||||
if isinstance(capture_exceptions, bool):
|
||||
self.capture_exceptions = capture_exceptions
|
||||
else:
|
||||
self.capture_exceptions = True
|
||||
|
||||
mw_client = _get_setting("CLIENT")
|
||||
if isinstance(mw_client, Client):
|
||||
self.client = cast("Optional[Client]", mw_client)
|
||||
else:
|
||||
self.client = None
|
||||
|
||||
def extract_tags(self, request):
|
||||
# type: (HttpRequest) -> Dict[str, Any]
|
||||
"""Extract tags from request in sync context."""
|
||||
user_id, user_email = self.extract_request_user(request)
|
||||
return self._build_tags(request, user_id, user_email)
|
||||
|
||||
def _build_tags(self, request, user_id, user_email):
|
||||
# type: (HttpRequest, Optional[str], Optional[str]) -> Dict[str, Any]
|
||||
"""
|
||||
Build tags dict from request and user info.
|
||||
|
||||
Centralized tag extraction logic used by both sync and async paths.
|
||||
"""
|
||||
tags = {}
|
||||
|
||||
# Extract session ID from X-INSIGHTS-SESSION-ID header
|
||||
session_id = request.headers.get("X-INSIGHTS-SESSION-ID")
|
||||
if session_id:
|
||||
contexts.set_context_session(session_id)
|
||||
|
||||
# Extract distinct ID from X-INSIGHTS-DISTINCT-ID header or request user id
|
||||
distinct_id = request.headers.get("X-INSIGHTS-DISTINCT-ID") or user_id
|
||||
if distinct_id:
|
||||
contexts.identify_context(distinct_id)
|
||||
|
||||
# Extract user email
|
||||
if user_email:
|
||||
tags["email"] = user_email
|
||||
|
||||
# Extract current URL
|
||||
absolute_url = request.build_absolute_uri()
|
||||
if absolute_url:
|
||||
tags["$current_url"] = absolute_url
|
||||
|
||||
# Extract request method
|
||||
if request.method:
|
||||
tags["$request_method"] = request.method
|
||||
|
||||
# Extract request path
|
||||
if request.path:
|
||||
tags["$request_path"] = request.path
|
||||
|
||||
# Extract IP address
|
||||
ip_address = request.headers.get("X-Forwarded-For")
|
||||
if ip_address:
|
||||
tags["$ip"] = ip_address
|
||||
|
||||
# Extract user agent
|
||||
user_agent = request.headers.get("User-Agent")
|
||||
if user_agent:
|
||||
tags["$user_agent"] = user_agent
|
||||
|
||||
# Apply extra tags if configured
|
||||
if self.extra_tags:
|
||||
extra = self.extra_tags(request)
|
||||
if extra:
|
||||
tags.update(extra)
|
||||
|
||||
# Apply tag mapping if configured
|
||||
if self.tag_map:
|
||||
tags = self.tag_map(tags)
|
||||
|
||||
return tags
|
||||
|
||||
def extract_request_user(self, request):
|
||||
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
|
||||
"""Extract user ID and email from request in sync context."""
|
||||
user = getattr(request, "user", None)
|
||||
return self._resolve_user_details(user)
|
||||
|
||||
async def aextract_tags(self, request):
|
||||
# type: (HttpRequest) -> Dict[str, Any]
|
||||
"""
|
||||
Async version of extract_tags for use in async request handling.
|
||||
|
||||
Uses await request.auser() instead of request.user to avoid
|
||||
SynchronousOnlyOperation in async context.
|
||||
|
||||
Follows Django's naming convention for async methods (auser, asave, etc.).
|
||||
"""
|
||||
user_id, user_email = await self.aextract_request_user(request)
|
||||
return self._build_tags(request, user_id, user_email)
|
||||
|
||||
async def aextract_request_user(self, request):
|
||||
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
|
||||
"""
|
||||
Async version of extract_request_user for use in async request handling.
|
||||
|
||||
Uses await request.auser() instead of request.user to avoid
|
||||
SynchronousOnlyOperation in async context.
|
||||
|
||||
Follows Django's naming convention for async methods (auser, asave, etc.).
|
||||
"""
|
||||
auser = getattr(request, "auser", None)
|
||||
if callable(auser):
|
||||
try:
|
||||
user = await auser()
|
||||
return self._resolve_user_details(user)
|
||||
except Exception:
|
||||
# If auser() fails, return empty - don't break the request
|
||||
# Real errors (permissions, broken auth) will be logged by Django
|
||||
return None, None
|
||||
|
||||
# Fallback for test requests without auser
|
||||
return None, None
|
||||
|
||||
def _resolve_user_details(self, user):
|
||||
# type: (Any) -> tuple[Optional[str], Optional[str]]
|
||||
"""
|
||||
Extract user ID and email from a user object.
|
||||
|
||||
Handles both authenticated and unauthenticated users, as well as
|
||||
legacy Django where is_authenticated was a method.
|
||||
"""
|
||||
user_id = None
|
||||
email = None
|
||||
|
||||
if user is None:
|
||||
return user_id, email
|
||||
|
||||
# Handle is_authenticated (property in modern Django, method in legacy)
|
||||
is_authenticated = getattr(user, "is_authenticated", False)
|
||||
if callable(is_authenticated):
|
||||
is_authenticated = is_authenticated()
|
||||
|
||||
if not is_authenticated:
|
||||
return user_id, email
|
||||
|
||||
# Extract user primary key
|
||||
user_pk = getattr(user, "pk", None)
|
||||
if user_pk is not None:
|
||||
user_id = str(user_pk)
|
||||
|
||||
# Extract user email
|
||||
user_email = getattr(user, "email", None)
|
||||
if user_email:
|
||||
email = str(user_email)
|
||||
|
||||
return user_id, email
|
||||
|
||||
def __call__(self, request):
|
||||
# type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]
|
||||
"""
|
||||
Unified entry point for both sync and async request handling.
|
||||
|
||||
When sync_capable and async_capable are both True, Django passes requests
|
||||
without conversion. This method detects the mode and routes accordingly.
|
||||
"""
|
||||
if self._is_coroutine:
|
||||
return self.__acall__(request)
|
||||
else:
|
||||
# Synchronous path
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return self.get_response(request)
|
||||
|
||||
with contexts.new_context(self.capture_exceptions, client=self.client):
|
||||
for k, v in self.extract_tags(request).items():
|
||||
contexts.tag(k, v)
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
async def __acall__(self, request):
|
||||
# type: (HttpRequest) -> Awaitable[HttpResponse]
|
||||
"""
|
||||
Asynchronous entry point for async request handling.
|
||||
|
||||
This method is called when the middleware chain is async.
|
||||
Uses aextract_tags() which calls request.auser() to avoid
|
||||
SynchronousOnlyOperation when accessing user in async context.
|
||||
"""
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return await self.get_response(request)
|
||||
|
||||
with contexts.new_context(self.capture_exceptions, client=self.client):
|
||||
for k, v in (await self.aextract_tags(request)).items():
|
||||
contexts.tag(k, v)
|
||||
|
||||
return await self.get_response(request)
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
# type: (HttpRequest, Exception) -> None
|
||||
"""
|
||||
Process exceptions from views and downstream middleware.
|
||||
|
||||
Django calls this WHILE still inside the context created by __call__,
|
||||
so request tags have already been extracted and set. This method just
|
||||
needs to capture the exception directly.
|
||||
|
||||
Django converts view exceptions into responses before they propagate through
|
||||
the middleware stack, so the context manager in __call__/__acall__ never sees them.
|
||||
|
||||
Note: Django's process_exception is always synchronous, even for async views.
|
||||
"""
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return
|
||||
|
||||
if not self.capture_exceptions:
|
||||
return
|
||||
|
||||
# Context and tags already set by __call__ or __acall__
|
||||
# Just capture the exception
|
||||
if self.client:
|
||||
self.client.capture_exception(exception)
|
||||
else:
|
||||
from hanzo_insights import capture_exception
|
||||
|
||||
capture_exception(exception)
|
||||
@@ -0,0 +1,397 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from gzip import GzipFile
|
||||
from io import BytesIO
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
from dateutil.tz import tzutc
|
||||
from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
|
||||
from urllib3.connection import HTTPConnection
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from hanzo_insights.utils import remove_trailing_slash
|
||||
from hanzo_insights.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,
|
||||
)
|
||||
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 hanzo_insights import set_socket_options
|
||||
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
|
||||
"""
|
||||
global _session, _flags_session, _socket_options
|
||||
if socket_options == _socket_options:
|
||||
return
|
||||
_socket_options = socket_options
|
||||
_session = _build_session(socket_options)
|
||||
_flags_session = _build_flags_session(socket_options)
|
||||
|
||||
|
||||
def enable_keep_alive() -> None:
|
||||
"""Enable TCP keepalive to prevent idle connections from being dropped."""
|
||||
set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS)
|
||||
|
||||
|
||||
def disable_connection_reuse() -> None:
|
||||
"""Disable connection reuse, creating a fresh connection for each request."""
|
||||
global _pooling_enabled
|
||||
_pooling_enabled = False
|
||||
|
||||
|
||||
US_INGESTION_ENDPOINT = "https://us.i.insights.hanzo.ai"
|
||||
EU_INGESTION_ENDPOINT = "https://eu.i.insights.hanzo.ai"
|
||||
DEFAULT_HOST = US_INGESTION_ENDPOINT
|
||||
USER_AGENT = "hanzo-insights-python/" + VERSION
|
||||
|
||||
|
||||
def determine_server_host(host: Optional[str]) -> str:
|
||||
"""Determines the server host to use."""
|
||||
host_or_default = host or DEFAULT_HOST
|
||||
trimmed_host = remove_trailing_slash(host_or_default)
|
||||
if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com", "https://insights.hanzo.ai", "https://us.insights.hanzo.ai"):
|
||||
return US_INGESTION_ENDPOINT
|
||||
elif trimmed_host in ("https://eu.posthog.com", "https://eu.insights.hanzo.ai"):
|
||||
return EU_INGESTION_ENDPOINT
|
||||
else:
|
||||
return host_or_default
|
||||
|
||||
|
||||
def post(
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
path=None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
session: Optional[requests.Session] = None,
|
||||
**kwargs,
|
||||
) -> requests.Response:
|
||||
"""Post the `kwargs` to the API"""
|
||||
log = logging.getLogger("hanzo_insights")
|
||||
body = kwargs
|
||||
body["sentAt"] = datetime.now(tz=tzutc()).isoformat()
|
||||
url = remove_trailing_slash(host or DEFAULT_HOST) + path
|
||||
body["api_key"] = api_key
|
||||
data = json.dumps(body, cls=DatetimeSerializer)
|
||||
log.debug("making request: %s to url: %s", data, url)
|
||||
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
|
||||
if gzip:
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
buf = BytesIO()
|
||||
with GzipFile(fileobj=buf, mode="w") as gz:
|
||||
# 'data' was produced by json.dumps(),
|
||||
# whose default encoding is utf-8.
|
||||
gz.write(data.encode("utf-8"))
|
||||
data = buf.getvalue()
|
||||
|
||||
res = (session or _get_session()).post(
|
||||
url, data=data, headers=headers, timeout=timeout
|
||||
)
|
||||
|
||||
if res.status_code == 200:
|
||||
log.debug("data uploaded successfully")
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _process_response(
|
||||
res: requests.Response, success_message: str, *, return_json: bool = True
|
||||
) -> Union[requests.Response, Any]:
|
||||
log = logging.getLogger("hanzo_insights")
|
||||
if res.status_code == 200:
|
||||
log.debug(success_message)
|
||||
response = res.json() if return_json else res
|
||||
# Handle quota limited decide responses by raising a specific error
|
||||
# NB: other services also put entries into the quotaLimited key, but right now we only care about feature flags
|
||||
# since most of the other services handle quota limiting in other places in the application.
|
||||
if (
|
||||
isinstance(response, dict)
|
||||
and "quotaLimited" in response
|
||||
and isinstance(response["quotaLimited"], list)
|
||||
and "feature_flags" in response["quotaLimited"]
|
||||
):
|
||||
log.warning(
|
||||
"[FEATURE FLAGS] Feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://insights.hanzo.ai/docs/billing/limits-alerts"
|
||||
)
|
||||
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)
|
||||
except (KeyError, ValueError):
|
||||
raise APIError(res.status_code, res.text, retry_after=retry_after)
|
||||
|
||||
|
||||
def decide(
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""Post the `kwargs to the decide API endpoint"""
|
||||
res = post(api_key, host, "/decide/?v=4", gzip, timeout, **kwargs)
|
||||
return _process_response(res, success_message="Feature flags decided successfully")
|
||||
|
||||
|
||||
def flags(
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""Post the kwargs to the flags API endpoint with automatic retries."""
|
||||
res = post(
|
||||
api_key,
|
||||
host,
|
||||
"/flags/?v=2",
|
||||
gzip,
|
||||
timeout,
|
||||
session=_get_flags_session(),
|
||||
**kwargs,
|
||||
)
|
||||
return _process_response(
|
||||
res, success_message="Feature flags evaluated successfully"
|
||||
)
|
||||
|
||||
|
||||
def remote_config(
|
||||
personal_api_key: str,
|
||||
project_api_key: str,
|
||||
host: Optional[str] = None,
|
||||
key: str = "",
|
||||
timeout: int = 15,
|
||||
) -> Any:
|
||||
"""Get remote config flag value from remote_config API endpoint"""
|
||||
response = get(
|
||||
personal_api_key,
|
||||
f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
|
||||
host,
|
||||
timeout,
|
||||
)
|
||||
return response.data
|
||||
|
||||
|
||||
def batch_post(
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> requests.Response:
|
||||
"""Post the `kwargs` to the batch API endpoint for events"""
|
||||
res = post(api_key, host, "/batch/", gzip, timeout, **kwargs)
|
||||
return _process_response(
|
||||
res, success_message="data uploaded successfully", return_json=False
|
||||
)
|
||||
|
||||
|
||||
def get(
|
||||
api_key: str,
|
||||
url: str,
|
||||
host: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
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("hanzo_insights")
|
||||
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
|
||||
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
|
||||
|
||||
if etag:
|
||||
headers["If-None-Match"] = etag
|
||||
|
||||
res = _get_session().get(full_url, headers=headers, timeout=timeout)
|
||||
|
||||
masked_url = _mask_tokens_in_url(full_url)
|
||||
|
||||
# Handle 304 Not Modified
|
||||
if res.status_code == 304:
|
||||
log.debug(f"GET {masked_url} returned 304 Not Modified")
|
||||
response_etag = res.headers.get("ETag")
|
||||
return GetResponse(data=None, etag=response_etag or etag, not_modified=True)
|
||||
|
||||
# Handle normal response
|
||||
data = _process_response(
|
||||
res, success_message=f"GET {masked_url} completed successfully"
|
||||
)
|
||||
response_etag = res.headers.get("ETag")
|
||||
return GetResponse(data=data, etag=response_etag, not_modified=False)
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
def __init__(
|
||||
self, status: Union[int, str], message: str, retry_after: Optional[float] = None
|
||||
):
|
||||
self.message = message
|
||||
self.status = status
|
||||
self.retry_after = retry_after
|
||||
|
||||
def __str__(self):
|
||||
msg = "[Insights] {0} ({1})"
|
||||
return msg.format(self.message, self.status)
|
||||
|
||||
|
||||
class QuotaLimitError(APIError):
|
||||
pass
|
||||
|
||||
|
||||
# Re-export requests exceptions for use in client.py
|
||||
# This keeps all requests library imports centralized in this module
|
||||
RequestsTimeout = requests.exceptions.Timeout
|
||||
RequestsConnectionError = requests.exceptions.ConnectionError
|
||||
|
||||
|
||||
class DatetimeSerializer(json.JSONEncoder):
|
||||
def default(self, obj: Any):
|
||||
if isinstance(obj, (date, datetime)):
|
||||
return obj.isoformat()
|
||||
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
@@ -6,7 +6,7 @@ import unittest
|
||||
|
||||
def all_names():
|
||||
for _, modname, _ in pkgutil.iter_modules(__path__):
|
||||
yield "posthog.test." + modname
|
||||
yield "hanzo_insights.test." + modname
|
||||
|
||||
|
||||
def all():
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,853 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from google import genai as google_genai
|
||||
|
||||
from hanzo_insights.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("hanzo_insights.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", insights_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Tell me a fun fact about hedgehogs"],
|
||||
insights_distinct_id="test-id",
|
||||
insights_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", insights_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content_stream(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Write a short story"],
|
||||
insights_distinct_id="test-id",
|
||||
insights_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", insights_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,
|
||||
insights_distinct_id="test-id",
|
||||
insights_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", insights_client=mock_client)
|
||||
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
insights_distinct_id="test-id",
|
||||
insights_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", insights_client=mock_client)
|
||||
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
insights_distinct_id="test-id",
|
||||
insights_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", insights_client=mock_client)
|
||||
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
insights_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", insights_client=mock_client)
|
||||
|
||||
# Test string input
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash", contents="Hello", insights_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"}]}],
|
||||
insights_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"}]}],
|
||||
insights_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"], insights_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", insights_client=mock_client)
|
||||
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
insights_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 Insights settings"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(
|
||||
api_key="test-key",
|
||||
insights_client=mock_client,
|
||||
insights_distinct_id="default_user",
|
||||
insights_properties={"team": "ai"},
|
||||
insights_privacy_mode=False,
|
||||
insights_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",
|
||||
insights_client=mock_client,
|
||||
insights_distinct_id="default_user",
|
||||
insights_properties={"team": "ai"},
|
||||
insights_privacy_mode=False,
|
||||
insights_groups={"company": "acme_corp"},
|
||||
)
|
||||
|
||||
# Override defaults in call
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
insights_distinct_id="specific_user",
|
||||
insights_properties={"feature": "chat", "urgent": True},
|
||||
insights_privacy_mode=True,
|
||||
insights_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,
|
||||
insights_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",
|
||||
insights_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", insights_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=["What's the weather in San Francisco?"],
|
||||
insights_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", insights_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.5-pro",
|
||||
contents="Test with cache",
|
||||
insights_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", insights_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content_stream(
|
||||
model="gemini-2.5-pro",
|
||||
contents="Test streaming with cache",
|
||||
insights_distinct_id="test-id",
|
||||
)
|
||||
|
||||
# Consume the stream
|
||||
result = []
|
||||
async for chunk in response:
|
||||
result.append(chunk)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
# Check Insights 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", insights_client=mock_client)
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents="What's the latest news?",
|
||||
insights_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", insights_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content_stream(
|
||||
model="gemini-2.5-flash",
|
||||
contents="What's the latest news?",
|
||||
insights_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
@@ -1,5 +1,5 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("langchain")
|
||||
pytest.importorskip("langchain_core")
|
||||
pytest.importorskip("langchain_community")
|
||||
pytest.importorskip("langgraph")
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
# Tests for OpenAI Agents SDK integration
|
||||
@@ -0,0 +1,810 @@
|
||||
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 hanzo_insights.ai.openai_agents import InsightsTracingProcessor, 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("hanzo_insights").setLevel(logging.DEBUG)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def processor(mock_client):
|
||||
return InsightsTracingProcessor(
|
||||
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 TestInsightsTracingProcessor:
|
||||
"""Tests for the InsightsTracingProcessor class."""
|
||||
|
||||
def test_initialization(self, mock_client):
|
||||
"""Test processor initializes correctly."""
|
||||
processor = InsightsTracingProcessor(
|
||||
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 = InsightsTracingProcessor(
|
||||
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 = InsightsTracingProcessor(
|
||||
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 = InsightsTracingProcessor(
|
||||
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 = InsightsTracingProcessor(
|
||||
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 = InsightsTracingProcessor(
|
||||
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 = InsightsTracingProcessor(
|
||||
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 = InsightsTracingProcessor(
|
||||
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 = InsightsTracingProcessor(
|
||||
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 = InsightsTracingProcessor(
|
||||
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 = InsightsTracingProcessor(
|
||||
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, InsightsTracingProcessor)
|
||||
|
||||
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"}
|
||||
@@ -0,0 +1,764 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from hanzo_insights.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_client(
|
||||
self,
|
||||
personal_api_key="phx_test_key",
|
||||
project_api_key="phc_test_key",
|
||||
host="https://us.insights.hanzo.ai",
|
||||
):
|
||||
"""Create a mock Insights 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("hanzo_insights.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)
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
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.insights.hanzo.ai/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("hanzo_insights.ai.prompts._get_session")
|
||||
def test_successfully_fetch_a_specific_prompt_version(self, mock_get_session):
|
||||
"""Should successfully fetch a specific prompt version."""
|
||||
mock_get = mock_get_session.return_value.get
|
||||
versioned_prompt_response = {
|
||||
**self.mock_prompt_response,
|
||||
"prompt": "Prompt version 1",
|
||||
"version": 1,
|
||||
}
|
||||
mock_get.return_value = MockResponse(json_data=versioned_prompt_response)
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
result = prompts.get("test-prompt", version=1)
|
||||
|
||||
self.assertEqual(result, versioned_prompt_response["prompt"])
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
self.assertEqual(
|
||||
call_args[0][0],
|
||||
"https://us.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key&version=1",
|
||||
)
|
||||
|
||||
@patch("hanzo_insights.ai.prompts._get_session")
|
||||
@patch("hanzo_insights.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
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
# 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("hanzo_insights.ai.prompts._get_session")
|
||||
def test_cache_latest_and_versioned_prompts_separately(self, mock_get_session):
|
||||
"""Should cache latest and historical prompt versions separately."""
|
||||
mock_get = mock_get_session.return_value.get
|
||||
latest_prompt_response = {
|
||||
**self.mock_prompt_response,
|
||||
"prompt": "Latest prompt",
|
||||
"version": 2,
|
||||
}
|
||||
versioned_prompt_response = {
|
||||
**self.mock_prompt_response,
|
||||
"prompt": "Prompt version 1",
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
MockResponse(json_data=latest_prompt_response),
|
||||
MockResponse(json_data=versioned_prompt_response),
|
||||
]
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
self.assertEqual(prompts.get("test-prompt"), latest_prompt_response["prompt"])
|
||||
self.assertEqual(
|
||||
prompts.get("test-prompt", version=1),
|
||||
versioned_prompt_response["prompt"],
|
||||
)
|
||||
self.assertEqual(prompts.get("test-prompt"), latest_prompt_response["prompt"])
|
||||
self.assertEqual(
|
||||
prompts.get("test-prompt", version=1),
|
||||
versioned_prompt_response["prompt"],
|
||||
)
|
||||
self.assertEqual(mock_get.call_count, 2)
|
||||
|
||||
@patch("hanzo_insights.ai.prompts._get_session")
|
||||
@patch("hanzo_insights.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
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
# 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("hanzo_insights.ai.prompts._get_session")
|
||||
@patch("hanzo_insights.ai.prompts.time.time")
|
||||
@patch("hanzo_insights.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
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
# 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("hanzo_insights.ai.prompts._get_session")
|
||||
@patch("hanzo_insights.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")
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
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("hanzo_insights.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")
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
with self.assertRaises(Exception) as context:
|
||||
prompts.get("test-prompt")
|
||||
|
||||
self.assertIn("Network error", str(context.exception))
|
||||
|
||||
@patch("hanzo_insights.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)
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
with self.assertRaises(Exception) as context:
|
||||
prompts.get("nonexistent-prompt")
|
||||
|
||||
self.assertIn('Prompt "nonexistent-prompt" not found', str(context.exception))
|
||||
|
||||
@patch("hanzo_insights.ai.prompts._get_session")
|
||||
def test_handle_404_response_for_specific_prompt_version(self, mock_get_session):
|
||||
"""Should handle 404 response for a specific prompt version."""
|
||||
mock_get = mock_get_session.return_value.get
|
||||
mock_get.return_value = MockResponse(status_code=404, ok=False)
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
with self.assertRaises(Exception) as context:
|
||||
prompts.get("nonexistent-prompt", version=3)
|
||||
|
||||
self.assertIn(
|
||||
'Prompt "nonexistent-prompt" version 3 not found',
|
||||
str(context.exception),
|
||||
)
|
||||
|
||||
@patch("hanzo_insights.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)
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
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."""
|
||||
client = self.create_mock_client(personal_api_key=None)
|
||||
prompts = Prompts(client)
|
||||
|
||||
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."""
|
||||
client = self.create_mock_client(project_api_key=None)
|
||||
prompts = Prompts(client)
|
||||
|
||||
with self.assertRaises(Exception) as context:
|
||||
prompts.get("test-prompt")
|
||||
|
||||
self.assertIn(
|
||||
"project_api_key is required to fetch prompts", str(context.exception)
|
||||
)
|
||||
|
||||
@patch("hanzo_insights.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"})
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
with self.assertRaises(Exception) as context:
|
||||
prompts.get("test-prompt")
|
||||
|
||||
self.assertIn("Invalid response format", str(context.exception))
|
||||
|
||||
@patch("hanzo_insights.ai.prompts._get_session")
|
||||
def test_use_custom_host_from_insights_options(self, mock_get_session):
|
||||
"""Should use custom host from Insights options."""
|
||||
mock_get = mock_get_session.return_value.get
|
||||
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
|
||||
|
||||
client = self.create_mock_client(host="https://eu.insights.hanzo.ai")
|
||||
prompts = Prompts(client)
|
||||
|
||||
prompts.get("test-prompt")
|
||||
|
||||
call_args = mock_get.call_args
|
||||
self.assertTrue(
|
||||
call_args[0][0].startswith(
|
||||
"https://eu.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key"
|
||||
),
|
||||
f"Expected URL to start with 'https://eu.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key', got {call_args[0][0]}",
|
||||
)
|
||||
|
||||
@patch("hanzo_insights.ai.prompts._get_session")
|
||||
@patch("hanzo_insights.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
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
# 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("hanzo_insights.ai.prompts._get_session")
|
||||
@patch("hanzo_insights.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
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client, 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("hanzo_insights.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)
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
prompts.get("prompt with spaces/and/slashes")
|
||||
|
||||
call_args = mock_get.call_args
|
||||
self.assertEqual(
|
||||
call_args[0][0],
|
||||
"https://us.insights.hanzo.ai/api/environments/@current/llm_prompts/name/prompt%20with%20spaces%2Fand%2Fslashes/?token=phc_test_key",
|
||||
)
|
||||
|
||||
@patch("hanzo_insights.ai.prompts._get_session")
|
||||
def test_work_with_direct_options_no_insights_client(self, mock_get_session):
|
||||
"""Should work with direct options (no Insights 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.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key",
|
||||
)
|
||||
self.assertEqual(
|
||||
call_args[1]["headers"]["Authorization"], "Bearer phx_direct_key"
|
||||
)
|
||||
|
||||
@patch("hanzo_insights.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.insights.hanzo.ai",
|
||||
)
|
||||
|
||||
prompts.get("test-prompt")
|
||||
|
||||
call_args = mock_get.call_args
|
||||
self.assertEqual(
|
||||
call_args[0][0],
|
||||
"https://eu.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key",
|
||||
)
|
||||
|
||||
@patch("hanzo_insights.ai.prompts._get_session")
|
||||
@patch("hanzo_insights.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."""
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
result = prompts.compile("Hello, {{name}}!", {"name": "World"})
|
||||
|
||||
self.assertEqual(result, "Hello, World!")
|
||||
|
||||
def test_replace_multiple_variables(self):
|
||||
"""Should replace multiple variables."""
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
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."""
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
result = prompts.compile("You have {{count}} items.", {"count": 42})
|
||||
|
||||
self.assertEqual(result, "You have 42 items.")
|
||||
|
||||
def test_handle_booleans(self):
|
||||
"""Should handle booleans."""
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
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."""
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
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."""
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
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."""
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
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."""
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
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."""
|
||||
|
||||
def test_clear_cache_with_version_and_no_name_raises_value_error(self):
|
||||
"""Should enforce that versioned cache clearing requires a prompt name."""
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
with self.assertRaises(ValueError) as context:
|
||||
prompts.clear_cache(version=1)
|
||||
|
||||
self.assertIn("requires 'name'", str(context.exception))
|
||||
|
||||
@patch("hanzo_insights.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),
|
||||
]
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
# 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("hanzo_insights.ai.prompts._get_session")
|
||||
def test_clear_a_specific_prompt_version_from_cache(self, mock_get_session):
|
||||
"""Should clear only the requested prompt version from cache."""
|
||||
mock_get = mock_get_session.return_value.get
|
||||
latest_prompt_response = {
|
||||
**self.mock_prompt_response,
|
||||
"prompt": "Latest prompt",
|
||||
"version": 2,
|
||||
}
|
||||
versioned_prompt_response = {
|
||||
**self.mock_prompt_response,
|
||||
"prompt": "Prompt version 1",
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
MockResponse(json_data=latest_prompt_response),
|
||||
MockResponse(json_data=versioned_prompt_response),
|
||||
MockResponse(json_data=versioned_prompt_response),
|
||||
]
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
prompts.get("test-prompt")
|
||||
prompts.get("test-prompt", version=1)
|
||||
self.assertEqual(mock_get.call_count, 2)
|
||||
|
||||
prompts.clear_cache("test-prompt", version=1)
|
||||
|
||||
prompts.get("test-prompt")
|
||||
self.assertEqual(mock_get.call_count, 2)
|
||||
|
||||
prompts.get("test-prompt", version=1)
|
||||
self.assertEqual(mock_get.call_count, 3)
|
||||
|
||||
@patch("hanzo_insights.ai.prompts._get_session")
|
||||
def test_clear_a_prompt_name_clears_all_cached_versions(self, mock_get_session):
|
||||
"""Should clear latest and versioned cache entries for the same prompt name."""
|
||||
mock_get = mock_get_session.return_value.get
|
||||
latest_prompt_response = {
|
||||
**self.mock_prompt_response,
|
||||
"prompt": "Latest prompt",
|
||||
"version": 2,
|
||||
}
|
||||
versioned_prompt_response = {
|
||||
**self.mock_prompt_response,
|
||||
"prompt": "Prompt version 1",
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
MockResponse(json_data=latest_prompt_response),
|
||||
MockResponse(json_data=versioned_prompt_response),
|
||||
MockResponse(json_data=latest_prompt_response),
|
||||
MockResponse(json_data=versioned_prompt_response),
|
||||
]
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
prompts.get("test-prompt")
|
||||
prompts.get("test-prompt", version=1)
|
||||
self.assertEqual(mock_get.call_count, 2)
|
||||
|
||||
prompts.clear_cache("test-prompt")
|
||||
|
||||
prompts.get("test-prompt")
|
||||
prompts.get("test-prompt", version=1)
|
||||
self.assertEqual(mock_get.call_count, 4)
|
||||
|
||||
@patch("hanzo_insights.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),
|
||||
]
|
||||
|
||||
client = self.create_mock_client()
|
||||
prompts = Prompts(client)
|
||||
|
||||
# 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()
|
||||
@@ -0,0 +1,541 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from hanzo_insights.ai.sanitization import (
|
||||
redact_base64_data_url,
|
||||
sanitize_openai,
|
||||
sanitize_openai_response,
|
||||
sanitize_anthropic,
|
||||
sanitize_gemini,
|
||||
sanitize_langchain,
|
||||
is_base64_data_url,
|
||||
is_raw_base64,
|
||||
REDACTED_IMAGE_PLACEHOLDER,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitization(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.sample_base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
self.sample_base64_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..."
|
||||
self.regular_url = "https://example.com/image.jpg"
|
||||
self.raw_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUl=="
|
||||
|
||||
def test_is_base64_data_url(self):
|
||||
self.assertTrue(is_base64_data_url(self.sample_base64_image))
|
||||
self.assertTrue(is_base64_data_url(self.sample_base64_png))
|
||||
self.assertFalse(is_base64_data_url(self.regular_url))
|
||||
self.assertFalse(is_base64_data_url("regular text"))
|
||||
|
||||
def test_is_raw_base64(self):
|
||||
self.assertTrue(is_raw_base64(self.raw_base64))
|
||||
self.assertFalse(is_raw_base64("short"))
|
||||
self.assertFalse(is_raw_base64(self.regular_url))
|
||||
self.assertFalse(is_raw_base64("/path/to/file"))
|
||||
|
||||
def test_redact_base64_data_url(self):
|
||||
self.assertEqual(
|
||||
redact_base64_data_url(self.sample_base64_image), REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(
|
||||
redact_base64_data_url(self.sample_base64_png), REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(redact_base64_data_url(self.regular_url), self.regular_url)
|
||||
self.assertEqual(redact_base64_data_url(None), None)
|
||||
self.assertEqual(redact_base64_data_url(123), 123)
|
||||
|
||||
def test_sanitize_openai(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": self.sample_base64_image,
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
|
||||
self.assertEqual(result[0]["content"][0]["text"], "What is in this image?")
|
||||
self.assertEqual(
|
||||
result[0]["content"][1]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(result[0]["content"][1]["image_url"]["detail"], "high")
|
||||
|
||||
def test_sanitize_openai_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 = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": self.regular_url},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["image_url"]["url"], self.regular_url)
|
||||
|
||||
def test_sanitize_openai_response(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": self.sample_base64_image,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai_response(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["image_url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_anthropic(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this image?"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": "base64data",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_anthropic(input_data)
|
||||
|
||||
self.assertEqual(result[0]["content"][0]["text"], "What is in this image?")
|
||||
self.assertEqual(
|
||||
result[0]["content"][1]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(result[0]["content"][1]["source"]["type"], "base64")
|
||||
self.assertEqual(result[0]["content"][1]["source"]["media_type"], "image/jpeg")
|
||||
|
||||
def test_sanitize_gemini(self):
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{"text": "What is in this image?"},
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "image/jpeg",
|
||||
"data": "base64data",
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
|
||||
self.assertEqual(result[0]["parts"][0]["text"], "What is in this image?")
|
||||
self.assertEqual(
|
||||
result[0]["parts"][1]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][1]["inline_data"]["mime_type"], "image/jpeg"
|
||||
)
|
||||
|
||||
def test_sanitize_langchain_openai_style(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": self.sample_base64_image},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_langchain(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_langchain_anthropic_style(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"data": "base64data"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_langchain(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_with_data_url_format(self):
|
||||
# Test that data URLs are properly detected and redacted across providers
|
||||
data_url = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD"
|
||||
|
||||
# OpenAI format
|
||||
openai_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "image_url", "image_url": {"url": data_url}}],
|
||||
}
|
||||
]
|
||||
result = sanitize_openai(openai_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
# Anthropic format
|
||||
anthropic_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": data_url,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_anthropic(anthropic_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
# LangChain format
|
||||
langchain_data = [
|
||||
{"role": "user", "content": [{"type": "image", "data": data_url}]}
|
||||
]
|
||||
result = sanitize_langchain(langchain_data)
|
||||
self.assertEqual(result[0]["content"][0]["data"], REDACTED_IMAGE_PLACEHOLDER)
|
||||
|
||||
def test_sanitize_with_raw_base64(self):
|
||||
# Test that raw base64 strings (without data URL prefix) are detected
|
||||
raw_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUl=="
|
||||
|
||||
# Test with Anthropic format
|
||||
anthropic_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": raw_base64,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_anthropic(anthropic_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
# Test with Gemini format
|
||||
gemini_data = [
|
||||
{"parts": [{"inline_data": {"mime_type": "image/png", "data": raw_base64}}]}
|
||||
]
|
||||
result = sanitize_gemini(gemini_data)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][0]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_preserves_regular_content(self):
|
||||
# Ensure non-base64 content is preserved across all providers
|
||||
regular_url = "https://example.com/image.jpg"
|
||||
text_content = "What do you see?"
|
||||
|
||||
# OpenAI
|
||||
openai_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": text_content},
|
||||
{"type": "image_url", "image_url": {"url": regular_url}},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_openai(openai_data)
|
||||
self.assertEqual(result[0]["content"][0]["text"], text_content)
|
||||
self.assertEqual(result[0]["content"][1]["image_url"]["url"], regular_url)
|
||||
|
||||
# Anthropic
|
||||
anthropic_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": text_content},
|
||||
{"type": "image", "source": {"type": "url", "url": regular_url}},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_anthropic(anthropic_data)
|
||||
self.assertEqual(result[0]["content"][0]["text"], text_content)
|
||||
# URL-based images should remain unchanged
|
||||
self.assertEqual(result[0]["content"][1]["source"]["url"], regular_url)
|
||||
|
||||
def test_sanitize_handles_non_dict_content(self):
|
||||
input_data = [{"role": "user", "content": "Just text"}]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result, input_data)
|
||||
|
||||
def test_sanitize_handles_none_input(self):
|
||||
self.assertIsNone(sanitize_openai(None))
|
||||
self.assertIsNone(sanitize_anthropic(None))
|
||||
self.assertIsNone(sanitize_gemini(None))
|
||||
self.assertIsNone(sanitize_langchain(None))
|
||||
|
||||
def test_sanitize_handles_single_message(self):
|
||||
input_data = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": self.sample_base64_image},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(
|
||||
result["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
|
||||
class TestAIMultipartRequest(unittest.TestCase):
|
||||
"""Test that _INTERNAL_LLMA_MULTIMODAL environment variable controls sanitization."""
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up environment variable after each test
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
def test_multimodal_disabled_redacts_images(self):
|
||||
"""When _INTERNAL_LLMA_MULTIMODAL is not set, images should be redacted."""
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, REDACTED_IMAGE_PLACEHOLDER)
|
||||
|
||||
def test_multimodal_enabled_preserves_images(self):
|
||||
"""When _INTERNAL_LLMA_MULTIMODAL is true, images should be preserved."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, base64_image)
|
||||
|
||||
def test_multimodal_enabled_with_1(self):
|
||||
"""_INTERNAL_LLMA_MULTIMODAL=1 should enable multimodal."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "1"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, base64_image)
|
||||
|
||||
def test_multimodal_enabled_with_yes(self):
|
||||
"""_INTERNAL_LLMA_MULTIMODAL=yes should enable multimodal."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "yes"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, base64_image)
|
||||
|
||||
def test_multimodal_false_redacts_images(self):
|
||||
"""_INTERNAL_LLMA_MULTIMODAL=false should still redact."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "false"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, REDACTED_IMAGE_PLACEHOLDER)
|
||||
|
||||
def test_anthropic_multimodal_enabled(self):
|
||||
"""Anthropic images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": "base64data",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_anthropic(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["source"]["data"], "base64data")
|
||||
|
||||
def test_gemini_multimodal_enabled(self):
|
||||
"""Gemini images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{"inline_data": {"mime_type": "image/jpeg", "data": "base64data"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
self.assertEqual(result[0]["parts"][0]["inline_data"]["data"], "base64data")
|
||||
|
||||
def test_langchain_anthropic_style_multimodal_enabled(self):
|
||||
"""LangChain Anthropic-style images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"data": "base64data"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_langchain(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["source"]["data"], "base64data")
|
||||
|
||||
def test_openai_audio_redacted_by_default(self):
|
||||
"""OpenAI audio should be redacted when _INTERNAL_LLMA_MULTIMODAL is not set."""
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "audio", "data": "base64audiodata", "id": "audio_123"}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["data"], REDACTED_IMAGE_PLACEHOLDER)
|
||||
self.assertEqual(result[0]["content"][0]["id"], "audio_123")
|
||||
|
||||
def test_openai_audio_preserved_with_flag(self):
|
||||
"""OpenAI audio should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "audio", "data": "base64audiodata", "id": "audio_123"}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["data"], "base64audiodata")
|
||||
|
||||
def test_gemini_audio_redacted_by_default(self):
|
||||
"""Gemini audio should be redacted when _INTERNAL_LLMA_MULTIMODAL is not set."""
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "audio/L16;codec=pcm;rate=24000",
|
||||
"data": "base64audiodata",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][0]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_gemini_audio_preserved_with_flag(self):
|
||||
"""Gemini audio should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "audio/L16;codec=pcm;rate=24000",
|
||||
"data": "base64audiodata",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][0]["inline_data"]["data"], "base64audiodata"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Tests for system prompt capture across all LLM providers.
|
||||
|
||||
This test suite ensures that system prompts are correctly captured in analytics
|
||||
regardless of how they're passed to the providers:
|
||||
- As first message in messages/contents array (standard format)
|
||||
- As separate system parameter (Anthropic, OpenAI)
|
||||
- As instructions parameter (OpenAI Responses API)
|
||||
- As system_instruction parameter (Gemini)
|
||||
"""
|
||||
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from hanzo_insights.client import Client
|
||||
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
|
||||
|
||||
|
||||
class TestSystemPromptCapture(unittest.TestCase):
|
||||
"""Test system prompt capture for all providers."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.test_system_prompt = "You are a helpful AI assistant."
|
||||
self.test_user_message = "Hello, how are you?"
|
||||
self.test_response = "I'm doing well, thank you!"
|
||||
|
||||
# Create mock Insights client
|
||||
self.client = Client(FAKE_TEST_API_KEY)
|
||||
self.client._enqueue = MagicMock()
|
||||
self.client.privacy_mode = False
|
||||
|
||||
def _assert_system_prompt_captured(self, captured_input):
|
||||
"""Helper to assert system prompt is correctly captured."""
|
||||
self.assertEqual(
|
||||
len(captured_input), 2, "Should have 2 messages (system + user)"
|
||||
)
|
||||
self.assertEqual(
|
||||
captured_input[0]["role"], "system", "First message should be system"
|
||||
)
|
||||
self.assertEqual(
|
||||
captured_input[0]["content"],
|
||||
self.test_system_prompt,
|
||||
"System content should match",
|
||||
)
|
||||
self.assertEqual(
|
||||
captured_input[1]["role"], "user", "Second message should be user"
|
||||
)
|
||||
self.assertEqual(
|
||||
captured_input[1]["content"],
|
||||
self.test_user_message,
|
||||
"User content should match",
|
||||
)
|
||||
|
||||
# OpenAI Tests
|
||||
def test_openai_messages_array_system_prompt(self):
|
||||
"""Test OpenAI with system prompt in messages array."""
|
||||
try:
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
from hanzo_insights.ai.openai import OpenAI
|
||||
except ImportError:
|
||||
self.skipTest("OpenAI package not available")
|
||||
|
||||
mock_response = ChatCompletion(
|
||||
id="test",
|
||||
model="gpt-4",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
choices=[
|
||||
Choice(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content=self.test_response, role="assistant"
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
||||
),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"openai.resources.chat.completions.Completions.create",
|
||||
return_value=mock_response,
|
||||
):
|
||||
client = OpenAI(insights_client=self.client, api_key="test")
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": self.test_system_prompt},
|
||||
{"role": "user", "content": self.test_user_message},
|
||||
]
|
||||
|
||||
client.chat.completions.create(
|
||||
model="gpt-4", messages=messages, insights_distinct_id="test-user"
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
def test_openai_separate_system_parameter(self):
|
||||
"""Test OpenAI with system prompt as separate parameter."""
|
||||
try:
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
from hanzo_insights.ai.openai import OpenAI
|
||||
except ImportError:
|
||||
self.skipTest("OpenAI package not available")
|
||||
|
||||
mock_response = ChatCompletion(
|
||||
id="test",
|
||||
model="gpt-4",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
choices=[
|
||||
Choice(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content=self.test_response, role="assistant"
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
||||
),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"openai.resources.chat.completions.Completions.create",
|
||||
return_value=mock_response,
|
||||
):
|
||||
client = OpenAI(insights_client=self.client, api_key="test")
|
||||
|
||||
messages = [{"role": "user", "content": self.test_user_message}]
|
||||
|
||||
client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=messages,
|
||||
system=self.test_system_prompt,
|
||||
insights_distinct_id="test-user",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
def test_openai_streaming_system_parameter(self):
|
||||
"""Test OpenAI streaming with system parameter."""
|
||||
try:
|
||||
from openai.types.chat.chat_completion_chunk import (
|
||||
ChatCompletionChunk,
|
||||
ChoiceDelta,
|
||||
)
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
from hanzo_insights.ai.openai import OpenAI
|
||||
except ImportError:
|
||||
self.skipTest("OpenAI package not available")
|
||||
|
||||
chunk1 = ChatCompletionChunk(
|
||||
id="test",
|
||||
model="gpt-4",
|
||||
object="chat.completion.chunk",
|
||||
created=int(time.time()),
|
||||
choices=[
|
||||
ChoiceChunk(
|
||||
finish_reason=None,
|
||||
index=0,
|
||||
delta=ChoiceDelta(content="Hello", role="assistant"),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
chunk2 = ChatCompletionChunk(
|
||||
id="test",
|
||||
model="gpt-4",
|
||||
object="chat.completion.chunk",
|
||||
created=int(time.time()),
|
||||
choices=[
|
||||
ChoiceChunk(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
delta=ChoiceDelta(content=" there!", role=None),
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
||||
),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"openai.resources.chat.completions.Completions.create",
|
||||
return_value=[chunk1, chunk2],
|
||||
):
|
||||
client = OpenAI(insights_client=self.client, api_key="test")
|
||||
|
||||
messages = [{"role": "user", "content": self.test_user_message}]
|
||||
|
||||
response_generator = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=messages,
|
||||
system=self.test_system_prompt,
|
||||
stream=True,
|
||||
insights_distinct_id="test-user",
|
||||
)
|
||||
|
||||
list(response_generator) # Consume generator
|
||||
|
||||
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
# Anthropic Tests
|
||||
def test_anthropic_messages_array_system_prompt(self):
|
||||
"""Test Anthropic with system prompt in messages array."""
|
||||
try:
|
||||
from hanzo_insights.ai.anthropic import Anthropic
|
||||
except ImportError:
|
||||
self.skipTest("Anthropic package not available")
|
||||
|
||||
with patch("anthropic.resources.messages.Messages.create") as mock_create:
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage.input_tokens = 20
|
||||
mock_response.usage.output_tokens = 10
|
||||
mock_response.usage.cache_read_input_tokens = None
|
||||
mock_response.usage.cache_creation_input_tokens = None
|
||||
mock_create.return_value = mock_response
|
||||
|
||||
client = Anthropic(insights_client=self.client, api_key="test")
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": self.test_system_prompt},
|
||||
{"role": "user", "content": self.test_user_message},
|
||||
]
|
||||
|
||||
client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
messages=messages,
|
||||
insights_distinct_id="test-user",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
def test_anthropic_separate_system_parameter(self):
|
||||
"""Test Anthropic with system prompt as separate parameter."""
|
||||
try:
|
||||
from hanzo_insights.ai.anthropic import Anthropic
|
||||
except ImportError:
|
||||
self.skipTest("Anthropic package not available")
|
||||
|
||||
with patch("anthropic.resources.messages.Messages.create") as mock_create:
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage.input_tokens = 20
|
||||
mock_response.usage.output_tokens = 10
|
||||
mock_response.usage.cache_read_input_tokens = None
|
||||
mock_response.usage.cache_creation_input_tokens = None
|
||||
mock_create.return_value = mock_response
|
||||
|
||||
client = Anthropic(insights_client=self.client, api_key="test")
|
||||
|
||||
messages = [{"role": "user", "content": self.test_user_message}]
|
||||
|
||||
client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
messages=messages,
|
||||
system=self.test_system_prompt,
|
||||
insights_distinct_id="test-user",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
# Gemini Tests
|
||||
def test_gemini_contents_array_system_prompt(self):
|
||||
"""Test Gemini with system prompt in contents array."""
|
||||
try:
|
||||
from hanzo_insights.ai.gemini import Client
|
||||
except ImportError:
|
||||
self.skipTest("Gemini package not available")
|
||||
|
||||
with patch("google.genai.Client") as mock_genai_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.candidates = [MagicMock()]
|
||||
mock_response.candidates[0].content.parts = [MagicMock()]
|
||||
mock_response.candidates[0].content.parts[0].text = self.test_response
|
||||
mock_response.usage_metadata.prompt_token_count = 20
|
||||
mock_response.usage_metadata.candidates_token_count = 10
|
||||
mock_response.usage_metadata.cached_content_token_count = None
|
||||
mock_response.usage_metadata.thoughts_token_count = None
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
mock_models_instance = MagicMock()
|
||||
mock_models_instance.generate_content.return_value = mock_response
|
||||
mock_client_instance.models = mock_models_instance
|
||||
mock_genai_class.return_value = mock_client_instance
|
||||
|
||||
client = Client(insights_client=self.client, api_key="test")
|
||||
|
||||
contents = [
|
||||
{"role": "system", "content": self.test_system_prompt},
|
||||
{"role": "user", "content": self.test_user_message},
|
||||
]
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=contents,
|
||||
insights_distinct_id="test-user",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
def test_gemini_system_instruction_parameter(self):
|
||||
"""Test Gemini with system_instruction in config parameter."""
|
||||
try:
|
||||
from hanzo_insights.ai.gemini import Client
|
||||
except ImportError:
|
||||
self.skipTest("Gemini package not available")
|
||||
|
||||
with patch("google.genai.Client") as mock_genai_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.candidates = [MagicMock()]
|
||||
mock_response.candidates[0].content.parts = [MagicMock()]
|
||||
mock_response.candidates[0].content.parts[0].text = self.test_response
|
||||
mock_response.usage_metadata.prompt_token_count = 20
|
||||
mock_response.usage_metadata.candidates_token_count = 10
|
||||
mock_response.usage_metadata.cached_content_token_count = None
|
||||
mock_response.usage_metadata.thoughts_token_count = None
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
mock_models_instance = MagicMock()
|
||||
mock_models_instance.generate_content.return_value = mock_response
|
||||
mock_client_instance.models = mock_models_instance
|
||||
mock_genai_class.return_value = mock_client_instance
|
||||
|
||||
client = Client(insights_client=self.client, api_key="test")
|
||||
|
||||
contents = [{"role": "user", "content": self.test_user_message}]
|
||||
config = {"system_instruction": self.test_system_prompt}
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=contents,
|
||||
config=config,
|
||||
insights_distinct_id="test-user",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
|
||||
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
@@ -0,0 +1,62 @@
|
||||
from parameterized import parameterized
|
||||
|
||||
from hanzo_insights.ai.utils import _get_tokens_source
|
||||
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
("no_insights_properties", {"$ai_input_tokens": 100}, None, "sdk"),
|
||||
("empty_insights_properties", {"$ai_input_tokens": 100}, {}, "sdk"),
|
||||
(
|
||||
"unrelated_insights_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, insights_properties, expected):
|
||||
result = _get_tokens_source(sdk_tags, insights_properties)
|
||||
assert result == expected
|
||||
@@ -0,0 +1,773 @@
|
||||
from hanzo_insights.contexts import (
|
||||
new_context,
|
||||
get_context_session_id,
|
||||
get_context_distinct_id,
|
||||
)
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
import asyncio
|
||||
|
||||
# Configure Django settings before importing middleware
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(
|
||||
DEBUG=True,
|
||||
SECRET_KEY="test-secret-key",
|
||||
INSTALLED_APPS=[],
|
||||
MIDDLEWARE=[],
|
||||
)
|
||||
django.setup()
|
||||
|
||||
from hanzo_insights.integrations.django import InsightsContextMiddleware
|
||||
|
||||
|
||||
class MockRequest:
|
||||
"""Mock Django HttpRequest object"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
headers=None,
|
||||
method="GET",
|
||||
path="/test",
|
||||
host="example.com",
|
||||
is_secure=False,
|
||||
):
|
||||
self.headers = headers or {}
|
||||
self.method = method
|
||||
self.path = path
|
||||
self._host = host
|
||||
self._is_secure = is_secure
|
||||
|
||||
def build_absolute_uri(self):
|
||||
scheme = "https" if self._is_secure else "http"
|
||||
return f"{scheme}://{self._host}{self.path}"
|
||||
|
||||
|
||||
class TestInsightsContextMiddleware(unittest.TestCase):
|
||||
def create_middleware(
|
||||
self,
|
||||
extra_tags=None,
|
||||
request_filter=None,
|
||||
tag_map=None,
|
||||
capture_exceptions=True,
|
||||
get_response=None,
|
||||
):
|
||||
"""Helper to create middleware instance with mock Django settings"""
|
||||
if get_response is None:
|
||||
get_response = Mock()
|
||||
|
||||
with patch("django.conf.settings") as mock_settings:
|
||||
# Configure mock settings
|
||||
mock_settings.INSIGHTS_MW_EXTRA_TAGS = extra_tags
|
||||
mock_settings.INSIGHTS_MW_REQUEST_FILTER = request_filter
|
||||
mock_settings.INSIGHTS_MW_TAG_MAP = tag_map
|
||||
mock_settings.INSIGHTS_MW_CAPTURE_EXCEPTIONS = capture_exceptions
|
||||
mock_settings.INSIGHTS_MW_CLIENT = None
|
||||
|
||||
# Make hasattr work correctly
|
||||
def mock_hasattr(obj, name):
|
||||
return name in [
|
||||
"INSIGHTS_MW_EXTRA_TAGS",
|
||||
"INSIGHTS_MW_REQUEST_FILTER",
|
||||
"INSIGHTS_MW_TAG_MAP",
|
||||
"INSIGHTS_MW_CAPTURE_EXCEPTIONS",
|
||||
"INSIGHTS_MW_CLIENT",
|
||||
]
|
||||
|
||||
with patch("builtins.hasattr", side_effect=mock_hasattr):
|
||||
middleware = InsightsContextMiddleware(get_response)
|
||||
|
||||
return middleware
|
||||
|
||||
def test_extract_tags_basic(self):
|
||||
with new_context():
|
||||
"""Test basic tag extraction from request"""
|
||||
middleware = self.create_middleware()
|
||||
request = MockRequest(
|
||||
headers={
|
||||
"X-INSIGHTS-SESSION-ID": "session-123",
|
||||
"X-INSIGHTS-DISTINCT-ID": "user-456",
|
||||
},
|
||||
method="POST",
|
||||
path="/api/test",
|
||||
host="example.com",
|
||||
is_secure=True,
|
||||
)
|
||||
|
||||
tags = middleware.extract_tags(request)
|
||||
|
||||
self.assertEqual(get_context_session_id(), "session-123")
|
||||
self.assertEqual(get_context_distinct_id(), "user-456")
|
||||
self.assertEqual(tags["$current_url"], "https://example.com/api/test")
|
||||
self.assertEqual(tags["$request_method"], "POST")
|
||||
|
||||
def test_extract_tags_missing_headers(self):
|
||||
"""Test tag extraction when Insights headers are missing"""
|
||||
|
||||
with new_context():
|
||||
middleware = self.create_middleware()
|
||||
request = MockRequest(headers={}, method="GET", path="/home")
|
||||
|
||||
tags = middleware.extract_tags(request)
|
||||
|
||||
self.assertIsNone(get_context_session_id())
|
||||
self.assertIsNone(get_context_distinct_id())
|
||||
self.assertEqual(tags["$current_url"], "http://example.com/home")
|
||||
self.assertEqual(tags["$request_method"], "GET")
|
||||
|
||||
def test_extract_tags_partial_headers(self):
|
||||
"""Test tag extraction with only some Insights headers present"""
|
||||
|
||||
with new_context():
|
||||
middleware = self.create_middleware()
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-SESSION-ID": "session-only"}, method="PUT"
|
||||
)
|
||||
|
||||
tags = middleware.extract_tags(request)
|
||||
|
||||
self.assertEqual(get_context_session_id(), "session-only")
|
||||
self.assertIsNone(get_context_distinct_id())
|
||||
self.assertEqual(tags["$request_method"], "PUT")
|
||||
|
||||
def test_extract_tags_with_extra_tags(self):
|
||||
"""Test tag extraction with extra_tags function"""
|
||||
|
||||
def extra_tags_func(request):
|
||||
return {"custom_tag": "custom_value", "user_id": "789"}
|
||||
|
||||
with new_context():
|
||||
middleware = self.create_middleware(extra_tags=extra_tags_func)
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-SESSION-ID": "session-123"}, method="GET"
|
||||
)
|
||||
|
||||
tags = middleware.extract_tags(request)
|
||||
|
||||
self.assertEqual(get_context_session_id(), "session-123")
|
||||
self.assertEqual(tags["custom_tag"], "custom_value")
|
||||
self.assertEqual(tags["user_id"], "789")
|
||||
|
||||
def test_extract_tags_with_tag_map(self):
|
||||
"""Test tag extraction with tag_map function"""
|
||||
|
||||
def extra_tags_func(request):
|
||||
return {"custom_tag": "custom_value", "user_id": "789"}
|
||||
|
||||
def tag_map_func(tags):
|
||||
if "custom_tag" in tags:
|
||||
tags["mapped_custom_tag"] = f"mapped_{tags['custom_tag']}"
|
||||
del tags["custom_tag"]
|
||||
return tags
|
||||
|
||||
with new_context():
|
||||
middleware = self.create_middleware(
|
||||
tag_map=tag_map_func, extra_tags=extra_tags_func
|
||||
)
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-SESSION-ID": "session-123"}, method="GET"
|
||||
)
|
||||
|
||||
tags = middleware.extract_tags(request)
|
||||
|
||||
self.assertEqual(tags["mapped_custom_tag"], "mapped_custom_value")
|
||||
|
||||
def test_extract_tags_extra_tags_returns_none(self):
|
||||
"""Test tag extraction when extra_tags returns None"""
|
||||
|
||||
def extra_tags_func(request):
|
||||
return None
|
||||
|
||||
middleware = self.create_middleware(extra_tags=extra_tags_func)
|
||||
request = MockRequest(method="GET")
|
||||
|
||||
tags = middleware.extract_tags(request)
|
||||
|
||||
self.assertEqual(tags["$request_method"], "GET")
|
||||
# Should not crash when extra_tags returns None
|
||||
|
||||
def test_extract_tags_extra_tags_returns_empty_dict(self):
|
||||
"""Test tag extraction when extra_tags returns empty dict"""
|
||||
|
||||
def extra_tags_func(request):
|
||||
return {}
|
||||
|
||||
middleware = self.create_middleware(extra_tags=extra_tags_func)
|
||||
request = MockRequest(method="PATCH")
|
||||
|
||||
tags = middleware.extract_tags(request)
|
||||
|
||||
self.assertEqual(tags["$request_method"], "PATCH")
|
||||
|
||||
def test_process_exception_called_during_view_exception(self):
|
||||
"""
|
||||
Unit test verifying process_exception captures exceptions per Django's contract.
|
||||
|
||||
Since this is a library test (no Django runtime), we simulate how Django
|
||||
would invoke our middleware in production:
|
||||
1. Middleware.__call__ creates context with request tags
|
||||
2. View raises exception inside get_response
|
||||
3. Django's BaseHandler catches it, calls process_exception, returns error response
|
||||
4. Exception never propagates to middleware's context manager
|
||||
|
||||
We manually call process_exception to simulate Django's behavior - this is
|
||||
the only way to test the hook without a full Django integration test.
|
||||
"""
|
||||
mock_client = Mock()
|
||||
view_exception = ValueError("View raised this error")
|
||||
error_response = Mock(status_code=500)
|
||||
|
||||
def mock_get_response(request):
|
||||
# Simulate Django's exception handling: catches view exception,
|
||||
# calls process_exception hook if it exists, returns error response
|
||||
if hasattr(middleware, "process_exception"):
|
||||
middleware.process_exception(request, view_exception)
|
||||
return error_response
|
||||
|
||||
middleware = self.create_middleware(get_response=mock_get_response)
|
||||
middleware.client = mock_client
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-DISTINCT-ID": "test-user"},
|
||||
method="POST",
|
||||
path="/api/endpoint",
|
||||
)
|
||||
response = middleware(request)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
mock_client.capture_exception.assert_called_once_with(view_exception)
|
||||
|
||||
def test_process_exception_respects_capture_exceptions_false(self):
|
||||
"""Verify process_exception respects capture_exceptions=False setting"""
|
||||
mock_client = Mock()
|
||||
view_exception = ValueError("Should not be captured")
|
||||
|
||||
def mock_get_response(request):
|
||||
if hasattr(middleware, "process_exception"):
|
||||
middleware.process_exception(request, view_exception)
|
||||
return Mock(status_code=500)
|
||||
|
||||
middleware = self.create_middleware(
|
||||
capture_exceptions=False, get_response=mock_get_response
|
||||
)
|
||||
middleware.client = mock_client
|
||||
|
||||
request = MockRequest()
|
||||
middleware(request)
|
||||
|
||||
mock_client.capture_exception.assert_not_called()
|
||||
|
||||
def test_process_exception_respects_request_filter(self):
|
||||
"""Verify process_exception respects request_filter setting"""
|
||||
mock_client = Mock()
|
||||
view_exception = ValueError("Should be filtered")
|
||||
|
||||
def mock_get_response(request):
|
||||
if hasattr(middleware, "process_exception"):
|
||||
middleware.process_exception(request, view_exception)
|
||||
return Mock(status_code=500)
|
||||
|
||||
middleware = self.create_middleware(
|
||||
request_filter=lambda req: False,
|
||||
capture_exceptions=True,
|
||||
get_response=mock_get_response,
|
||||
)
|
||||
middleware.client = mock_client
|
||||
|
||||
request = MockRequest()
|
||||
middleware(request)
|
||||
|
||||
mock_client.capture_exception.assert_not_called()
|
||||
|
||||
|
||||
class TestInsightsContextMiddlewareSync(unittest.TestCase):
|
||||
"""Test synchronous middleware behavior"""
|
||||
|
||||
def test_sync_middleware_call(self):
|
||||
"""Test that sync middleware correctly processes requests"""
|
||||
mock_response = Mock()
|
||||
get_response = Mock(return_value=mock_response)
|
||||
|
||||
# Create middleware with sync get_response
|
||||
middleware = InsightsContextMiddleware(get_response)
|
||||
|
||||
# Verify sync mode detected
|
||||
self.assertFalse(middleware._is_coroutine)
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-SESSION-ID": "test-session"},
|
||||
method="GET",
|
||||
path="/test",
|
||||
)
|
||||
|
||||
with new_context():
|
||||
response = middleware(request)
|
||||
|
||||
# Verify response returned
|
||||
self.assertEqual(response, mock_response)
|
||||
get_response.assert_called_once_with(request)
|
||||
|
||||
def test_sync_middleware_with_filter(self):
|
||||
"""Test sync middleware respects request filter"""
|
||||
mock_response = Mock()
|
||||
get_response = Mock(return_value=mock_response)
|
||||
|
||||
# Create middleware with request filter that filters all requests
|
||||
def request_filter(req):
|
||||
return False
|
||||
|
||||
middleware = InsightsContextMiddleware.__new__(InsightsContextMiddleware)
|
||||
middleware.get_response = get_response
|
||||
middleware._is_coroutine = False
|
||||
middleware.request_filter = request_filter
|
||||
middleware.capture_exceptions = True
|
||||
middleware.client = None
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
# Should skip context creation and return response directly
|
||||
response = middleware(request)
|
||||
self.assertEqual(response, mock_response)
|
||||
get_response.assert_called_once_with(request)
|
||||
|
||||
def test_view_exceptions_only_captured_via_process_exception(self):
|
||||
"""
|
||||
Demonstrates that process_exception is required to capture view exceptions.
|
||||
|
||||
In production Django, view exceptions don't propagate to middleware's context
|
||||
manager because Django's BaseHandler catches them first and converts them to
|
||||
error responses. Django provides the exception via process_exception hook instead.
|
||||
|
||||
This unit test proves:
|
||||
1. Context manager in __call__ never sees view exceptions (Django intercepts)
|
||||
2. Only process_exception can capture them
|
||||
3. Without process_exception, exceptions are silently lost (v6.7.5 regression)
|
||||
|
||||
We manually call process_exception to verify the hook works - in production,
|
||||
Django's BaseHandler would call it when a view raises.
|
||||
"""
|
||||
mock_client = Mock()
|
||||
get_response = Mock(return_value=Mock(status_code=500))
|
||||
|
||||
middleware = InsightsContextMiddleware(get_response)
|
||||
middleware.client = mock_client
|
||||
|
||||
def get_response_simulating_django(request):
|
||||
# Simulates Django behavior: view exception converted to error response,
|
||||
# never propagates to middleware's context manager
|
||||
return Mock(status_code=500)
|
||||
|
||||
middleware._sync_get_response = get_response_simulating_django
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
response = middleware(request)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
# Context manager didn't capture anything - exception was intercepted by Django
|
||||
mock_client.capture_exception.assert_not_called()
|
||||
|
||||
# Verify process_exception hook exists and captures exceptions when called
|
||||
if hasattr(middleware, "process_exception"):
|
||||
exception = ValueError("View error")
|
||||
middleware.process_exception(request, exception)
|
||||
mock_client.capture_exception.assert_called_once_with(exception)
|
||||
else:
|
||||
self.fail(
|
||||
"process_exception missing - view exceptions will not be captured!"
|
||||
)
|
||||
|
||||
|
||||
class TestInsightsContextMiddlewareAsync(unittest.TestCase):
|
||||
"""Test asynchronous middleware behavior"""
|
||||
|
||||
def test_async_middleware_detection(self):
|
||||
"""Test that async get_response is correctly detected"""
|
||||
|
||||
async def async_get_response(request):
|
||||
return Mock()
|
||||
|
||||
middleware = InsightsContextMiddleware(async_get_response)
|
||||
|
||||
# Verify async mode detected
|
||||
self.assertTrue(middleware._is_coroutine)
|
||||
|
||||
def test_async_middleware_call(self):
|
||||
"""Test that async middleware correctly processes requests"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = InsightsContextMiddleware(async_get_response)
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-SESSION-ID": "async-session"},
|
||||
method="POST",
|
||||
path="/async-test",
|
||||
)
|
||||
|
||||
with new_context():
|
||||
# Call should return the coroutine from __acall__
|
||||
result = middleware(request)
|
||||
|
||||
# Verify it's a coroutine
|
||||
self.assertTrue(asyncio.iscoroutine(result))
|
||||
|
||||
# Await the result
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_filter(self):
|
||||
"""Test async middleware respects request filter"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
# Properly initialize middleware
|
||||
middleware = InsightsContextMiddleware(async_get_response)
|
||||
# Override request filter after initialization
|
||||
middleware.request_filter = lambda req: False
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
# Should skip context creation and return response directly
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_context_propagation(self):
|
||||
"""Test that async middleware properly propagates context"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
# Verify context is available during async processing
|
||||
session_id = get_context_session_id()
|
||||
self.assertEqual(session_id, "async-session-123")
|
||||
return mock_response
|
||||
|
||||
middleware = InsightsContextMiddleware(async_get_response)
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-SESSION-ID": "async-session-123"},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
await result
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_exception_capture(self):
|
||||
"""Test that async middleware captures exceptions during request processing"""
|
||||
|
||||
async def run_test():
|
||||
mock_client = Mock()
|
||||
|
||||
# Make async_get_response raise an exception
|
||||
async def raise_exception(request):
|
||||
raise ValueError("Async test exception")
|
||||
|
||||
# Properly initialize middleware
|
||||
middleware = InsightsContextMiddleware(raise_exception)
|
||||
middleware.client = mock_client # Override with mock client
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
# Should capture exception and re-raise
|
||||
with self.assertRaises(ValueError):
|
||||
result = middleware(request)
|
||||
await result
|
||||
|
||||
# Verify exception was captured by middleware
|
||||
mock_client.capture_exception.assert_called_once()
|
||||
captured_exception = mock_client.capture_exception.call_args[0][0]
|
||||
self.assertIsInstance(captured_exception, ValueError)
|
||||
self.assertEqual(str(captured_exception), "Async test exception")
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_authenticated_user(self):
|
||||
"""
|
||||
Test that async middleware correctly extracts user info in async context.
|
||||
|
||||
Django's request.user is a SimpleLazyObject that defers DB access.
|
||||
In async context, accessing it directly raises SynchronousOnlyOperation.
|
||||
The middleware should use request.auser() instead.
|
||||
|
||||
This tests the fix for issue #355.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = True
|
||||
mock_user.pk = 123
|
||||
mock_user.email = "test@example.com"
|
||||
|
||||
async def async_get_response(request):
|
||||
# Verify user info was extracted and set as distinct_id
|
||||
distinct_id = get_context_distinct_id()
|
||||
self.assertEqual(distinct_id, "123")
|
||||
return mock_response
|
||||
|
||||
middleware = InsightsContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-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 = InsightsContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-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 = InsightsContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
# Request without auser method (no auth middleware)
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-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 = InsightsContextMiddleware(async_get_response)
|
||||
middleware.extra_tags = extra_tags_callback
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-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 = InsightsContextMiddleware(async_get_response)
|
||||
middleware.tag_map = tag_map_callback
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-INSIGHTS-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 = InsightsContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={
|
||||
"X-INSIGHTS-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 TestInsightsContextMiddlewareHybrid(unittest.TestCase):
|
||||
"""Test hybrid middleware behavior with mixed sync/async chains"""
|
||||
|
||||
def test_hybrid_flags_set(self):
|
||||
"""Test that both capability flags are set"""
|
||||
self.assertTrue(InsightsContextMiddleware.sync_capable)
|
||||
self.assertTrue(InsightsContextMiddleware.async_capable)
|
||||
|
||||
def test_sync_to_async_routing(self):
|
||||
"""Test that __call__ routes to __acall__ when async"""
|
||||
|
||||
async def run_test():
|
||||
async def async_get_response(request):
|
||||
return Mock()
|
||||
|
||||
middleware = InsightsContextMiddleware(async_get_response)
|
||||
|
||||
# Verify routing happens
|
||||
request = MockRequest()
|
||||
result = middleware(request)
|
||||
|
||||
# Should be a coroutine from __acall__
|
||||
self.assertTrue(asyncio.iscoroutine(result))
|
||||
await result # Clean up
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_sync_path_direct_return(self):
|
||||
"""Test that sync path returns directly without coroutine"""
|
||||
mock_response = Mock()
|
||||
|
||||
def sync_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = InsightsContextMiddleware(sync_get_response)
|
||||
|
||||
request = MockRequest()
|
||||
result = middleware(request)
|
||||
|
||||
# Should NOT be a coroutine
|
||||
self.assertFalse(asyncio.iscoroutine(result))
|
||||
self.assertEqual(result, mock_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,218 @@
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
from hanzo_insights.client import Client
|
||||
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
|
||||
|
||||
|
||||
class TestClient(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# This ensures no real HTTP POST requests are made
|
||||
cls.client_post_patcher = mock.patch("hanzo_insights.client.batch_post")
|
||||
cls.consumer_post_patcher = mock.patch("hanzo_insights.consumer.batch_post")
|
||||
cls.client_post_patcher.start()
|
||||
cls.consumer_post_patcher.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.client_post_patcher.stop()
|
||||
cls.consumer_post_patcher.stop()
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
print("FAIL", e, batch) # noqa: T201
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
def test_before_send_callback_modifies_event(self):
|
||||
"""Test that before_send callback can modify events."""
|
||||
processed_events = []
|
||||
|
||||
def my_before_send(event):
|
||||
processed_events.append(event.copy())
|
||||
if "properties" not in event:
|
||||
event["properties"] = {}
|
||||
event["properties"]["processed_by_before_send"] = True
|
||||
return event
|
||||
|
||||
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=my_before_send,
|
||||
sync_mode=True,
|
||||
)
|
||||
msg_uuid = client.capture(
|
||||
"test_event", distinct_id="user1", properties={"original": "value"}
|
||||
)
|
||||
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Get the enqueued message from the mock
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
|
||||
self.assertEqual(
|
||||
enqueued_msg["properties"]["processed_by_before_send"], True
|
||||
)
|
||||
self.assertEqual(enqueued_msg["properties"]["original"], "value")
|
||||
self.assertEqual(len(processed_events), 1)
|
||||
self.assertEqual(processed_events[0]["event"], "test_event")
|
||||
|
||||
def test_before_send_callback_drops_event(self):
|
||||
"""Test that before_send callback can drop events by returning None."""
|
||||
|
||||
def drop_test_events(event):
|
||||
if event.get("event") == "test_drop_me":
|
||||
return None
|
||||
return event
|
||||
|
||||
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=drop_test_events,
|
||||
sync_mode=True,
|
||||
)
|
||||
|
||||
# Event should be dropped
|
||||
msg_uuid = client.capture("test_drop_me", distinct_id="user1")
|
||||
self.assertIsNone(msg_uuid)
|
||||
|
||||
# Event should go through
|
||||
msg_uuid = client.capture("keep_me", distinct_id="user1")
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Check the enqueued message
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
self.assertEqual(enqueued_msg["event"], "keep_me")
|
||||
|
||||
def test_before_send_callback_handles_exceptions(self):
|
||||
"""Test that exceptions in before_send don't crash the client."""
|
||||
|
||||
def buggy_before_send(event):
|
||||
raise ValueError("Oops!")
|
||||
|
||||
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=buggy_before_send,
|
||||
sync_mode=True,
|
||||
)
|
||||
msg_uuid = client.capture("robust_event", distinct_id="user1")
|
||||
|
||||
# Event should still be sent despite the exception
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Check the enqueued message
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
self.assertEqual(enqueued_msg["event"], "robust_event")
|
||||
|
||||
def test_before_send_callback_works_with_all_event_types(self):
|
||||
"""Test that before_send works with capture, set, etc."""
|
||||
|
||||
def add_marker(event):
|
||||
if "properties" not in event:
|
||||
event["properties"] = {}
|
||||
event["properties"]["marked"] = True
|
||||
return event
|
||||
|
||||
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=add_marker,
|
||||
sync_mode=True,
|
||||
)
|
||||
|
||||
# Test capture
|
||||
msg_uuid = client.capture("event", distinct_id="user1")
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Test set
|
||||
msg_uuid = client.set(distinct_id="user1", properties={"prop": "value"})
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Check all events were marked
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
for call in mock_post.call_args_list:
|
||||
batch_data = call[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
self.assertTrue(enqueued_msg["properties"]["marked"])
|
||||
|
||||
def test_before_send_callback_disabled_when_none(self):
|
||||
"""Test that client works normally when before_send is None."""
|
||||
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=None,
|
||||
sync_mode=True,
|
||||
)
|
||||
msg_uuid = client.capture("normal_event", distinct_id="user1")
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Check the event was sent normally
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
self.assertEqual(enqueued_msg["event"], "normal_event")
|
||||
|
||||
def test_before_send_callback_pii_scrubbing_example(self):
|
||||
"""Test a realistic PII scrubbing use case."""
|
||||
|
||||
def scrub_pii(event):
|
||||
properties = event.get("properties", {})
|
||||
|
||||
# Mask email but keep domain
|
||||
if "email" in properties:
|
||||
email = properties["email"]
|
||||
if "@" in email:
|
||||
domain = email.split("@")[1]
|
||||
properties["email"] = f"***@{domain}"
|
||||
else:
|
||||
properties["email"] = "***"
|
||||
|
||||
# Remove credit card
|
||||
properties.pop("credit_card", None)
|
||||
|
||||
return event
|
||||
|
||||
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=scrub_pii,
|
||||
sync_mode=True,
|
||||
)
|
||||
msg_uuid = client.capture(
|
||||
"form_submit",
|
||||
distinct_id="user1",
|
||||
properties={
|
||||
"email": "user@example.com",
|
||||
"credit_card": "1234-5678-9012-3456",
|
||||
"form_name": "contact",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Check the enqueued message was scrubbed
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
|
||||
self.assertEqual(enqueued_msg["properties"]["email"], "***@example.com")
|
||||
self.assertNotIn("credit_card", enqueued_msg["properties"])
|
||||
self.assertEqual(enqueued_msg["properties"]["form_name"], "contact")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
from typing import Any
|
||||
|
||||
import mock
|
||||
from parameterized import parameterized
|
||||
|
||||
try:
|
||||
from queue import Queue
|
||||
except ImportError:
|
||||
from Queue import Queue
|
||||
|
||||
from hanzo_insights.consumer import MAX_MSG_SIZE, Consumer
|
||||
from hanzo_insights.request import APIError
|
||||
from hanzo_insights.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:
|
||||
q = Queue()
|
||||
consumer = Consumer(q, "")
|
||||
q.put(1)
|
||||
next = consumer.next()
|
||||
self.assertEqual(next, [1])
|
||||
|
||||
def test_next_limit(self) -> None:
|
||||
q = Queue()
|
||||
flush_at = 50
|
||||
consumer = Consumer(q, "", flush_at)
|
||||
for i in range(10000):
|
||||
q.put(i)
|
||||
next = consumer.next()
|
||||
self.assertEqual(next, list(range(flush_at)))
|
||||
|
||||
def test_dropping_oversize_msg(self) -> None:
|
||||
q = Queue()
|
||||
consumer = Consumer(q, "")
|
||||
oversize_msg = {"m": "x" * MAX_MSG_SIZE}
|
||||
q.put(oversize_msg)
|
||||
next = consumer.next()
|
||||
self.assertEqual(next, [])
|
||||
self.assertTrue(q.empty())
|
||||
|
||||
def test_upload(self) -> None:
|
||||
q = Queue()
|
||||
consumer = Consumer(q, TEST_API_KEY)
|
||||
q.put(_track_event())
|
||||
success = consumer.upload()
|
||||
self.assertTrue(success)
|
||||
|
||||
def test_flush_interval(self) -> None:
|
||||
# Put _n_ items in the queue, pausing a little bit more than
|
||||
# _flush_interval_ after each one.
|
||||
# The consumer should upload _n_ times.
|
||||
q = Queue()
|
||||
flush_interval = 0.3
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=10, flush_interval=flush_interval)
|
||||
with mock.patch("hanzo_insights.consumer.batch_post") as mock_post:
|
||||
consumer.start()
|
||||
for i in range(3):
|
||||
q.put(_track_event("python event %d" % i))
|
||||
time.sleep(flush_interval * 1.1)
|
||||
self.assertEqual(mock_post.call_count, 3)
|
||||
|
||||
def test_multiple_uploads_per_interval(self) -> None:
|
||||
# Put _flush_at*2_ items in the queue at once, then pause for
|
||||
# _flush_interval_. The consumer should upload 2 times.
|
||||
q = Queue()
|
||||
flush_interval = 0.5
|
||||
flush_at = 10
|
||||
consumer = Consumer(
|
||||
q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval
|
||||
)
|
||||
with mock.patch("hanzo_insights.consumer.batch_post") as mock_post:
|
||||
consumer.start()
|
||||
for i in range(flush_at * 2):
|
||||
q.put(_track_event("python event %d" % i))
|
||||
time.sleep(flush_interval * 1.1)
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
|
||||
def test_request(self) -> None:
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
consumer.request([_track_event()])
|
||||
|
||||
def _run_retry_test(
|
||||
self, exception: Exception, exception_count: int, retries: int = 10
|
||||
) -> None:
|
||||
call_count = [0]
|
||||
|
||||
def mock_post(*args: Any, **kwargs: Any) -> None:
|
||||
call_count[0] += 1
|
||||
if call_count[0] <= exception_count:
|
||||
raise exception
|
||||
|
||||
consumer = Consumer(None, TEST_API_KEY, retries=retries)
|
||||
with mock.patch(
|
||||
"hanzo_insights.consumer.batch_post", mock.Mock(side_effect=mock_post)
|
||||
):
|
||||
if exception_count <= retries:
|
||||
consumer.request([_track_event()])
|
||||
else:
|
||||
with self.assertRaises(type(exception)):
|
||||
consumer.request([_track_event()])
|
||||
|
||||
@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_does_not_retry_client_errors(self) -> None:
|
||||
with self.assertRaises(APIError):
|
||||
self._run_retry_test(APIError(400, "Client Errors"), 1)
|
||||
|
||||
def test_request_fails_when_exceptions_exceed_retries(self) -> None:
|
||||
self._run_retry_test(APIError(500, "Internal Server Error"), 4, retries=3)
|
||||
|
||||
def test_pause(self) -> None:
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
consumer.pause()
|
||||
self.assertFalse(consumer.running)
|
||||
|
||||
def test_max_batch_size(self) -> None:
|
||||
q = Queue()
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
|
||||
properties = {}
|
||||
for n in range(0, 500):
|
||||
properties[str(n)] = "one_long_property_value_to_build_a_big_event"
|
||||
track = {
|
||||
"type": "track",
|
||||
"event": "python event",
|
||||
"distinct_id": "distinct_id",
|
||||
"properties": properties,
|
||||
}
|
||||
msg_size = len(json.dumps(track).encode())
|
||||
# Let's capture 8MB of data to trigger two batches
|
||||
n_msgs = int(8_000_000 / msg_size)
|
||||
|
||||
def mock_post_fn(_: str, data: str, **kwargs: Any) -> mock.Mock:
|
||||
res = mock.Mock()
|
||||
res.status_code = 200
|
||||
request_size = len(data.encode())
|
||||
# Batches close after the first message bringing it bigger than BATCH_SIZE_LIMIT, let's add 10% of margin
|
||||
self.assertTrue(
|
||||
request_size < (5 * 1024 * 1024) * 1.1,
|
||||
"batch size (%d) higher than limit" % request_size,
|
||||
)
|
||||
return res
|
||||
|
||||
with mock.patch(
|
||||
"hanzo_insights.request._session.post", side_effect=mock_post_fn
|
||||
) as mock_post:
|
||||
consumer.start()
|
||||
for _ in range(0, n_msgs + 2):
|
||||
q.put(track)
|
||||
q.join()
|
||||
self.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("hanzo_insights.consumer.batch_post", side_effect=mock_post),
|
||||
mock.patch("hanzo_insights.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("hanzo_insights.consumer.batch_post", side_effect=mock_post),
|
||||
mock.patch("hanzo_insights.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("hanzo_insights.consumer.batch_post", side_effect=mock_post),
|
||||
mock.patch("hanzo_insights.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])
|
||||
@@ -0,0 +1,233 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from hanzo_insights.contexts import (
|
||||
get_tags,
|
||||
new_context,
|
||||
scoped,
|
||||
tag,
|
||||
identify_context,
|
||||
set_context_session,
|
||||
get_context_session_id,
|
||||
get_context_distinct_id,
|
||||
)
|
||||
|
||||
|
||||
class TestContexts(unittest.TestCase):
|
||||
def test_tag_and_get_tags(self):
|
||||
with new_context(fresh=True):
|
||||
tag("key1", "value1")
|
||||
tag("key2", 2)
|
||||
|
||||
tags = get_tags()
|
||||
assert tags["key1"] == "value1"
|
||||
assert tags["key2"] == 2
|
||||
|
||||
def test_new_context_isolation(self):
|
||||
with new_context(fresh=True):
|
||||
# Set tag in outer context
|
||||
tag("outer", "value")
|
||||
|
||||
with new_context(fresh=True):
|
||||
# Inner context should start empty
|
||||
assert get_tags() == {}
|
||||
|
||||
# Set tag in inner context
|
||||
tag("inner", "value")
|
||||
assert get_tags()["inner"] == "value"
|
||||
|
||||
# Outer tag should not be visible
|
||||
self.assertNotIn("outer", get_tags())
|
||||
|
||||
with new_context(fresh=False):
|
||||
# Inner context should inherit outer tag
|
||||
assert get_tags() == {"outer": "value"}
|
||||
|
||||
# After exiting context, inner tag should be gone
|
||||
self.assertNotIn("inner", get_tags())
|
||||
|
||||
# Outer tag should still be there
|
||||
assert get_tags()["outer"] == "value"
|
||||
|
||||
def test_nested_contexts(self):
|
||||
with new_context(fresh=True):
|
||||
tag("level1", "value1")
|
||||
|
||||
with new_context(fresh=True):
|
||||
tag("level2", "value2")
|
||||
|
||||
with new_context(fresh=True):
|
||||
tag("level3", "value3")
|
||||
assert get_tags() == {"level3": "value3"}
|
||||
|
||||
# Back to level 2
|
||||
assert get_tags() == {"level2": "value2"}
|
||||
|
||||
# Back to level 1
|
||||
assert get_tags() == {"level1": "value1"}
|
||||
|
||||
@patch("hanzo_insights.capture_exception")
|
||||
def test_scoped_decorator_success(self, mock_capture):
|
||||
@scoped()
|
||||
def successful_function(x, y):
|
||||
tag("x", x)
|
||||
tag("y", y)
|
||||
return x + y
|
||||
|
||||
result = successful_function(1, 2)
|
||||
|
||||
# Function should execute normally
|
||||
assert result == 3
|
||||
|
||||
# No exception should be captured
|
||||
mock_capture.assert_not_called()
|
||||
|
||||
# Context should be cleared after function execution
|
||||
assert get_tags() == {}
|
||||
|
||||
@patch("hanzo_insights.capture_exception")
|
||||
def test_scoped_decorator_exception(self, mock_capture):
|
||||
test_exception = ValueError("Test exception")
|
||||
|
||||
def check_context_on_capture(exception, **kwargs):
|
||||
# Assert tags are available when capture_exception is called
|
||||
current_tags = get_tags()
|
||||
assert current_tags.get("important_context") == "value"
|
||||
|
||||
mock_capture.side_effect = check_context_on_capture
|
||||
|
||||
@scoped()
|
||||
def failing_function():
|
||||
tag("important_context", "value")
|
||||
raise test_exception
|
||||
|
||||
# Function should raise the exception
|
||||
with self.assertRaises(ValueError):
|
||||
failing_function()
|
||||
|
||||
# Verify capture_exception was called
|
||||
mock_capture.assert_called_once_with(test_exception)
|
||||
|
||||
# Context should be cleared after function execution
|
||||
assert get_tags() == {}
|
||||
|
||||
@patch("hanzo_insights.capture_exception")
|
||||
def test_new_context_exception_handling(self, mock_capture):
|
||||
test_exception = RuntimeError("Context exception")
|
||||
|
||||
def check_context_on_capture(exception, **kwargs):
|
||||
# Assert inner context tags are available when capture_exception is called
|
||||
current_tags = get_tags()
|
||||
assert current_tags.get("inner_context") == "inner_value"
|
||||
|
||||
mock_capture.side_effect = check_context_on_capture
|
||||
|
||||
# Set up outer context
|
||||
with new_context():
|
||||
tag("outer_context", "outer_value")
|
||||
|
||||
try:
|
||||
with new_context():
|
||||
tag("inner_context", "inner_value")
|
||||
raise test_exception
|
||||
except RuntimeError:
|
||||
pass # Expected exception
|
||||
|
||||
# Outer context should still be intact
|
||||
assert get_tags()["outer_context"] == "outer_value"
|
||||
|
||||
# Verify capture_exception was called
|
||||
mock_capture.assert_called_once_with(test_exception)
|
||||
|
||||
def test_identify_context(self):
|
||||
with new_context(fresh=True):
|
||||
# Initially no distinct ID
|
||||
assert get_context_distinct_id() is None
|
||||
|
||||
# Set distinct ID
|
||||
identify_context("user123")
|
||||
assert get_context_distinct_id() == "user123"
|
||||
|
||||
def test_set_context_session(self):
|
||||
with new_context(fresh=True):
|
||||
# Initially no session ID
|
||||
assert get_context_session_id() is None
|
||||
|
||||
# Set session ID
|
||||
set_context_session("session456")
|
||||
assert get_context_session_id() == "session456"
|
||||
|
||||
def test_context_inheritance_fresh_context(self):
|
||||
with new_context(fresh=True):
|
||||
identify_context("user123")
|
||||
set_context_session("session456")
|
||||
|
||||
with new_context(fresh=True):
|
||||
# Fresh context should not inherit
|
||||
assert get_context_distinct_id() is None
|
||||
assert get_context_session_id() is None
|
||||
|
||||
# Original context should still have values
|
||||
assert get_context_distinct_id() == "user123"
|
||||
assert get_context_session_id() == "session456"
|
||||
|
||||
def test_context_inheritance_non_fresh_context(self):
|
||||
with new_context(fresh=True):
|
||||
identify_context("user123")
|
||||
set_context_session("session456")
|
||||
|
||||
with new_context(fresh=False):
|
||||
# Non-fresh context should inherit
|
||||
assert get_context_distinct_id() == "user123"
|
||||
assert get_context_session_id() == "session456"
|
||||
|
||||
# Override in child context
|
||||
identify_context("user789")
|
||||
set_context_session("session999")
|
||||
assert get_context_distinct_id() == "user789"
|
||||
assert get_context_session_id() == "session999"
|
||||
|
||||
# Original context should still have original values
|
||||
assert get_context_distinct_id() == "user123"
|
||||
assert get_context_session_id() == "session456"
|
||||
|
||||
def test_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():
|
||||
identify_context("user456")
|
||||
set_context_session("session789")
|
||||
return get_context_distinct_id(), get_context_session_id()
|
||||
|
||||
distinct_id, session_id = function_with_context()
|
||||
assert distinct_id == "user456"
|
||||
assert session_id == "session789"
|
||||
|
||||
# Context should be cleared after function execution
|
||||
assert get_context_distinct_id() is None
|
||||
assert get_context_session_id() is None
|
||||
@@ -0,0 +1,689 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_excepthook(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
from hanzo_insights import Insights
|
||||
client = Insights('phc_x', host='https://eu.i.insights.hanzo.ai', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
|
||||
|
||||
# frame_value = "LOL"
|
||||
|
||||
1/0
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output
|
||||
|
||||
assert b"ZeroDivisionError" in output
|
||||
assert b"LOL" in output
|
||||
assert b"DEBUG:hanzo_insights:data uploaded successfully" in output
|
||||
assert (
|
||||
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
|
||||
in output
|
||||
)
|
||||
|
||||
|
||||
def test_code_variables_capture(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
from hanzo_insights import Insights
|
||||
|
||||
class UnserializableObject:
|
||||
pass
|
||||
|
||||
client = Insights(
|
||||
'phc_x',
|
||||
host='https://eu.i.insights.hanzo.ai',
|
||||
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\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"other_key\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\"}'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'{\\"level1\\": {\\"level2\\": {\\"api_key\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"data\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"safe\\": \\"visible\\"}}}'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'[\\"safe_item\\", \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"another_safe\\"]'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'[\\"tuple_safe\\", \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"tuple_also_safe\\"]'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'[{\\"id\\": 1, \\"password\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\"}, {\\"id\\": 2, \\"value\\": \\"safe_value\\"}]'
|
||||
in output
|
||||
)
|
||||
assert b"<__main__.UnserializableObject object at" in output
|
||||
assert b"'my_password': '$$_insights_redacted_based_on_masking_rules_$$'" in output
|
||||
assert (
|
||||
b"'my_innocent_var': '$$_insights_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 hanzo_insights
|
||||
from hanzo_insights import Insights
|
||||
|
||||
insights_client = Insights(
|
||||
'phc_x',
|
||||
host='https://eu.i.insights.hanzo.ai',
|
||||
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 hanzo_insights.new_context(client=insights_client):
|
||||
hanzo_insights.set_capture_exception_code_variables_context(True)
|
||||
hanzo_insights.set_code_variables_mask_patterns_context([r"(?i).*bank.*"])
|
||||
hanzo_insights.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': '$$_insights_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 hanzo_insights import Insights
|
||||
|
||||
client = Insights(
|
||||
'phc_x',
|
||||
host='https://eu.i.insights.hanzo.ai',
|
||||
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 hanzo_insights import Insights
|
||||
|
||||
client = Insights(
|
||||
'phc_x',
|
||||
host='https://eu.i.insights.hanzo.ai',
|
||||
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 hanzo_insights
|
||||
from hanzo_insights import Insights
|
||||
|
||||
insights_client = Insights(
|
||||
'phc_x',
|
||||
host='https://eu.i.insights.hanzo.ai',
|
||||
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 hanzo_insights.new_context(client=insights_client):
|
||||
hanzo_insights.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 hanzo_insights import Insights
|
||||
|
||||
class CustomReprClass:
|
||||
def __repr__(self):
|
||||
return '<CustomReprClass: custom representation>'
|
||||
|
||||
client = Insights(
|
||||
'phc_x',
|
||||
host='https://eu.i.insights.hanzo.ai',
|
||||
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 hanzo_insights import Insights
|
||||
|
||||
client = Insights(
|
||||
'phc_x',
|
||||
host='https://eu.i.insights.hanzo.ai',
|
||||
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 "$$_insights_value_too_long_$$" in output
|
||||
|
||||
assert "'long_blob': '$$_insights_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 hanzo_insights import Insights
|
||||
|
||||
client = Insights(
|
||||
'phc_x',
|
||||
host='https://eu.i.insights.hanzo.ai',
|
||||
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 "$$_insights_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 hanzo_insights.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"] == "$$_insights_redacted_based_on_masking_rules_$$"
|
||||
|
||||
|
||||
def test_mask_sensitive_data_circular_ref():
|
||||
from hanzo_insights.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 hanzo_insights.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 hanzo_insights.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 hanzo_insights.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 hanzo_insights.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
|
||||
@@ -0,0 +1,189 @@
|
||||
import unittest
|
||||
|
||||
from hanzo_insights.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata
|
||||
|
||||
|
||||
class TestFeatureFlag(unittest.TestCase):
|
||||
def test_feature_flag_from_json(self):
|
||||
# Test with full metadata
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"variant": "test-variant",
|
||||
"reason": {
|
||||
"code": "matched_condition",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 1,
|
||||
"payload": '{"some": "json"}',
|
||||
"version": 2,
|
||||
"description": "test-description",
|
||||
},
|
||||
}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertEqual(
|
||||
flag.reason,
|
||||
FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
flag.metadata,
|
||||
FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
)
|
||||
|
||||
def test_feature_flag_from_json_minimal(self):
|
||||
# Test with minimal required fields
|
||||
resp = {"key": "test-flag", "enabled": True}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertEqual(flag.get_value(), True)
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
|
||||
|
||||
def test_feature_flag_from_json_without_metadata(self):
|
||||
# Test with reason but no metadata
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"variant": "test-variant",
|
||||
"reason": {
|
||||
"code": "matched_condition",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertEqual(
|
||||
flag.reason,
|
||||
FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
|
||||
|
||||
def test_flag_reason_from_json(self):
|
||||
# Test with complete data
|
||||
resp = {
|
||||
"code": "user_in_segment",
|
||||
"condition_index": 1,
|
||||
"description": "User is in segment 'beta_users'",
|
||||
}
|
||||
reason = FlagReason.from_json(resp)
|
||||
self.assertEqual(reason.code, "user_in_segment")
|
||||
self.assertEqual(reason.condition_index, 1)
|
||||
self.assertEqual(reason.description, "User is in segment 'beta_users'")
|
||||
|
||||
# Test with partial data
|
||||
resp = {"code": "user_in_segment"}
|
||||
reason = FlagReason.from_json(resp)
|
||||
self.assertEqual(reason.code, "user_in_segment")
|
||||
self.assertIsNone(reason.condition_index) # default value
|
||||
self.assertEqual(reason.description, "")
|
||||
|
||||
# Test with None
|
||||
self.assertIsNone(FlagReason.from_json(None))
|
||||
|
||||
def test_flag_metadata_from_json(self):
|
||||
# Test with complete data
|
||||
resp = {
|
||||
"id": 123,
|
||||
"payload": {"key": "value"},
|
||||
"version": 1,
|
||||
"description": "Test flag",
|
||||
}
|
||||
metadata = FlagMetadata.from_json(resp)
|
||||
self.assertEqual(metadata.id, 123)
|
||||
self.assertEqual(metadata.payload, {"key": "value"})
|
||||
self.assertEqual(metadata.version, 1)
|
||||
self.assertEqual(metadata.description, "Test flag")
|
||||
|
||||
# Test with partial data
|
||||
resp = {"id": 123}
|
||||
metadata = FlagMetadata.from_json(resp)
|
||||
self.assertEqual(metadata.id, 123)
|
||||
self.assertIsNone(metadata.payload)
|
||||
self.assertEqual(metadata.version, 0) # default value
|
||||
self.assertEqual(metadata.description, "") # default value
|
||||
|
||||
# Test with None
|
||||
self.assertIsInstance(FlagMetadata.from_json(None), LegacyFlagMetadata)
|
||||
|
||||
def test_feature_flag_from_json_complete(self):
|
||||
# Test with complete data
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"variant": "control",
|
||||
"reason": {
|
||||
"code": "user_in_segment",
|
||||
"condition_index": 1,
|
||||
"description": "User is in segment 'beta_users'",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 123,
|
||||
"payload": {"key": "value"},
|
||||
"version": 1,
|
||||
"description": "Test flag",
|
||||
},
|
||||
}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "control")
|
||||
self.assertIsInstance(flag.reason, FlagReason)
|
||||
self.assertEqual(flag.reason.code, "user_in_segment")
|
||||
self.assertIsInstance(flag.metadata, FlagMetadata)
|
||||
self.assertEqual(flag.metadata.id, 123)
|
||||
self.assertEqual(flag.metadata.payload, {"key": "value"})
|
||||
|
||||
def test_feature_flag_from_json_minimal_data(self):
|
||||
# Test with minimal data
|
||||
resp = {"key": "test-flag", "enabled": False}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertFalse(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertIsInstance(flag.metadata, LegacyFlagMetadata)
|
||||
self.assertIsNone(flag.metadata.payload)
|
||||
|
||||
def test_feature_flag_from_json_with_reason(self):
|
||||
# Test with reason but no metadata
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"reason": {"code": "user_in_segment"},
|
||||
}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertIsInstance(flag.reason, FlagReason)
|
||||
self.assertEqual(flag.reason.code, "user_in_segment")
|
||||
self.assertIsInstance(flag.metadata, LegacyFlagMetadata)
|
||||
self.assertIsNone(flag.metadata.payload)
|
||||
@@ -0,0 +1,883 @@
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
from hanzo_insights.client import Client
|
||||
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
|
||||
from hanzo_insights.types import (
|
||||
FeatureFlag,
|
||||
FeatureFlagError,
|
||||
FeatureFlagResult,
|
||||
FlagMetadata,
|
||||
FlagReason,
|
||||
)
|
||||
|
||||
|
||||
class TestFeatureFlagResult(unittest.TestCase):
|
||||
def test_from_bool_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", True, "[1, 2, 3]"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, [1, 2, 3])
|
||||
|
||||
def test_from_false_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", False, '{"some": "value"}'
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, False)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_variant_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", "control", "true"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, True)
|
||||
|
||||
def test_from_none_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", None, '{"some": "value"}'
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_from_boolean_flag_details(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='"Some string"'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, "Some string")
|
||||
|
||||
def test_from_boolean_flag_details_with_override_variant_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='"Some string"'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value="control"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, "Some string")
|
||||
|
||||
def test_from_boolean_flag_details_with_override_boolean_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='{"some": "value"}'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value=True
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_boolean_flag_details_with_override_false_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='{"some": "value"}'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value=False
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, False)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_variant_flag_details(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='{"some": "value"}'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_none_flag_details(self):
|
||||
result = FeatureFlagResult.from_flag_details(None)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_from_flag_details_with_none_payload(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload=None
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertIsNone(result.payload)
|
||||
|
||||
|
||||
class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# This ensures no real HTTP POST requests are made
|
||||
cls.capture_patch = mock.patch.object(Client, "capture")
|
||||
cls.capture_patch.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.capture_patch.stop()
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture):
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "region",
|
||||
"operator": "exact",
|
||||
"value": ["USA"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"payloads": {"true": "300"},
|
||||
},
|
||||
}
|
||||
self.client.feature_flags = [basic_flag]
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, None)
|
||||
self.assertEqual(flag_result.payload, 300)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": True,
|
||||
"$feature_flag_payload": 300,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture):
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "region",
|
||||
"operator": "exact",
|
||||
"value": ["USA"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{"key": "variant-1", "rollout_percentage": 50},
|
||||
{"key": "variant-2", "rollout_percentage": 50},
|
||||
]
|
||||
},
|
||||
"payloads": {"variant-1": '{"some": "value"}'},
|
||||
},
|
||||
}
|
||||
self.client.feature_flags = [basic_flag]
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "distinct_id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, "variant-1")
|
||||
self.assertEqual(flag_result.get_value(), "variant-1")
|
||||
self.assertEqual(flag_result.payload, {"some": "value"})
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="distinct_id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-1",
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": "variant-1",
|
||||
"$feature_flag_payload": {"some": "value"},
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
another_flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "another-distinct-id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(another_flag_result.enabled, True)
|
||||
self.assertEqual(another_flag_result.variant, "variant-2")
|
||||
self.assertEqual(another_flag_result.get_value(), "variant-2")
|
||||
self.assertIsNone(another_flag_result.payload)
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="another-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-2",
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": "variant-2",
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("hanzo_insights.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": "300",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "some-distinct-id"
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, None)
|
||||
self.assertEqual(flag_result.payload, 300)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": False,
|
||||
"$feature/person-flag": True,
|
||||
"$feature_flag_reason": "Matched condition set 1",
|
||||
"$feature_flag_id": 23,
|
||||
"$feature_flag_version": 42,
|
||||
"$feature_flag_payload": 300,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
@mock.patch("hanzo_insights.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": "variant-1",
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 1,
|
||||
"version": 2,
|
||||
"payload": "[1, 2, 3]",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("person-flag", "distinct_id")
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, "variant-1")
|
||||
self.assertEqual(flag_result.get_value(), "variant-1")
|
||||
self.assertEqual(flag_result.payload, [1, 2, 3])
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="distinct_id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-1",
|
||||
"locally_evaluated": False,
|
||||
"$feature/person-flag": "variant-1",
|
||||
"$feature_flag_reason": "Matched condition set 1",
|
||||
"$feature_flag_id": 1,
|
||||
"$feature_flag_version": 2,
|
||||
"$feature_flag_payload": [1, 2, 3],
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
@mock.patch("hanzo_insights.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": "300",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"no-person-flag", "some-distinct-id"
|
||||
)
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "no-person-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/no-person-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.FLAG_MISSING,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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 hanzo_insights.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("hanzo_insights.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 hanzo_insights.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("hanzo_insights.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 hanzo_insights.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("hanzo_insights.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 hanzo_insights.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 Insights 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("hanzo_insights.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 hanzo_insights.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("hanzo_insights.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 hanzo_insights.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("hanzo_insights.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 hanzo_insights.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("hanzo_insights.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 hanzo_insights.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,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,612 @@
|
||||
"""
|
||||
Tests for FlagDefinitionCacheProvider functionality.
|
||||
|
||||
These tests follow the patterns from the TypeScript implementation in insights-js/packages/node.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import unittest
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
from hanzo_insights.client import Client
|
||||
from hanzo_insights.flag_definition_cache import (
|
||||
FlagDefinitionCacheData,
|
||||
FlagDefinitionCacheProvider,
|
||||
)
|
||||
from hanzo_insights.request import GetResponse
|
||||
from hanzo_insights.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("hanzo_insights.client.batch_post")
|
||||
cls.consumer_post_patcher = mock.patch("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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()
|
||||
@@ -0,0 +1,32 @@
|
||||
import unittest
|
||||
|
||||
from hanzo_insights import Insights
|
||||
|
||||
|
||||
class TestModule(unittest.TestCase):
|
||||
client = None
|
||||
|
||||
def _assert_enqueue_result(self, result):
|
||||
self.assertEqual(type(result[0]), str)
|
||||
|
||||
def failed(self):
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.client = Insights(
|
||||
"testsecret", host="http://localhost:8000", on_error=self.failed
|
||||
)
|
||||
|
||||
def test_track(self):
|
||||
res = self.client.capture("python module event", distinct_id="distinct_id")
|
||||
self._assert_enqueue_result(res)
|
||||
self.client.flush()
|
||||
|
||||
def test_alias(self):
|
||||
res = self.client.alias("previousId", "distinct_id")
|
||||
self._assert_enqueue_result(res)
|
||||
self.client.flush()
|
||||
|
||||
def test_flush(self):
|
||||
self.client.flush()
|
||||
@@ -0,0 +1,666 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
import hanzo_insights.request as request_module
|
||||
from hanzo_insights.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 hanzo_insights.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, expected",
|
||||
[
|
||||
# Token with params after - masks keeping first 10 chars
|
||||
(
|
||||
"https://example.com/api/flags?token=phc_abc123xyz789&send_cohorts",
|
||||
"https://example.com/api/flags?token=phc_abc123...&send_cohorts",
|
||||
),
|
||||
# Token at end of URL
|
||||
(
|
||||
"https://example.com/api/flags?token=phc_abc123xyz789",
|
||||
"https://example.com/api/flags?token=phc_abc123...",
|
||||
),
|
||||
# No token - unchanged
|
||||
(
|
||||
"https://example.com/api/flags?other=value",
|
||||
"https://example.com/api/flags?other=value",
|
||||
),
|
||||
# Short token (<10 chars) - unchanged
|
||||
(
|
||||
"https://example.com/api/flags?token=short",
|
||||
"https://example.com/api/flags?token=short",
|
||||
),
|
||||
# Exactly 10 char token - gets ellipsis
|
||||
(
|
||||
"https://example.com/api/flags?token=1234567890",
|
||||
"https://example.com/api/flags?token=1234567890...",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_mask_tokens_in_url(url, expected):
|
||||
assert _mask_tokens_in_url(url) == expected
|
||||
|
||||
|
||||
class TestRequests(unittest.TestCase):
|
||||
def test_valid_request(self):
|
||||
res = batch_post(
|
||||
TEST_API_KEY,
|
||||
batch=[
|
||||
{"distinct_id": "distinct_id", "event": "python event", "type": "track"}
|
||||
],
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_invalid_request_error(self):
|
||||
self.assertRaises(
|
||||
Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]"
|
||||
)
|
||||
|
||||
def test_invalid_host(self):
|
||||
self.assertRaises(
|
||||
Exception, batch_post, "testsecret", "t.posthog.com/", batch=[]
|
||||
)
|
||||
|
||||
def test_datetime_serialization(self):
|
||||
data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
self.assertEqual(result, '{"created": "2012-03-04T05:06:07.891011"}')
|
||||
|
||||
def test_date_serialization(self):
|
||||
today = date.today()
|
||||
data = {"created": today}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
expected = '{"created": "%s"}' % today.isoformat()
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_should_not_timeout(self):
|
||||
res = batch_post(
|
||||
TEST_API_KEY,
|
||||
batch=[
|
||||
{"distinct_id": "distinct_id", "event": "python event", "type": "track"}
|
||||
],
|
||||
timeout=15,
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_should_timeout(self):
|
||||
with self.assertRaises(requests.ReadTimeout):
|
||||
batch_post(
|
||||
"key",
|
||||
batch=[
|
||||
{
|
||||
"distinct_id": "distinct_id",
|
||||
"event": "python event",
|
||||
"type": "track",
|
||||
}
|
||||
],
|
||||
timeout=0.0001,
|
||||
)
|
||||
|
||||
def test_quota_limited_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"quotaLimited": ["feature_flags"],
|
||||
"featureFlags": {},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("hanzo_insights.request._session.post", return_value=mock_response):
|
||||
with self.assertRaises(QuotaLimitError) as cm:
|
||||
decide("fake_key", "fake_host")
|
||||
|
||||
self.assertEqual(cm.exception.status, 200)
|
||||
self.assertEqual(cm.exception.message, "Feature flags quota limited")
|
||||
|
||||
def test_normal_decide_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"featureFlags": {"flag1": True},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("hanzo_insights.request._session.post", return_value=mock_response):
|
||||
response = decide("fake_key", "fake_host")
|
||||
self.assertEqual(response["featureFlags"], {"flag1": True})
|
||||
|
||||
|
||||
class TestGet(unittest.TestCase):
|
||||
"""Unit tests for the get() function HTTP-level behavior."""
|
||||
|
||||
@mock.patch("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo_insights.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("hanzo-insights-python/")
|
||||
)
|
||||
|
||||
@mock.patch("hanzo_insights.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("hanzo_insights.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("hanzo_insights.request._session.get")
|
||||
def test_get_removes_trailing_slash_from_host(self, mock_get):
|
||||
"""Test that trailing slash is removed from host."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/api/flags", host="https://example.com/")
|
||||
|
||||
call_args = mock_get.call_args[0]
|
||||
self.assertEqual(call_args[0], "https://example.com/api/flags")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host, expected",
|
||||
[
|
||||
("https://t.posthog.com", "https://t.posthog.com"),
|
||||
("https://t.posthog.com/", "https://t.posthog.com/"),
|
||||
("t.posthog.com", "t.posthog.com"),
|
||||
("t.posthog.com/", "t.posthog.com/"),
|
||||
("https://us.posthog.com.rg.proxy.com", "https://us.posthog.com.rg.proxy.com"),
|
||||
("app.posthog.com", "app.posthog.com"),
|
||||
("eu.posthog.com", "eu.posthog.com"),
|
||||
("https://app.posthog.com", "https://us.i.insights.hanzo.ai"),
|
||||
("https://eu.posthog.com", "https://eu.i.insights.hanzo.ai"),
|
||||
("https://us.posthog.com", "https://us.i.insights.hanzo.ai"),
|
||||
("https://app.posthog.com/", "https://us.i.insights.hanzo.ai"),
|
||||
("https://eu.posthog.com/", "https://eu.i.insights.hanzo.ai"),
|
||||
("https://us.posthog.com/", "https://us.i.insights.hanzo.ai"),
|
||||
(None, "https://us.i.insights.hanzo.ai"),
|
||||
],
|
||||
)
|
||||
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 hanzo_insights.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 hanzo_insights.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 hanzo_insights.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 hanzo_insights.request import RETRY_STATUS_FORCELIST
|
||||
|
||||
self.assertNotIn(402, RETRY_STATUS_FORCELIST)
|
||||
|
||||
@mock.patch("hanzo_insights.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("hanzo_insights.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 hanzo_insights.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 hanzo_insights.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 hanzo_insights.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 hanzo_insights.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 hanzo_insights.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
|
||||
|
||||
# Get an available port by binding then closing a socket
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close() # Port is now available but nothing is listening
|
||||
|
||||
adapter = HTTPAdapterWithSocketOptions(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
backoff_factor=0.05, # Very fast for testing
|
||||
status_forcelist=RETRY_STATUS_FORCELIST,
|
||||
allowed_methods=["POST"],
|
||||
),
|
||||
)
|
||||
session = requests.Session()
|
||||
session.mount("http://", adapter)
|
||||
|
||||
start = time.time()
|
||||
with self.assertRaises(requests.exceptions.ConnectionError):
|
||||
session.post(
|
||||
f"http://127.0.0.1:{port}/flags/?v=2",
|
||||
json={"distinct_id": "user123"},
|
||||
timeout=1,
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# With 3 attempts and backoff, should take more than instant
|
||||
# but less than timeout (confirms retries happened)
|
||||
self.assertGreater(elapsed, 0.05, "Should have some delay from retries")
|
||||
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
|
||||
from parameterized import parameterized
|
||||
|
||||
from hanzo_insights import utils
|
||||
|
||||
|
||||
class TestSizeLimitedDict(unittest.TestCase):
|
||||
@parameterized.expand([(10, 100), (5, 20), (20, 200)])
|
||||
def test_size_limited_dict(self, size: int, iterations: int) -> None:
|
||||
values = utils.SizeLimitedDict(size, lambda _: -1)
|
||||
|
||||
for i in range(iterations):
|
||||
values[i] = i
|
||||
|
||||
assert values[i] == i
|
||||
assert len(values) == i % size + 1
|
||||
|
||||
if i % size == 0:
|
||||
# old numbers should've been removed
|
||||
self.assertIsNone(values.get(i - 1))
|
||||
self.assertIsNone(values.get(i - 3))
|
||||
self.assertIsNone(values.get(i - 5))
|
||||
self.assertIsNone(values.get(i - 9))
|
||||
@@ -0,0 +1,208 @@
|
||||
import unittest
|
||||
|
||||
from parameterized import parameterized
|
||||
|
||||
from hanzo_insights.types import (
|
||||
FeatureFlag,
|
||||
FlagMetadata,
|
||||
FlagReason,
|
||||
LegacyFlagMetadata,
|
||||
normalize_flags_response,
|
||||
to_flags_and_payloads,
|
||||
)
|
||||
|
||||
|
||||
class TestTypes(unittest.TestCase):
|
||||
@parameterized.expand([(True,), (False,)])
|
||||
def test_normalize_decide_response_v4(self, has_errors: bool):
|
||||
resp = {
|
||||
"flags": {
|
||||
"my-flag": FeatureFlag(
|
||||
key="my-flag",
|
||||
enabled=True,
|
||||
variant="test-variant",
|
||||
reason=FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
)
|
||||
},
|
||||
"errorsWhileComputingFlags": has_errors,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertEqual(
|
||||
flag.reason,
|
||||
FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
flag.metadata,
|
||||
FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
)
|
||||
self.assertEqual(result["errorsWhileComputingFlags"], has_errors)
|
||||
self.assertEqual(result["requestId"], "test-id")
|
||||
|
||||
def test_normalize_decide_response_legacy(self):
|
||||
# Test legacy response format with "featureFlags" and "featureFlagPayloads"
|
||||
resp = {
|
||||
"featureFlags": {"my-flag": "test-variant"},
|
||||
"featureFlagPayloads": {"my-flag": '{"some": "json-payload"}'},
|
||||
"errorsWhileComputingFlags": False,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertEqual(
|
||||
flag.metadata, LegacyFlagMetadata(payload='{"some": "json-payload"}')
|
||||
)
|
||||
self.assertFalse(result["errorsWhileComputingFlags"])
|
||||
self.assertEqual(result["requestId"], "test-id")
|
||||
# Verify legacy fields are removed
|
||||
self.assertNotIn("featureFlags", result)
|
||||
self.assertNotIn("featureFlagPayloads", result)
|
||||
|
||||
def test_normalize_decide_response_boolean_flag(self):
|
||||
# Test legacy response with boolean flag
|
||||
resp = {"featureFlags": {"my-flag": True}, "errorsWhileComputingFlags": False}
|
||||
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
self.assertIn("requestId", result)
|
||||
self.assertIsNone(result["requestId"])
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
|
||||
self.assertFalse(result["errorsWhileComputingFlags"])
|
||||
self.assertNotIn("featureFlags", result)
|
||||
self.assertNotIn("featureFlagPayloads", result)
|
||||
|
||||
def test_to_flags_and_payloads_v4(self):
|
||||
# Test v4 response format
|
||||
resp = {
|
||||
"flags": {
|
||||
"my-variant-flag": FeatureFlag(
|
||||
key="my-variant-flag",
|
||||
enabled=True,
|
||||
variant="test-variant",
|
||||
reason=FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
),
|
||||
"my-boolean-flag": FeatureFlag(
|
||||
key="my-boolean-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
reason=FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1, payload=None, version=2, description="test-description"
|
||||
),
|
||||
),
|
||||
"disabled-flag": FeatureFlag(
|
||||
key="disabled-flag",
|
||||
enabled=False,
|
||||
variant=None,
|
||||
reason=None,
|
||||
metadata=LegacyFlagMetadata(payload=None),
|
||||
),
|
||||
},
|
||||
"errorsWhileComputingFlags": False,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = to_flags_and_payloads(resp)
|
||||
|
||||
self.assertEqual(result["featureFlags"]["my-variant-flag"], "test-variant")
|
||||
self.assertEqual(result["featureFlags"]["my-boolean-flag"], True)
|
||||
self.assertEqual(result["featureFlags"]["disabled-flag"], False)
|
||||
self.assertEqual(
|
||||
result["featureFlagPayloads"]["my-variant-flag"], '{"some": "json"}'
|
||||
)
|
||||
self.assertNotIn("my-boolean-flag", result["featureFlagPayloads"])
|
||||
self.assertNotIn("disabled-flag", result["featureFlagPayloads"])
|
||||
|
||||
def test_to_flags_and_payloads_empty(self):
|
||||
# Test empty response
|
||||
resp = {
|
||||
"flags": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = to_flags_and_payloads(resp)
|
||||
|
||||
self.assertEqual(result["featureFlags"], {})
|
||||
self.assertEqual(result["featureFlagPayloads"], {})
|
||||
|
||||
def test_to_flags_and_payloads_with_payload(self):
|
||||
resp = {
|
||||
"flags": {
|
||||
"decide-flag": {
|
||||
"key": "decide-flag",
|
||||
"enabled": True,
|
||||
"variant": "decide-variant",
|
||||
"reason": {
|
||||
"code": "matched_condition",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": '{"foo": "bar"}',
|
||||
},
|
||||
}
|
||||
},
|
||||
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||||
}
|
||||
|
||||
normalized = normalize_flags_response(resp)
|
||||
result = to_flags_and_payloads(normalized)
|
||||
|
||||
self.assertEqual(result["featureFlags"]["decide-flag"], "decide-variant")
|
||||
self.assertEqual(result["featureFlagPayloads"]["decide-flag"], '{"foo": "bar"}')
|
||||
@@ -0,0 +1,301 @@
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import six
|
||||
from dateutil.tz import tzutc
|
||||
from parameterized import parameterized
|
||||
from pydantic import BaseModel
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
|
||||
from hanzo_insights import utils
|
||||
from hanzo_insights.types import FeatureFlagResult
|
||||
|
||||
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
|
||||
FAKE_TEST_API_KEY = "random_key"
|
||||
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
@parameterized.expand(
|
||||
[
|
||||
("naive datetime should be naive", True),
|
||||
("timezone-aware datetime should not be naive", False),
|
||||
]
|
||||
)
|
||||
def test_is_naive(self, _name: str, expected_naive: bool):
|
||||
if expected_naive:
|
||||
dt = datetime.now() # naive datetime
|
||||
else:
|
||||
dt = datetime.now(tz=tzutc()) # timezone-aware datetime
|
||||
|
||||
assert utils.is_naive(dt) is expected_naive
|
||||
|
||||
def test_timezone_utils(self):
|
||||
now = datetime.now()
|
||||
utcnow = datetime.now(tz=tzutc())
|
||||
|
||||
fixed = utils.guess_timezone(now)
|
||||
assert utils.is_naive(fixed) is False
|
||||
|
||||
shouldnt_be_edited = utils.guess_timezone(utcnow)
|
||||
assert utcnow == shouldnt_be_edited
|
||||
|
||||
def test_clean(self):
|
||||
simple = {
|
||||
"decimal": Decimal("0.142857"),
|
||||
"unicode": six.u("woo"),
|
||||
"date": datetime.now(),
|
||||
"long": 200000000,
|
||||
"integer": 1,
|
||||
"float": 2.0,
|
||||
"bool": True,
|
||||
"str": "woo",
|
||||
"none": None,
|
||||
}
|
||||
|
||||
complicated = {
|
||||
"exception": Exception("This should show up"),
|
||||
"timedelta": timedelta(microseconds=20),
|
||||
"list": [1, 2, 3],
|
||||
}
|
||||
|
||||
combined = dict(simple.items())
|
||||
combined.update(complicated.items())
|
||||
|
||||
pre_clean_keys = combined.keys()
|
||||
|
||||
utils.clean(combined)
|
||||
assert combined.keys() == pre_clean_keys
|
||||
|
||||
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
|
||||
assert (
|
||||
utils.clean(UUID("12345678123456781234567812345678"))
|
||||
== "12345678-1234-5678-1234-567812345678"
|
||||
)
|
||||
|
||||
def test_clean_with_dates(self):
|
||||
dict_with_dates = {
|
||||
"birthdate": date(1980, 1, 1),
|
||||
"registration": datetime.now(tz=tzutc()),
|
||||
}
|
||||
assert dict_with_dates == utils.clean(dict_with_dates)
|
||||
|
||||
def test_bytes(self):
|
||||
item = bytes(10)
|
||||
utils.clean(item)
|
||||
assert utils.clean(item) == "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
|
||||
def test_clean_fn(self):
|
||||
cleaned = utils.clean({"fn": lambda x: x, "number": 4})
|
||||
assert cleaned == {"fn": None, "number": 4}
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
("http://hanzo_insights.io/", "http://hanzo_insights.io"),
|
||||
("http://hanzo_insights.io", "http://hanzo_insights.io"),
|
||||
("https://example.com/path/", "https://example.com/path"),
|
||||
("https://example.com/path", "https://example.com/path"),
|
||||
]
|
||||
)
|
||||
def test_remove_slash(self, input_url, expected_url):
|
||||
assert expected_url == utils.remove_trailing_slash(input_url)
|
||||
|
||||
def test_clean_pydantic(self):
|
||||
class ModelV2(BaseModel):
|
||||
foo: str
|
||||
bar: int
|
||||
baz: Optional[str] = None
|
||||
|
||||
class ModelV1(BaseModelV1):
|
||||
foo: int
|
||||
bar: str
|
||||
|
||||
class NestedModel(BaseModel):
|
||||
foo: ModelV2
|
||||
|
||||
assert utils.clean(ModelV2(foo="1", bar=2)) == {
|
||||
"foo": "1",
|
||||
"bar": 2,
|
||||
"baz": None,
|
||||
}
|
||||
# Pydantic V1 is not compatible with Python 3.14+
|
||||
if sys.version_info < (3, 14):
|
||||
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
|
||||
assert utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))) == {
|
||||
"foo": {"foo": "1", "bar": 2, "baz": "3"}
|
||||
}
|
||||
|
||||
def test_clean_pydantic_like_class(self) -> None:
|
||||
class Dummy:
|
||||
def model_dump(self, required_param: str) -> dict:
|
||||
return {}
|
||||
|
||||
# previously python 2 code would cause an error while cleaning,
|
||||
# and this entire object would be None, and we would log an error
|
||||
# let's allow ourselves to clean `Dummy` as None,
|
||||
# without blatting the `test` key
|
||||
assert utils.clean({"test": Dummy()}) == {"test": None}
|
||||
|
||||
def test_clean_dataclass(self):
|
||||
@dataclass
|
||||
class InnerDataClass:
|
||||
inner_foo: str
|
||||
inner_bar: int
|
||||
inner_uuid: UUID
|
||||
inner_date: datetime
|
||||
inner_optional: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class TestDataClass:
|
||||
foo: str
|
||||
bar: int
|
||||
nested: InnerDataClass
|
||||
|
||||
assert utils.clean(
|
||||
TestDataClass(
|
||||
foo="1",
|
||||
bar=2,
|
||||
nested=InnerDataClass(
|
||||
inner_foo="3",
|
||||
inner_bar=4,
|
||||
inner_uuid=UUID("12345678123456781234567812345678"),
|
||||
inner_date=datetime(2025, 1, 1),
|
||||
),
|
||||
)
|
||||
) == {
|
||||
"foo": "1",
|
||||
"bar": 2,
|
||||
"nested": {
|
||||
"inner_foo": "3",
|
||||
"inner_bar": 4,
|
||||
"inner_uuid": "12345678-1234-5678-1234-567812345678",
|
||||
"inner_date": datetime(2025, 1, 1),
|
||||
"inner_optional": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestFlagCache(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.cache = utils.FlagCache(max_size=3, default_ttl=1)
|
||||
self.flag_result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", True, None
|
||||
)
|
||||
|
||||
def test_cache_basic_operations(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
flag_version = 1
|
||||
|
||||
# Test cache miss
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is None
|
||||
|
||||
# Test cache set and hit
|
||||
self.cache.set_cached_flag(
|
||||
distinct_id, flag_key, self.flag_result, flag_version
|
||||
)
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is not None
|
||||
assert result.get_value()
|
||||
|
||||
def test_cache_ttl_expiration(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
flag_version = 1
|
||||
|
||||
# Set flag in cache
|
||||
self.cache.set_cached_flag(
|
||||
distinct_id, flag_key, self.flag_result, flag_version
|
||||
)
|
||||
|
||||
# Should be available immediately
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is not None
|
||||
|
||||
# Wait for TTL to expire (1 second + buffer)
|
||||
time.sleep(1.1)
|
||||
|
||||
# Should be expired
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is None
|
||||
|
||||
def test_cache_version_invalidation(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
old_version = 1
|
||||
new_version = 2
|
||||
|
||||
# Set flag with old version
|
||||
self.cache.set_cached_flag(distinct_id, flag_key, self.flag_result, old_version)
|
||||
|
||||
# Should hit with old version
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
|
||||
assert result is not None
|
||||
|
||||
# Should miss with new version
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, new_version)
|
||||
assert result is None
|
||||
|
||||
# Invalidate old version
|
||||
self.cache.invalidate_version(old_version)
|
||||
|
||||
# Should miss even with old version after invalidation
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
|
||||
assert result is None
|
||||
|
||||
def test_stale_cache_functionality(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
flag_version = 1
|
||||
|
||||
# Set flag in cache
|
||||
self.cache.set_cached_flag(
|
||||
distinct_id, flag_key, self.flag_result, flag_version
|
||||
)
|
||||
|
||||
# Wait for TTL to expire
|
||||
time.sleep(1.1)
|
||||
|
||||
# Should not get fresh cache
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is None
|
||||
|
||||
# Should get stale cache (within 1 hour default)
|
||||
stale_result = self.cache.get_stale_cached_flag(distinct_id, flag_key)
|
||||
assert stale_result is not None
|
||||
assert stale_result.get_value()
|
||||
|
||||
def test_lru_eviction(self):
|
||||
# Cache has max_size=3, so adding 4 users should evict the LRU one
|
||||
flag_version = 1
|
||||
|
||||
# Add 3 users
|
||||
for i in range(3):
|
||||
user_id = f"user{i}"
|
||||
self.cache.set_cached_flag(
|
||||
user_id, "test-flag", self.flag_result, flag_version
|
||||
)
|
||||
|
||||
# Access user0 to make it recently used
|
||||
self.cache.get_cached_flag("user0", "test-flag", flag_version)
|
||||
|
||||
# Add 4th user, should evict user1 (least recently used)
|
||||
self.cache.set_cached_flag("user3", "test-flag", self.flag_result, flag_version)
|
||||
|
||||
# user0 should still be there (was recently accessed)
|
||||
result = self.cache.get_cached_flag("user0", "test-flag", flag_version)
|
||||
assert result is not None
|
||||
|
||||
# user2 should still be there (was recently added)
|
||||
result = self.cache.get_cached_flag("user2", "test-flag", flag_version)
|
||||
assert result is not None
|
||||
|
||||
# user3 should be there (just added)
|
||||
result = self.cache.get_cached_flag("user3", "test-flag", flag_version)
|
||||
assert result is not None
|
||||
@@ -0,0 +1,348 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, List, Optional, TypedDict, Union, cast
|
||||
|
||||
FlagValue = Union[bool, str]
|
||||
|
||||
# Type alias for the before_send callback function
|
||||
# Takes an event dictionary and returns the modified event or None to drop it
|
||||
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
|
||||
|
||||
|
||||
# Type alias for the send_feature_flags parameter
|
||||
class SendFeatureFlagsOptions(TypedDict, total=False):
|
||||
"""Options for sending feature flags with capture events.
|
||||
|
||||
Args:
|
||||
only_evaluate_locally: Whether to only use local evaluation for feature flags.
|
||||
If True, only flags that can be evaluated locally will be included.
|
||||
If False, remote evaluation via /flags API will be used when needed.
|
||||
person_properties: Properties to use for feature flag evaluation specific to this event.
|
||||
These properties will be merged with any existing person properties.
|
||||
group_properties: Group properties to use for feature flag evaluation specific to this event.
|
||||
Format: { group_type_name: { group_properties } }
|
||||
"""
|
||||
|
||||
should_send: bool
|
||||
only_evaluate_locally: Optional[bool]
|
||||
person_properties: Optional[dict[str, Any]]
|
||||
group_properties: Optional[dict[str, dict[str, Any]]]
|
||||
flag_keys_filter: Optional[list[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlagReason:
|
||||
code: str
|
||||
condition_index: Optional[int]
|
||||
description: str
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, resp: Any) -> Optional["FlagReason"]:
|
||||
if not resp:
|
||||
return None
|
||||
return cls(
|
||||
code=resp.get("code", ""),
|
||||
condition_index=resp.get("condition_index"),
|
||||
description=resp.get("description", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyFlagMetadata:
|
||||
payload: Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlagMetadata:
|
||||
id: int
|
||||
payload: Optional[str]
|
||||
version: int
|
||||
description: str
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]:
|
||||
if not resp:
|
||||
return LegacyFlagMetadata(payload=None)
|
||||
return cls(
|
||||
id=resp.get("id", 0),
|
||||
payload=resp.get("payload"),
|
||||
version=resp.get("version", 0),
|
||||
description=resp.get("description", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureFlag:
|
||||
key: str
|
||||
enabled: bool
|
||||
variant: Optional[str]
|
||||
reason: Optional[FlagReason]
|
||||
metadata: Union[FlagMetadata, LegacyFlagMetadata]
|
||||
|
||||
def get_value(self) -> FlagValue:
|
||||
return self.variant or self.enabled
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, resp: Any) -> "FeatureFlag":
|
||||
reason = None
|
||||
if resp.get("reason"):
|
||||
reason = FlagReason.from_json(resp.get("reason"))
|
||||
|
||||
metadata = None
|
||||
if resp.get("metadata"):
|
||||
metadata = FlagMetadata.from_json(resp.get("metadata"))
|
||||
else:
|
||||
metadata = LegacyFlagMetadata(payload=None)
|
||||
|
||||
return cls(
|
||||
key=resp.get("key"),
|
||||
enabled=resp.get("enabled"),
|
||||
variant=resp.get("variant"),
|
||||
reason=reason,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_value_and_payload(
|
||||
cls, key: str, value: FlagValue, payload: Any
|
||||
) -> "FeatureFlag":
|
||||
enabled, variant = (True, value) if isinstance(value, str) else (value, None)
|
||||
return cls(
|
||||
key=key,
|
||||
enabled=enabled,
|
||||
variant=variant,
|
||||
reason=None,
|
||||
metadata=LegacyFlagMetadata(
|
||||
payload=payload,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FlagsResponse(TypedDict, total=False):
|
||||
flags: dict[str, FeatureFlag]
|
||||
errorsWhileComputingFlags: bool
|
||||
requestId: str
|
||||
quotaLimit: Optional[List[str]]
|
||||
evaluatedAt: Optional[int]
|
||||
|
||||
|
||||
class FlagsAndPayloads(TypedDict, total=True):
|
||||
featureFlags: Optional[dict[str, FlagValue]]
|
||||
featureFlagPayloads: Optional[dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureFlagResult:
|
||||
"""
|
||||
The result of calling a feature flag which includes the flag result, variant, and payload.
|
||||
|
||||
Attributes:
|
||||
key (str): The unique identifier of the feature flag.
|
||||
enabled (bool): Whether the feature flag is enabled for the current context.
|
||||
variant (Optional[str]): The variant value if the flag is enabled and has variants, None otherwise.
|
||||
payload (Optional[Any]): Additional data associated with the feature flag, if any.
|
||||
reason (Optional[str]): A description of why the flag was enabled or disabled, if available.
|
||||
"""
|
||||
|
||||
key: str
|
||||
enabled: bool
|
||||
variant: Optional[str]
|
||||
payload: Optional[Any]
|
||||
reason: Optional[str]
|
||||
|
||||
def get_value(self) -> FlagValue:
|
||||
"""
|
||||
Returns the value of the flag. This is the variant if it exists, otherwise the enabled value.
|
||||
This is the value we report as `$feature_flag_response` in the `$feature_flag_called` event.
|
||||
|
||||
Returns:
|
||||
FlagValue: Either a string variant or boolean value representing the flag's state.
|
||||
"""
|
||||
return self.variant or self.enabled
|
||||
|
||||
@classmethod
|
||||
def from_value_and_payload(
|
||||
cls, key: str, value: Union[FlagValue, None], payload: Any
|
||||
) -> Union["FeatureFlagResult", None]:
|
||||
"""
|
||||
Creates a FeatureFlagResult from a flag value and payload.
|
||||
|
||||
Args:
|
||||
key (str): The unique identifier of the feature flag.
|
||||
value (Union[FlagValue, None]): The value of the flag (string variant or boolean).
|
||||
payload (Any): Additional data associated with the feature flag.
|
||||
|
||||
Returns:
|
||||
Union[FeatureFlagResult, None]: A new FeatureFlagResult instance, or None if value is None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
enabled, variant = (True, value) if isinstance(value, str) else (value, None)
|
||||
return cls(
|
||||
key=key,
|
||||
enabled=enabled,
|
||||
variant=variant,
|
||||
payload=json.loads(payload)
|
||||
if isinstance(payload, str) and payload
|
||||
else payload,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_flag_details(
|
||||
cls,
|
||||
details: Union[FeatureFlag, None],
|
||||
override_match_value: Optional[FlagValue] = None,
|
||||
) -> "FeatureFlagResult | None":
|
||||
"""
|
||||
Create a FeatureFlagResult from a FeatureFlag object.
|
||||
|
||||
Args:
|
||||
details (Union[FeatureFlag, None]): The FeatureFlag object to convert.
|
||||
override_match_value (Optional[FlagValue]): If provided, this value will be used to populate
|
||||
the enabled and variant fields instead of the values from the FeatureFlag.
|
||||
|
||||
Returns:
|
||||
FeatureFlagResult | None: A new FeatureFlagResult instance, or None if details is None.
|
||||
"""
|
||||
|
||||
if details is None:
|
||||
return None
|
||||
|
||||
if override_match_value is not None:
|
||||
enabled, variant = (
|
||||
(True, override_match_value)
|
||||
if isinstance(override_match_value, str)
|
||||
else (override_match_value, None)
|
||||
)
|
||||
else:
|
||||
enabled, variant = (details.enabled, details.variant)
|
||||
|
||||
return cls(
|
||||
key=details.key,
|
||||
enabled=enabled,
|
||||
variant=variant,
|
||||
payload=(
|
||||
json.loads(details.metadata.payload)
|
||||
if isinstance(details.metadata.payload, str)
|
||||
and details.metadata.payload
|
||||
else details.metadata.payload
|
||||
),
|
||||
reason=details.reason.description if details.reason else None,
|
||||
)
|
||||
|
||||
|
||||
def normalize_flags_response(resp: Any) -> FlagsResponse:
|
||||
"""
|
||||
Normalize the response from the decide or flags API endpoint into a FlagsResponse.
|
||||
|
||||
Args:
|
||||
resp: A v3 or v4 response from the decide (or a v1 or v2 response from the flags) API endpoint.
|
||||
|
||||
Returns:
|
||||
A FlagsResponse containing feature flags and their details.
|
||||
"""
|
||||
if "requestId" not in resp:
|
||||
resp["requestId"] = None
|
||||
if "flags" in resp:
|
||||
flags = resp["flags"]
|
||||
# For each flag, create a FeatureFlag object
|
||||
for key, value in flags.items():
|
||||
if isinstance(value, FeatureFlag):
|
||||
continue
|
||||
value["key"] = key
|
||||
flags[key] = FeatureFlag.from_json(value)
|
||||
else:
|
||||
# Handle legacy format
|
||||
featureFlags = resp.get("featureFlags", {})
|
||||
featureFlagPayloads = resp.get("featureFlagPayloads", {})
|
||||
resp.pop("featureFlags", None)
|
||||
resp.pop("featureFlagPayloads", None)
|
||||
# look at each key in featureFlags and create a FeatureFlag object
|
||||
flags = {}
|
||||
for key, value in featureFlags.items():
|
||||
flags[key] = FeatureFlag.from_value_and_payload(
|
||||
key, value, featureFlagPayloads.get(key, None)
|
||||
)
|
||||
resp["flags"] = flags
|
||||
return cast(FlagsResponse, resp)
|
||||
|
||||
|
||||
def to_flags_and_payloads(resp: FlagsResponse) -> FlagsAndPayloads:
|
||||
"""
|
||||
Convert a FlagsResponse into a FlagsAndPayloads object which is a
|
||||
dict of feature flags and their payloads. This is needed by certain
|
||||
functions in the client.
|
||||
Args:
|
||||
resp: A FlagsResponse containing feature flags and their payloads.
|
||||
|
||||
Returns:
|
||||
A tuple containing:
|
||||
- A dictionary mapping flag keys to their values (bool or str)
|
||||
- A dictionary mapping flag keys to their payloads
|
||||
"""
|
||||
return {"featureFlags": to_values(resp), "featureFlagPayloads": to_payloads(resp)}
|
||||
|
||||
|
||||
def to_values(response: FlagsResponse) -> Optional[dict[str, FlagValue]]:
|
||||
if "flags" not in response:
|
||||
return None
|
||||
|
||||
flags = response.get("flags", {})
|
||||
return {
|
||||
key: value.get_value()
|
||||
for key, value in flags.items()
|
||||
if isinstance(value, FeatureFlag)
|
||||
}
|
||||
|
||||
|
||||
def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
|
||||
if "flags" not in response:
|
||||
return None
|
||||
|
||||
return {
|
||||
key: value.metadata.payload
|
||||
for key, value in response.get("flags", {}).items()
|
||||
if isinstance(value, FeatureFlag)
|
||||
and value.enabled
|
||||
and value.metadata.payload is not None
|
||||
}
|
||||
|
||||
|
||||
class FeatureFlagError:
|
||||
"""Error type constants for the $feature_flag_error property.
|
||||
|
||||
These values are sent in analytics events to track flag evaluation failures.
|
||||
They should not be changed without considering impact on existing dashboards
|
||||
and queries that filter on these values.
|
||||
|
||||
Error values:
|
||||
ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true
|
||||
FLAG_MISSING: Requested flag not in API response
|
||||
QUOTA_LIMITED: Rate/quota limit exceeded
|
||||
TIMEOUT: Request timed out
|
||||
CONNECTION_ERROR: Network connectivity issue
|
||||
UNKNOWN_ERROR: Unexpected exceptions
|
||||
|
||||
For API errors with status codes, use the api_error() method which returns
|
||||
a string like "api_error_500".
|
||||
"""
|
||||
|
||||
ERRORS_WHILE_COMPUTING = "errors_while_computing_flags"
|
||||
FLAG_MISSING = "flag_missing"
|
||||
QUOTA_LIMITED = "quota_limited"
|
||||
TIMEOUT = "timeout"
|
||||
CONNECTION_ERROR = "connection_error"
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
|
||||
@staticmethod
|
||||
def api_error(status: Union[int, str]) -> str:
|
||||
"""Generate API error string with status code.
|
||||
|
||||
Args:
|
||||
status: HTTP status code from the API error
|
||||
|
||||
Returns:
|
||||
Error string like "api_error_500"
|
||||
"""
|
||||
return f"api_error_{status}"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user