Compare commits

..
Author SHA1 Message Date
Rafa Audibert 480a18b582 fix: Fix syntax warning
This is a syntax error in Python 3.8+ (it was deprecated earlier and became an error). The return statement should be moved outside the finally block.

We've never noticed this, but we can see it because of the new github action added on https://github.com/PostHog/posthog-python/pull/404
2026-01-07 22:16:17 -03:00
pauldambra 78482b188c chore: check for syntax warnings 2026-01-07 22:56:11 +00:00
109 changed files with 2178 additions and 8100 deletions
+6 -6
View File
@@ -1,11 +1,11 @@
# Hanzo Insights API Configuration
# PostHog 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 project API key (found on the /setup page in PostHog)
POSTHOG_PROJECT_API_KEY=phc_your_project_api_key_here
# Your personal API key (for local evaluation and other advanced features)
INSIGHTS_PERSONAL_API_KEY=phx_your_personal_api_key_here
POSTHOG_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
# PostHog host URL (remove this line if using posthog.com)
POSTHOG_HOST=http://localhost:8000
-9
View File
@@ -1,9 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+4 -4
View File
@@ -9,7 +9,7 @@ permissions:
jobs:
code-quality:
name: Code quality checks
runs-on: hanzo-build-linux-amd64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
@@ -46,7 +46,7 @@ jobs:
tests:
name: Python ${{ matrix.python-version }} tests
runs-on: hanzo-build-linux-amd64
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
@@ -78,7 +78,7 @@ jobs:
import-check:
name: Python ${{ matrix.python-version }} import check
runs-on: hanzo-build-linux-amd64
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
@@ -101,7 +101,7 @@ jobs:
django5-integration:
name: Django 5 integration tests
runs-on: hanzo-build-linux-amd64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
-45
View File
@@ -1,45 +0,0 @@
name: 'CodeQL Advanced'
on:
push:
branches: ['master']
pull_request:
branches: ['master']
schedule:
- cron: '32 13 * * 1'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: 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}}'
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
docs-generation:
name: Generate references
runs-on: hanzo-build-linux-amd64
runs-on: ubuntu-latest
permissions:
contents: write
steps:
+31 -222
View File
@@ -1,249 +1,58 @@
name: "Release"
on:
pull_request:
types: [closed]
branches: [master]
push:
branches:
- master
paths:
- "posthog/version.py"
workflow_dispatch:
permissions:
contents: read
# Concurrency control: only one release process can run at a time
# This prevents race conditions if multiple PRs with 'release' label merge simultaneously
concurrency:
group: release
cancel-in-progress: false
jobs:
check-release-label:
name: Check for release label
runs-on: 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
name: Publish release
runs-on: ubuntu-latest
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
- name: Checkout the repository
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }}
message: "✅ Release approved! Version bump in progress..."
emoji_reaction: "white_check_mark"
- name: Get GitHub App token
id: releaser
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_APP_POSTHOG_PYTHON_RELEASER_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_PYTHON_RELEASER_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
token: ${{ steps.releaser.outputs.token }}
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: 3.11.11
- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
enable-cache: true
pyproject-file: "pyproject.toml"
enable-cache: true
pyproject-file: 'pyproject.toml'
- name: Detect version
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
- name: Install Rust
uses: dtolnay/rust-toolchain@0b1efabc08b657293548b77fb76cc02d26091c7e
with:
toolchain: 1.91.1
components: cargo
- name: Cache Sampo CLI
id: cache-sampo
uses: actions/cache@v3
with:
path: ~/.cargo/bin/sampo
key: sampo-${{ runner.os }}-${{ runner.arch }}
- name: Install Sampo CLI
if: steps.cache-sampo.outputs.cache-hit != 'true'
run: cargo install sampo
- name: Install dependencies
- name: Prepare for building release
run: uv sync --extra dev
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Prepare release with Sampo
id: sampo-release
- name: Push releases to PyPI
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"
TWINE_USERNAME: __token__
run: uv run make release && uv run make release_analytics
- 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'
- name: Create GitHub release
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"
run: |
gh release create "v${{ env.REPO_VERSION }}" \
--title "${{ env.REPO_VERSION }}" \
--generate-notes
- name: Dispatch generate-references for posthog-python
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run generate-references.yml --ref master
-21
View File
@@ -1,21 +0,0 @@
name: SDK Compliance Tests
permissions:
contents: read
packages: read
pull-requests: write
on:
pull_request:
push:
branches:
- master
jobs:
compliance:
name: PostHog SDK compliance tests
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@main
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
adapter-context: "."
test-harness-version: "latest"
-5
View File
@@ -1,5 +0,0 @@
---
pypi/posthog: patch
---
feat(llma): support fetching versioned prompts from the prompts sdk
-19
View File
@@ -1,19 +0,0 @@
# Sampo configuration
version = 1
[git]
default_branch = "master"
short_tags = "posthog" # Tag with v1.2.3 rather than posthog-v1.2.3
[github]
repository = "posthog/posthog-python"
[changelog]
# Options for release notes generation.
# show_commit_hash = true (default)
# show_acknowledgments = true (default)
[packages]
# Options for package discovery and filtering.
# ignore_unpublished = false (default)
# ignore = ["internal-*", "examples/*"]
+7 -7
View File
@@ -1,6 +1,6 @@
# Before Send Hook
The `before_send` parameter allows you to modify or filter events before they are sent to Insights. This is useful for:
The `before_send` parameter allows you to modify or filter events before they are sent to PostHog. This is useful for:
- **Privacy**: Removing or masking sensitive data (PII)
- **Filtering**: Dropping unwanted events (test events, internal users, etc.)
@@ -10,12 +10,12 @@ The `before_send` parameter allows you to modify or filter events before they ar
## Basic Usage
```python
import hanzo_insights
import posthog
from typing import Optional, Dict, Any
def my_before_send(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Process event before sending to Insights.
Process event before sending to PostHog.
Args:
event: The event dictionary containing 'event', 'distinct_id', 'properties', etc.
@@ -27,7 +27,7 @@ def my_before_send(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return event
# Initialize client with before_send hook
client = hanzo_insights.Client(
client = posthog.Client(
api_key="your-project-api-key",
before_send=my_before_send
)
@@ -166,7 +166,7 @@ def should_drop_event(event: dict[str, Any]) -> bool:
## Error Handling
If your `before_send` function raises an exception, Insights will:
If your `before_send` function raises an exception, PostHog will:
1. Log the error
2. Continue with the original, unmodified event
@@ -184,7 +184,7 @@ def risky_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
## Complete Example
```python
import hanzo_insights
import posthog
from typing import Optional, Any
import re
@@ -227,7 +227,7 @@ def production_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
return event # Return original event on error
# Usage
client = hanzo_insights.Client(
client = posthog.Client(
api_key="your-api-key",
before_send=production_before_send
)
+54 -143
View File
@@ -1,116 +1,27 @@
# posthog
# 7.5.1 - 2026-01-08
## 7.9.7 — 2026-03-05
fix: Fix incorrect syntax with `return` inside `finally` block
### 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
# 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
# 7.4.3 - 2026-01-02
Fixes cache creation cost for Langchain with Anthropic
## 7.4.2 - 2025-12-22
# 7.4.2 - 2025-12-22
feat: add `in_app_modules` option to control code variables capturing
## 7.4.1 - 2025-12-19
# 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
# 7.4.0 - 2025-12-16
feat: Add automatic retries for feature flag requests
@@ -122,32 +33,32 @@ Feature flag API requests now automatically retry on transient failures:
Rate limit (429) and quota (402) errors are not retried.
## 7.3.1 - 2025-12-06
# 7.3.1 - 2025-12-06
fix: remove unused $exception_message and $exception_type
## 7.3.0 - 2025-12-05
# 7.3.0 - 2025-12-05
feat: improve code variables capture masking
## 7.2.0 - 2025-12-01
# 7.2.0 - 2025-12-01
feat: add $feature_flag_evaluated_at properties to $feature_flag_called events
## 7.1.0 - 2025-11-26
# 7.1.0 - 2025-11-26
Add support for the async version of Gemini.
## 7.0.2 - 2025-11-18
# 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
# 7.0.1 - 2025-11-15
Try to use repr() when formatting code variables
## 7.0.0 - 2025-11-11
# 7.0.0 - 2025-11-11
NB Python 3.9 is no longer supported
@@ -161,155 +72,155 @@ NB Python 3.9 is no longer supported
- langchain-community: 0.3.29 → 0.4.1
- langgraph: 0.6.6 → 1.0.2
## 6.9.3 - 2025-11-10
# 6.9.3 - 2025-11-10
- feat(ph-ai): PostHog properties dict in GenerationMetadata
## 6.9.2 - 2025-11-10
# 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
# 6.9.1 - 2025-11-07
- fix(error-tracking): pass code variables config from init to client
## 6.9.0 - 2025-11-06
# 6.9.0 - 2025-11-06
- feat(error-tracking): add local variables capture
## 6.8.0 - 2025-11-03
# 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
# 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
# 6.7.13 - 2025-11-02
- fix(llma): cache cost calculation in the LangChain callback
## 6.7.12 - 2025-11-02
# 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
# 6.7.11 - 2025-10-28
- feat(ai): Add `$ai_framework` property for framework integrations (e.g. LangChain)
## 6.7.10 - 2025-10-24
# 6.7.10 - 2025-10-24
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
## 6.7.9 - 2025-10-22
# 6.7.9 - 2025-10-22
- fix(flags): multi-condition flags with static cohorts returning wrong variants
## 6.7.8 - 2025-10-16
# 6.7.8 - 2025-10-16
- fix(llma): missing async for OpenAI's streaming implementation
## 6.7.7 - 2025-10-14
# 6.7.7 - 2025-10-14
- fix: remove deprecated attribute $exception_personURL from exception events
## 6.7.6 - 2025-09-16
# 6.7.6 - 2025-09-16
- fix: don't sort condition sets with variant overrides to the top
- fix: Prevent core Client methods from raising exceptions
## 6.7.5 - 2025-09-16
# 6.7.5 - 2025-09-16
- feat: Django middleware now supports async request handling.
## 6.7.4 - 2025-09-05
# 6.7.4 - 2025-09-05
- fix: Missing system prompts for some providers
## 6.7.3 - 2025-09-04
# 6.7.3 - 2025-09-04
- fix: missing usage tokens in Gemini
## 6.7.2 - 2025-09-03
# 6.7.2 - 2025-09-03
- fix: tool call results in streaming providers
## 6.7.1 - 2025-09-01
# 6.7.1 - 2025-09-01
- fix: Add base64 inline image sanitization
## 6.7.0 - 2025-08-26
# 6.7.0 - 2025-08-26
- feat: Add support for feature flag dependencies
## 6.6.1 - 2025-08-21
# 6.6.1 - 2025-08-21
- fix: Prevent `NoneType` error when `group_properties` is `None`
## 6.6.0 - 2025-08-15
# 6.6.0 - 2025-08-15
- feat: Add `flag_keys_to_evaluate` parameter to optimize feature flag evaluation performance by only evaluating specified flags
- feat: Add `flag_keys_filter` option to `send_feature_flags` for selective flag evaluation in capture events
## 6.5.0 - 2025-08-08
# 6.5.0 - 2025-08-08
- feat: Add `$context_tags` to an event to know which properties were included as tags
## 6.4.1 - 2025-08-06
# 6.4.1 - 2025-08-06
- fix: Always pass project API key in `remote_config` requests for deterministic project routing
## 6.4.0 - 2025-08-05
# 6.4.0 - 2025-08-05
- feat: support Vertex AI for Gemini
## 6.3.4 - 2025-08-04
# 6.3.4 - 2025-08-04
- fix: set `$ai_tools` for all providers and `$ai_output_choices` for all non-streaming provider flows properly
## 6.3.3 - 2025-08-01
# 6.3.3 - 2025-08-01
- fix: `get_feature_flag_result` now correctly returns FeatureFlagResult when payload is empty string instead of None
## 6.3.2 - 2025-07-31
# 6.3.2 - 2025-07-31
- fix: Anthropic's tool calls are now handled properly
## 6.3.0 - 2025-07-22
# 6.3.0 - 2025-07-22
- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties
## 6.2.1 - 2025-07-21
# 6.2.1 - 2025-07-21
- feat: make `posthog_client` an optional argument in PostHog AI providers wrappers (`posthog.ai.*`), intuitively using the default client as the default
## 6.1.1 - 2025-07-16
# 6.1.1 - 2025-07-16
- fix: correctly capture exceptions processed by Django from views or middleware
## 6.1.0 - 2025-07-10
# 6.1.0 - 2025-07-10
- feat: decouple feature flag local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller
## 6.0.4 - 2025-07-09
# 6.0.4 - 2025-07-09
- fix: add POSTHOG_MW_CLIENT setting to django middleware, to support custom clients for exception capture.
## 6.0.3 - 2025-07-07
# 6.0.3 - 2025-07-07
- feat: add a feature flag evaluation cache (local storage or redis) to support returning flag evaluations when the service is down
## 6.0.2 - 2025-07-02
# 6.0.2 - 2025-07-02
- fix: send_feature_flags changed to default to false in `Client::capture_exception`
## 6.0.1
# 6.0.1
- fix: response `$process_person_profile` property when passed to capture
## 6.0.0
# 6.0.0
This release contains a number of major breaking changes:
@@ -336,15 +247,15 @@ with posthog.new_context():
Generally, arguments are now appropriately typed, and docstrings have been updated. If something is unclear, please open an issue, or submit a PR!
## 5.4.0 - 2025-06-20
# 5.4.0 - 2025-06-20
- feat: add support to session_id context on page method
## 5.3.0 - 2025-06-19
# 5.3.0 - 2025-06-19
- fix: safely handle exception values
## 5.2.0 - 2025-06-19
# 5.2.0 - 2025-06-19
- feat: construct artificial stack traces if no traceback is available on a captured exception
-1
View File
@@ -1 +0,0 @@
LLM.md
-48
View File
@@ -1,48 +0,0 @@
# 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
+18 -32
View File
@@ -5,42 +5,28 @@ test:
coverage run -m pytest
coverage report
build_release:
release:
rm -rf dist/*
python setup.py sdist bdist_wheel
twine upload dist/*
# Builds the `posthoganalytics` PyPI package, which is a mirror of `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:
release_analytics:
rm -rf dist
rm -rf build
rm -rf posthoganalytics
mkdir posthoganalytics
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' {} \;
cp -r posthog/* posthoganalytics/
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
find ./posthoganalytics -name "*.bak" -delete
rm -rf hanzo_insights
rm -rf posthog
python setup_analytics.py sdist bdist_wheel
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' {} \;
twine upload dist/*
mkdir posthog
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from posthog /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from posthog\./g' {} \;
find ./posthoganalytics -name "*.bak" -delete
cp -r posthoganalytics/* hanzo_insights/
cp -r posthoganalytics/* posthog/
rm -rf posthoganalytics
rm -f pyproject.toml
cp pyproject.toml.backup pyproject.toml
@@ -55,17 +41,17 @@ prep_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 && cp -r posthog/* posthoganalytics/
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
cd ../posthog-python-local && find ./posthoganalytics -name "*.bak" -delete
cd ../posthog-python-local && rm -rf hanzo_insights
cd ../posthog-python-local && rm -rf posthog
cd ../posthog-python-local && sed -i.bak 's/from version import VERSION/from posthoganalytics.version import VERSION/' setup_analytics.py
cd ../posthog-python-local && rm setup_analytics.py.bak
cd ../posthog-python-local && sed -i.bak 's/"hanzo_insights"/"posthoganalytics"/' setup.py
cd ../posthog-python-local && sed -i.bak 's/"posthog"/"posthoganalytics"/' setup.py
cd ../posthog-python-local && rm setup.py.bak
cd ../posthog-python-local && python -c "import setup_analytics" 2>/dev/null || true
@echo "Local copy created at ../posthog-python-local"
@echo "Install with: pip install -e ../posthog-python-local"
.PHONY: test lint build_release build_release_analytics e2e_test prep_local
.PHONY: test lint release e2e_test prep_local
+53 -51
View File
@@ -1,53 +1,41 @@
<p align="center"><img src=".github/hero.svg" alt="insights-python" width="880"></p>
# PostHog Python
# Hanzo Insights Python SDK
<p align="center">
<img alt="posthoglogo" src="https://user-images.githubusercontent.com/65415371/205059737-c8a4f836-4889-4654-902e-f302b187b6a0.png">
</p>
<p align="center">
<a href="https://pypi.org/project/posthog/"><img alt="pypi installs" src="https://img.shields.io/pypi/v/posthog"/></a>
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/posthog/posthog-python">
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/posthog/posthog-python"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/posthog/posthog-python"/>
</p>
Integrate [Hanzo Insights](https://insights.hanzo.ai) into any Python application.
## 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()
```
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
## 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 |
| SDK Version | Python Versions Supported | Notes |
|-------------|---------------------------|-------|
| 7.3.1+ | 3.10, 3.11, 3.12, 3.13, 3.14 | Added Python 3.14 support |
| 7.0.0 - 7.0.1 | 3.10, 3.11, 3.12, 3.13 | Dropped Python 3.9 support |
| 4.0.1 - 6.x | 3.9, 3.10, 3.11, 3.12, 3.13 | Python 3.9+ required |
## Development
We use [uv](https://docs.astral.sh/uv/).
### Testing Locally
We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
1. Run `uv venv env` (creates virtual environment called "env")
* or `python3 -m venv env`
2. Run `source env/bin/activate` (activates the virtual environment)
3. Run `uv sync --extra dev --extra test` (installs the package in develop mode, along with test dependencies)
* or `pip install -e ".[dev,test]"`
4. you have to run `pre-commit install` to have auto linting pre commit
5. Run `make test`
1. To run a specific test do `pytest -k test_no_api_key`
## PostHog recommends `uv` so...
```bash
uv python install 3.12
@@ -59,14 +47,28 @@ pre-commit install
make test
```
### Running Tests
### Running Locally
```bash
make test
# or run a specific test:
pytest -k test_no_api_key
Assuming you have a [local version of PostHog](https://posthog.com/docs/developing-locally) running, you can run `python3 example.py` to see the library in action.
### Releasing Versions
Updates are released automatically using GitHub Actions when `version.py` is updated on `master`. After bumping `version.py` in `master` and adding to `CHANGELOG.md`, the [release workflow](https://github.com/PostHog/posthog-python/blob/master/.github/workflows/release.yaml) will automatically trigger and deploy the new version.
If you need to check the latest runs or manually trigger a release, you can go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
### Testing changes locally with the PostHog app
You can run `make prep_local`, and it'll create a new folder alongside the SDK repo one called `posthog-python-local`, which you can then import into the posthog project by changing pyproject.toml to look like this:
```toml
dependencies = [
...
"posthoganalytics" #NOTE: no version number
...
]
...
[tools.uv.sources]
posthoganalytics = { path = "../posthog-python-local" }
```
## License
MIT
This'll let you build and test SDK changes fully locally, incorporating them into your local posthog app stack. It mainly takes care of the `posthog -> posthoganalytics` module renaming. You'll need to re-run `make prep_local` each time you make a change, and re-run `uv sync --active` in the posthog app project.
-11
View File
@@ -1,11 +0,0 @@
# posthoganalytics
> **Do not use this package.** Use [`posthog`](https://pypi.org/project/posthog/) instead.
```bash
pip install posthog
```
This package exists solely for internal use by [posthog/posthog](https://github.com/posthog/posthog) to avoid import conflicts with the local `posthog` package in that repository. It is an automatically generated mirror of `posthog` — same code, same versions, just published under a different name.
If you are not working on the PostHog main repository, you should never need this package. All documentation, issues, and development happen in [`posthog-python`](https://github.com/posthog/posthog-python).
+1 -1
View File
@@ -4,5 +4,5 @@
source bin/helpers/_utils.sh
set_source_and_root_dir
flake8 hanzo_insights --ignore E501,W503
flake8 posthog --ignore E501,W503
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#/ Usage: bin/docs
#/ Description: Generate documentation for the Insights Python SDK
#/ Description: Generate documentation for the PostHog Python SDK
source bin/helpers/_utils.sh
set_source_and_root_dir
ensure_virtual_env
+6 -6
View File
@@ -1,15 +1,15 @@
"""
Constants for Insights Python SDK documentation generation.
Constants for PostHog Python SDK documentation generation.
"""
from typing import Dict, Union
from hanzo_insights.version import VERSION
from posthog.version import VERSION
# Documentation generation metadata
DOCUMENTATION_METADATA = {
"hogRef": "0.3",
"slugPrefix": "insights-python",
"specUrl": "https://github.com/Insights/insights-python",
"slugPrefix": "posthog-python",
"specUrl": "https://github.com/PostHog/posthog-python",
}
# Docstring parsing patterns for new format
@@ -29,8 +29,8 @@ DOCSTRING_PATTERNS = {
# 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",
"filename": f"posthog-python-references-{VERSION}.json",
"filename_latest": "posthog-python-references-latest.json",
"indent": 2,
}
+26 -26
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Generate comprehensive SDK documentation JSON from Insights Python SDK.
Generate comprehensive SDK documentation JSON from PostHog Python SDK.
This script inspects the code and docstrings to create documentation in the specified format.
"""
@@ -337,19 +337,19 @@ def analyze_type(cls) -> dict:
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
# Import PostHog components
import posthog
from posthog.client import Client
import posthog.types as types_module
import posthog.args as args_module
from posthog.version import VERSION
# Main SDK info
sdk_info = {
"version": VERSION,
"id": "insights-python",
"title": "Insights Python SDK",
"description": "Integrate Insights into any python application.",
"id": "posthog-python",
"title": "PostHog Python SDK",
"description": "Integrate PostHog into any python application.",
"slugPrefix": DOCUMENTATION_METADATA["slugPrefix"],
"specUrl": DOCUMENTATION_METADATA["specUrl"],
}
@@ -357,7 +357,7 @@ def generate_sdk_documentation():
# Collect types
types_list = []
# Types from hanzo_insights.types
# Types from posthog.types
for name in dir(types_module):
obj = getattr(types_module, name)
if inspect.isclass(obj) and not name.startswith("_"):
@@ -367,7 +367,7 @@ def generate_sdk_documentation():
except Exception as e:
print(f"Error analyzing type {name}: {e}")
# Types from hanzo_insights.args
# Types from posthog.args
for name in dir(args_module):
obj = getattr(args_module, name)
if inspect.isclass(obj) and not name.startswith("_"):
@@ -388,26 +388,26 @@ def generate_sdk_documentation():
# Collect classes
classes_list = []
# Main Insights class (renamed from Client)
# Main PostHog class (renamed from Client)
client_class = analyze_class(Client)
client_class["id"] = "Insights"
client_class["title"] = "Insights"
client_class["id"] = "PostHog"
client_class["title"] = "PostHog"
classes_list.append(client_class)
# Global module functions (functions callable as hanzo_insights.function_name)
# Global module functions (functions callable as posthog.function_name)
global_functions = []
for func_name in dir(hanzo_insights):
for func_name in dir(posthog):
# Skip private functions and non-callables
if func_name.startswith("_") or not callable(getattr(hanzo_insights, func_name)):
if func_name.startswith("_") or not callable(getattr(posthog, func_name)):
continue
func = getattr(hanzo_insights, func_name)
# Only include functions actually defined in the hanzo_insights module (not imported)
func = getattr(posthog, func_name)
# Only include functions actually defined in the posthog module (not imported)
# and exclude class references
if (
func_name not in ["Client", "Insights"]
func_name not in ["Client", "Posthog"]
and hasattr(func, "__module__")
and func.__module__ == "hanzo_insights"
and func.__module__ == "posthog"
):
try:
func_info = analyze_function(func, func_name)
@@ -421,8 +421,8 @@ def generate_sdk_documentation():
classes_list.append(
{
"id": "PostHogModule",
"title": "Insights Module Functions",
"description": "Global functions available in the Insights module",
"title": "PostHog Module Functions",
"description": "Global functions available in the PostHog module",
"functions": global_functions,
}
)
@@ -443,7 +443,7 @@ def generate_sdk_documentation():
# Create the final structure
result = {
"id": "insights-python",
"id": "posthog-python",
"hogRef": DOCUMENTATION_METADATA["hogRef"],
"info": sdk_info,
"types": types_list,
@@ -455,7 +455,7 @@ def generate_sdk_documentation():
if __name__ == "__main__":
print("Generating Insights Python SDK documentation...")
print("Generating PostHog Python SDK documentation...")
try:
documentation = generate_sdk_documentation()
+87 -87
View File
@@ -1,18 +1,18 @@
# Hanzo Insights Python library example
# PostHog Python library example
#
# This script demonstrates various Hanzo Insights Python SDK capabilities including:
# This script demonstrates various PostHog Python SDK capabilities including:
# - Basic event capture and user identification
# - Feature flag local evaluation
# - Feature flag payloads
# - Context management and tagging
#
# Setup:
# 1. Copy .env.example to .env and fill in your Insights credentials
# 1. Copy .env.example to .env and fill in your PostHog credentials
# 2. Run this script and choose from the interactive menu
import os
import hanzo_insights
import posthog
def load_env_file():
@@ -31,30 +31,30 @@ def load_env_file():
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")
project_key = os.getenv("POSTHOG_PROJECT_API_KEY", "")
personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "")
host = os.getenv("POSTHOG_HOST", "http://localhost:8000")
# Check if project key is provided (required)
if not project_key:
print("❌ Missing Insights project API key!")
print(" Please set INSIGHTS_PROJECT_API_KEY environment variable")
print("❌ Missing PostHog project API key!")
print(" Please set POSTHOG_PROJECT_API_KEY environment variable")
print(" or copy .env.example to .env and fill in your values")
exit(1)
# Configure 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
# Configure PostHog with credentials
posthog.debug = False
posthog.api_key = project_key
posthog.project_api_key = project_key
posthog.host = host
posthog.poll_interval = 10
# 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
posthog.personal_api_key = personal_api_key
print("🔑 Insights Configuration:")
print("🔑 PostHog Configuration:")
print(f" Project API Key: {project_key[:9]}...")
if local_eval_available:
print(" Personal API Key: [SET]")
@@ -63,7 +63,7 @@ else:
print(f" Host: {host}\n")
# Display menu and get user choice
print("🚀 Hanzo Insights Python SDK Demo - Choose an example to run:\n")
print("🚀 PostHog Python SDK Demo - Choose an example to run:\n")
print("1. Identify and capture examples")
local_eval_note = "" if local_eval_available else " [requires personal API key]"
print(f"2. Feature flag local evaluation examples{local_eval_note}")
@@ -79,11 +79,11 @@ if choice == "1":
print("IDENTIFY AND CAPTURE EXAMPLES")
print("=" * 60)
hanzo_insights.debug = True
posthog.debug = True
# Capture an event
print("📊 Capturing events...")
hanzo_insights.capture(
posthog.capture(
"event",
distinct_id="distinct_id",
properties={"property1": "value", "property2": "value"},
@@ -92,14 +92,14 @@ if choice == "1":
# Alias a previous distinct id with a new one
print("🔗 Creating alias...")
hanzo_insights.alias("distinct_id", "new_distinct_id")
posthog.alias("distinct_id", "new_distinct_id")
hanzo_insights.capture(
posthog.capture(
"event2",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
)
hanzo_insights.capture(
posthog.capture(
"event-with-groups",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
@@ -108,28 +108,28 @@ if choice == "1":
# Add properties to the person
print("👤 Identifying user...")
hanzo_insights.set(
posthog.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})
posthog.group_identify("company", "id:5", {"employees": 11})
# Properties set only once to the person
print("🔒 Setting properties once...")
hanzo_insights.set_once(
posthog.set_once(
distinct_id="new_distinct_id", properties={"self_serve_signup": True}
)
# This will not change the property (because it was already set)
hanzo_insights.set_once(
posthog.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(
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
posthog.set(
distinct_id="new_distinct_id", properties={"current_browser": "Firefox"}
)
@@ -137,45 +137,45 @@ 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."
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
)
hanzo_insights.shutdown()
posthog.shutdown()
exit(1)
print("\n" + "=" * 60)
print("FEATURE FLAG LOCAL EVALUATION EXAMPLES")
print("=" * 60)
hanzo_insights.debug = True
posthog.debug = True
print("🏁 Testing basic feature flags...")
print(
f"beta-feature for 'distinct_id': {hanzo_insights.feature_enabled('beta-feature', 'distinct_id')}"
f"beta-feature for 'distinct_id': {posthog.feature_enabled('beta-feature', 'distinct_id')}"
)
print(
f"beta-feature for 'new_distinct_id': {hanzo_insights.feature_enabled('beta-feature', 'new_distinct_id')}"
f"beta-feature for 'new_distinct_id': {posthog.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'})}"
f"beta-feature with groups: {posthog.feature_enabled('beta-feature-groups', 'distinct_id', groups={'company': 'id:5'})}"
)
print("\n🌍 Testing location-based flags...")
# Assume test-flag has `City Name = Sydney` as a person property set
print(
f"Sydney user: {hanzo_insights.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
f"Sydney user: {posthog.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)}"
f"Sydney user (local only): {posthog.feature_enabled('test-flag', 'distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
)
print("\n📋 Getting all flags...")
print(f"All flags: {hanzo_insights.get_all_flags('distinct_id_random_22')}")
print(f"All flags: {posthog.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)}"
f"All flags (local): {posthog.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)}"
f"All flags with properties: {posthog.get_all_flags('distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
)
elif choice == "3":
@@ -183,22 +183,22 @@ elif choice == "3":
print("FEATURE FLAG PAYLOAD EXAMPLES")
print("=" * 60)
hanzo_insights.debug = True
posthog.debug = True
print("📦 Testing feature flag payloads...")
print(
f"beta-feature payload: {hanzo_insights.get_feature_flag_payload('beta-feature', 'distinct_id')}"
f"beta-feature payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}"
)
print(
f"All flags and payloads: {hanzo_insights.get_all_flags_and_payloads('distinct_id')}"
f"All flags and payloads: {posthog.get_all_flags_and_payloads('distinct_id')}"
)
print(
f"Remote config payload: {hanzo_insights.get_remote_config_payload('encrypted_payload_flag_key')}"
f"Remote config payload: {posthog.get_remote_config_payload('encrypted_payload_flag_key')}"
)
# Get feature flag result with all details (enabled, variant, payload, key, reason)
print("\n🔍 Getting detailed flag result...")
result = hanzo_insights.get_feature_flag_result("beta-feature", "distinct_id")
result = posthog.get_feature_flag_result("beta-feature", "distinct_id")
if result:
print(f"Flag key: {result.key}")
print(f"Flag enabled: {result.enabled}")
@@ -212,9 +212,9 @@ 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."
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
)
hanzo_insights.shutdown()
posthog.shutdown()
exit(1)
print("\n" + "=" * 60)
@@ -234,10 +234,10 @@ elif choice == "4":
print(" - Rollout: 100%")
print("")
hanzo_insights.debug = True
posthog.debug = True
# Test @example.com user (should satisfy dependency if flags exist)
result1 = hanzo_insights.feature_enabled(
result1 = posthog.feature_enabled(
"test-flag-dependency",
"example_user",
person_properties={"email": "user@example.com"},
@@ -246,7 +246,7 @@ elif choice == "4":
print(f"✅ @example.com user (test-flag-dependency): {result1}")
# Test non-example.com user (dependency should not be satisfied)
result2 = hanzo_insights.feature_enabled(
result2 = posthog.feature_enabled(
"test-flag-dependency",
"regular_user",
person_properties={"email": "user@other.com"},
@@ -255,13 +255,13 @@ elif choice == "4":
print(f"❌ Regular user (test-flag-dependency): {result2}")
# Test beta-feature directly for comparison
beta1 = hanzo_insights.feature_enabled(
beta1 = posthog.feature_enabled(
"beta-feature",
"example_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
beta2 = hanzo_insights.feature_enabled(
beta2 = posthog.feature_enabled(
"beta-feature",
"regular_user",
person_properties={"email": "user@other.com"},
@@ -303,7 +303,7 @@ elif choice == "4":
print("")
# Test pineapple -> blue -> breaking-bad chain
dependent_result3 = hanzo_insights.get_feature_flag(
dependent_result3 = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": "pineapple@example.com"},
@@ -317,7 +317,7 @@ elif choice == "4":
print("'multivariate-root-flag' with email pineapple@example.com succeeded")
# Test mango -> red -> the-wire chain
dependent_result4 = hanzo_insights.get_feature_flag(
dependent_result4 = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": "mango@example.com"},
@@ -336,19 +336,19 @@ elif choice == "4":
("pineapple@example.com", ["pineapple", "blue", "breaking-bad"]),
("mango@example.com", ["mango", "red", "the-wire"]),
]:
leaf = hanzo_insights.get_feature_flag(
leaf = posthog.get_feature_flag(
"multivariate-leaf-flag",
"regular_user",
person_properties={"email": email},
only_evaluate_locally=True,
)
intermediate = hanzo_insights.get_feature_flag(
intermediate = posthog.get_feature_flag(
"multivariate-intermediate-flag",
"regular_user",
person_properties={"email": email},
only_evaluate_locally=True,
)
root = hanzo_insights.get_feature_flag(
root = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": email},
@@ -373,7 +373,7 @@ elif choice == "5":
print("CONTEXT MANAGEMENT AND TAGGING EXAMPLES")
print("=" * 60)
hanzo_insights.debug = True
posthog.debug = True
print("🏷️ Testing context management...")
print(
@@ -384,12 +384,12 @@ elif choice == "5":
# 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"})
with posthog.new_context():
posthog.tag("transaction_id", "abc123")
posthog.tag("some_arbitrary_value", {"tags": "can be dicts"})
# This event will be captured with the tags set above
hanzo_insights.capture("order_processed")
posthog.capture("order_processed")
print("✅ Event captured with inherited context tags")
# This exception will be captured with the tags set above
# raise Exception("Order processing failed")
@@ -398,30 +398,30 @@ elif choice == "5":
# 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")
with posthog.new_context(fresh=True):
posthog.tag("session_id", "xyz789")
# Only session_id tag will be present, no inherited tags
hanzo_insights.capture("session_event")
posthog.capture("session_event")
print("✅ Event captured with fresh context tags")
# raise Exception("Session handling failed")
except Exception as e:
print(f"Exception captured: {e}")
# You can also use the `@hanzo_insights.scoped()` decorator to enter a new context.
# You can also use the `@posthog.scoped()` decorator to enter a new context.
# By default, it inherits tags from the parent context
@hanzo_insights.scoped()
@posthog.scoped()
def process_order(order_id):
hanzo_insights.tag("order_id", order_id)
hanzo_insights.capture("order_step_completed")
posthog.tag("order_id", order_id)
posthog.capture("order_step_completed")
print(f"✅ Order {order_id} processed with scoped context")
# Exception will be captured and tagged automatically
# raise Exception("Order processing failed")
# Use fresh=True to start with a clean context (no inherited tags)
@hanzo_insights.scoped(fresh=True)
@posthog.scoped(fresh=True)
def process_payment(payment_id):
hanzo_insights.tag("payment_id", payment_id)
hanzo_insights.capture("payment_processed")
posthog.tag("payment_id", payment_id)
posthog.capture("payment_processed")
print(f"✅ Payment {payment_id} processed with fresh scoped context")
# Only payment_id tag will be present, no inherited tags
# raise Exception("Payment processing failed")
@@ -436,18 +436,18 @@ elif choice == "6":
# Run example 1
print(f"\n{'🔸' * 20} IDENTIFY AND CAPTURE {'🔸' * 20}")
hanzo_insights.debug = True
posthog.debug = True
print("📊 Capturing events...")
hanzo_insights.capture(
posthog.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")
posthog.alias("distinct_id", "new_distinct_id")
print("👤 Identifying user...")
hanzo_insights.set(
posthog.set(
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
)
@@ -455,27 +455,27 @@ elif choice == "6":
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"beta-feature: {posthog.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'})}"
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
)
# Run example 3
print(f"\n{'🔸' * 20} PAYLOADS {'🔸' * 20}")
print("📦 Testing payloads...")
print(f"Payload: {hanzo_insights.get_feature_flag_payload('beta-feature', 'distinct_id')}")
print(f"Payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}")
# Run example 4 (requires local evaluation)
if local_eval_available:
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
print("🔗 Testing flag dependencies...")
result1 = hanzo_insights.feature_enabled(
result1 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
result2 = hanzo_insights.feature_enabled(
result2 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user2",
person_properties={"email": "user@other.com"},
@@ -486,23 +486,23 @@ elif choice == "6":
# 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")
with posthog.new_context():
posthog.tag("demo_run", "all_examples")
posthog.capture("demo_completed")
print("✅ Demo completed with context tags")
elif choice == "7":
print("👋 Goodbye!")
hanzo_insights.shutdown()
posthog.shutdown()
exit()
else:
print("❌ Invalid choice. Please run again and select 1-7.")
hanzo_insights.shutdown()
posthog.shutdown()
exit()
print("\n" + "=" * 60)
print("✅ Example completed!")
print("=" * 60)
hanzo_insights.shutdown()
posthog.shutdown()
+11 -11
View File
@@ -1,17 +1,17 @@
"""
Redis-based distributed cache for Insights feature flag definitions.
Redis-based distributed cache for PostHog feature flag definitions.
This example demonstrates how to implement a FlagDefinitionCacheProvider
using Redis for multi-instance deployments (leader election pattern).
Usage:
import redis
from hanzo_insights import Insights
from posthog import Posthog
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
cache = RedisFlagCache(redis_client, service_key="my-service")
client = Insights(
posthog = Posthog(
"<project_api_key>",
personal_api_key="<personal_api_key>",
flag_definition_cache_provider=cache,
@@ -24,17 +24,17 @@ Requirements:
import json
import uuid
from hanzo_insights import FlagDefinitionCacheData, FlagDefinitionCacheProvider
from posthog import FlagDefinitionCacheData, FlagDefinitionCacheProvider
from redis import Redis
from typing import Optional
class RedisFlagCache(FlagDefinitionCacheProvider):
"""
A distributed cache for Insights feature flag definitions using Redis.
A distributed cache for PostHog feature flag definitions using Redis.
In a multi-instance deployment (e.g., multiple serverless functions or containers),
we want only ONE instance to poll Insights for flag updates, while all instances
we want only ONE instance to poll PostHog for flag updates, while all instances
share the cached results. This prevents N instances from making N redundant API calls.
The implementation uses leader election:
@@ -83,8 +83,8 @@ class RedisFlagCache(FlagDefinitionCacheProvider):
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
- posthog:flags:{service_key} - Cached flag definitions (JSON)
- posthog:flags:{service_key}:lock - Leader election lock
Example:
redis_client = redis.Redis(
@@ -95,8 +95,8 @@ class RedisFlagCache(FlagDefinitionCacheProvider):
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._cache_key = f"posthog:flags:{service_key}"
self._lock_key = f"posthog:flags:{service_key}:lock"
self._instance_id = str(uuid.uuid4())
self._try_lead = self._redis.register_script(self._LUA_TRY_LEAD)
self._stop_lead = self._redis.register_script(self._LUA_STOP_LEAD)
@@ -113,7 +113,7 @@ class RedisFlagCache(FlagDefinitionCacheProvider):
def should_fetch_flag_definitions(self) -> bool:
"""
Determines if this instance should fetch flag definitions from Insights.
Determines if this instance should fetch flag definitions from PostHog.
Atomically either:
- Acquires the lock if no one holds it, OR
+8 -8
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env python3
"""
Simple test script for Insights remote config endpoint.
Simple test script for PostHog remote config endpoint.
"""
import hanzo_insights
import posthog
# 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
# Initialize PostHog client
posthog.api_key = "phc_..."
posthog.personal_api_key = "phs_..." # or "phx_..."
posthog.host = "http://localhost:8000" # or "https://us.posthog.com"
posthog.debug = True
def test_remote_config():
@@ -21,7 +21,7 @@ def test_remote_config():
try:
# Get remote config payload
payload = hanzo_insights.get_remote_config_payload(flag_key)
payload = posthog.get_remote_config_payload(flag_key)
print(f"✅ Success! Remote config payload for '{flag_key}': {payload}")
except Exception as e:
-3
View File
@@ -1,3 +0,0 @@
from hanzo_insights.ai.prompts import Prompts
__all__ = ["Prompts"]
@@ -1,76 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union
if TYPE_CHECKING:
from agents.tracing import Trace
from 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
@@ -1,863 +0,0 @@
import json
import logging
import time
from datetime import datetime
from typing import Any, Callable, Dict, Optional, Union
from agents.tracing import Span, Trace
from agents.tracing.processor_interface import TracingProcessor
from agents.tracing.span_data import (
AgentSpanData,
CustomSpanData,
FunctionSpanData,
GenerationSpanData,
GuardrailSpanData,
HandoffSpanData,
MCPListToolsSpanData,
ResponseSpanData,
SpeechGroupSpanData,
SpeechSpanData,
TranscriptionSpanData,
)
from 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}")
-329
View File
@@ -1,329 +0,0 @@
"""
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"]
@@ -1 +0,0 @@
# Tests for OpenAI Agents SDK integration
@@ -1,810 +0,0 @@
import logging
from unittest.mock import MagicMock, patch
import pytest
try:
from agents.tracing.span_data import (
AgentSpanData,
CustomSpanData,
FunctionSpanData,
GenerationSpanData,
GuardrailSpanData,
HandoffSpanData,
ResponseSpanData,
SpeechSpanData,
TranscriptionSpanData,
)
from 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"}
-764
View File
@@ -1,764 +0,0 @@
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()
@@ -1,62 +0,0 @@
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
-1
View File
@@ -1 +0,0 @@
VERSION = "7.9.7"
-3
View File
@@ -1,3 +0,0 @@
# Convenience re-export so `from insights import Insights` works.
from hanzo_insights import * # noqa: F401, F403
from hanzo_insights import Insights, Client # noqa: F401
@@ -1,10 +1,10 @@
"""
Test that verifies exception capture functionality.
These tests verify that exceptions are actually captured to Insights, not just that
These tests verify that exceptions are actually captured to PostHog, not just that
500 responses are returned.
Without process_exception(), view exceptions are NOT captured to Insights (v6.7.11 and earlier).
Without process_exception(), view exceptions are NOT captured to PostHog (v6.7.11 and earlier).
With process_exception(), Django calls this method to capture exceptions before
converting them to 500 responses.
"""
@@ -30,7 +30,7 @@ def asgi_app():
@pytest.mark.asyncio
async def test_async_exception_is_captured(asgi_app):
"""
Test that async view exceptions are captured to Insights.
Test that async view exceptions are captured to PostHog.
The middleware's process_exception() method ensures exceptions are captured.
Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned.
@@ -50,8 +50,8 @@ async def test_async_exception_is_captured(asgi_app):
}
)
# Patch at the hanzo_insights module level where middleware imports from
with patch("hanzo_insights.capture_exception", side_effect=mock_capture):
# Patch at the posthog module level where middleware imports from
with patch("posthog.capture_exception", side_effect=mock_capture):
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
@@ -60,8 +60,8 @@ async def test_async_exception_is_captured(asgi_app):
# Django returns 500
assert response.status_code == 500
# CRITICAL: Verify Insights captured the exception
assert len(captured) > 0, "Exception was NOT captured to Insights!"
# CRITICAL: Verify PostHog captured the exception
assert len(captured) > 0, "Exception was NOT captured to PostHog!"
# Verify it's the right exception
exception_data = captured[0]
@@ -72,7 +72,7 @@ async def test_async_exception_is_captured(asgi_app):
@pytest.mark.asyncio
async def test_sync_exception_is_captured(asgi_app):
"""
Test that sync view exceptions are captured to Insights.
Test that sync view exceptions are captured to PostHog.
The middleware's process_exception() method ensures exceptions are captured.
Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned.
@@ -92,8 +92,8 @@ async def test_sync_exception_is_captured(asgi_app):
}
)
# Patch at the hanzo_insights module level where middleware imports from
with patch("hanzo_insights.capture_exception", side_effect=mock_capture):
# Patch at the posthog module level where middleware imports from
with patch("posthog.capture_exception", side_effect=mock_capture):
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
@@ -102,8 +102,8 @@ async def test_sync_exception_is_captured(asgi_app):
# Django returns 500
assert response.status_code == 500
# CRITICAL: Verify Insights captured the exception
assert len(captured) > 0, "Exception was NOT captured to Insights!"
# CRITICAL: Verify PostHog captured the exception
assert len(captured) > 0, "Exception was NOT captured to PostHog!"
# Verify it's the right exception
exception_data = captured[0]
+5 -5
View File
@@ -1,5 +1,5 @@
"""
Tests for Insights Django middleware in async context.
Tests for PostHog Django middleware in async context.
These tests verify that the middleware correctly handles:
1. Async user access (request.auser() in Django 5)
@@ -103,7 +103,7 @@ async def test_async_authenticated_user_access(asgi_app):
# Make request with session cookie - this should trigger the bug in v6.7.11
# Disable exception capture to see the SynchronousOnlyOperation clearly
with override_settings(INSIGHTS_MW_CAPTURE_EXCEPTIONS=False):
with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False):
async with AsyncClient(
transport=ASGITransport(app=asgi_app),
base_url="http://testserver",
@@ -139,10 +139,10 @@ async def test_async_exception_capture(asgi_app):
"""
Test that middleware handles exceptions from async views.
The middleware's process_exception() method captures view exceptions to Insights
The middleware's process_exception() method captures view exceptions to PostHog
before Django converts them to 500 responses. This test verifies the exception
causes a 500 response. See test_exception_capture.py for tests that verify
actual exception capture to Insights.
actual exception capture to PostHog.
"""
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
@@ -158,7 +158,7 @@ async def test_sync_exception_capture(asgi_app):
"""
Test that middleware handles exceptions from sync views.
The middleware's process_exception() method captures view exceptions to Insights.
The middleware's process_exception() method captures view exceptions to PostHog.
This test verifies the exception causes a 500 response.
"""
async with AsyncClient(
@@ -47,7 +47,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"hanzo_insights.integrations.django.InsightsContextMiddleware", # Test Insights middleware
"posthog.integrations.django.PosthogContextMiddleware", # Test PostHog middleware
]
ROOT_URLCONF = "testdjango.urls"
@@ -123,7 +123,7 @@ STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Insights settings for testing
INSIGHTS_API_KEY = "test-key"
# PostHog settings for testing
POSTHOG_API_KEY = "test-key"
POSTHOG_HOST = "https://app.posthog.com"
INSIGHTS_MW_CAPTURE_EXCEPTIONS = True
POSTHOG_MW_CAPTURE_EXCEPTIONS = True
@@ -1,5 +1,5 @@
"""
Test views for validating Insights middleware with Django 5 ASGI.
Test views for validating PostHog middleware with Django 5 ASGI.
"""
from django.http import JsonResponse
+2 -2
View File
@@ -17,10 +17,10 @@ ignore_missing_imports = True
[mypy-sentry_sdk.*]
ignore_missing_imports = True
[mypy-hanzo_insights.test.*]
[mypy-posthog.test.*]
ignore_errors = True
[mypy-hanzo_insights.*.test.*]
[mypy-posthog.*.test.*]
ignore_errors = True
[mypy-openai.*]
@@ -3,66 +3,63 @@ 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 (
from posthog.args import ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
from posthog.client import Client
from posthog.contexts import (
identify_context as inner_identify_context,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
new_context as inner_new_context,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
scoped as inner_scoped,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
set_capture_exception_code_variables_context as inner_set_capture_exception_code_variables_context,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
set_code_variables_ignore_patterns_context as inner_set_code_variables_ignore_patterns_context,
)
from hanzo_insights.contexts import (
from posthog.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 (
from posthog.contexts import (
set_context_session as inner_set_context_session,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
tag as inner_tag,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
get_tags as inner_get_tags,
)
from hanzo_insights.exception_utils import (
from posthog.exception_utils import (
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
)
from hanzo_insights.feature_flags import (
from posthog.feature_flags import (
InconclusiveMatchError as InconclusiveMatchError,
)
from hanzo_insights.feature_flags import (
from posthog.feature_flags import (
RequiresServerEvaluation as RequiresServerEvaluation,
)
from hanzo_insights.flag_definition_cache import (
from posthog.flag_definition_cache import (
FlagDefinitionCacheData as FlagDefinitionCacheData,
FlagDefinitionCacheProvider as FlagDefinitionCacheProvider,
)
from hanzo_insights.request import (
from posthog.request import (
disable_connection_reuse as disable_connection_reuse,
enable_keep_alive as enable_keep_alive,
set_socket_options as set_socket_options,
SocketOptions as SocketOptions,
)
from hanzo_insights.types import (
from posthog.types import (
FeatureFlag,
FlagsAndPayloads,
)
from hanzo_insights.types import (
from posthog.types import (
FeatureFlagResult as FeatureFlagResult,
)
from hanzo_insights.version import VERSION
from posthog.version import VERSION
__version__ = VERSION
@@ -76,11 +73,11 @@ def new_context(fresh=False, capture_exceptions=True, client=None):
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)
client: Optional Posthog client instance to use for this context (default: None)
Examples:
```python
from hanzo_insights import new_context, tag, capture
from posthog import new_context, tag, capture
with new_context():
tag("request_id", "123")
capture("event_name", properties={"property": "value"})
@@ -100,11 +97,11 @@ def scoped(fresh=False, capture_exceptions=True):
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)
capture_exceptions: Whether to capture and track exceptions with posthog error tracking (default: True)
Examples:
```python
from hanzo_insights import scoped, tag, capture
from posthog import scoped, tag, capture
@scoped()
def process_payment(payment_id):
tag("payment_id", payment_id)
@@ -126,7 +123,7 @@ def set_context_session(session_id: str):
Examples:
```python
from hanzo_insights import set_context_session
from posthog import set_context_session
set_context_session("session_123")
```
@@ -136,26 +133,6 @@ def set_context_session(session_id: str):
return inner_set_context_session(session_id)
def set_context_device_id(device_id: str):
"""
Set the device ID for the current context, associating all feature flag requests
in this or child contexts with the given device ID.
Args:
device_id: The device ID to associate with the current context and its children
Examples:
```python
from 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.
@@ -165,7 +142,7 @@ def identify_context(distinct_id: str):
Examples:
```python
from hanzo_insights import identify_context
from posthog import identify_context
identify_context("user_123")
```
@@ -206,7 +183,7 @@ def tag(name: str, value: Any):
Examples:
```python
from hanzo_insights import tag
from posthog import tag
tag("user_id", "123")
```
@@ -263,7 +240,7 @@ 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
# it impossible to write `posthog.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]:
@@ -280,12 +257,12 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]:
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.
Capture allows you to capture anything a user does within your system, which you can later use in PostHog 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
from posthog 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
@@ -312,7 +289,7 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]:
```
```python
# Set event properties
from hanzo_insights import capture
from posthog import capture
capture(
"user_signed_up",
distinct_id="distinct_id_of_the_user",
@@ -339,7 +316,7 @@ def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
Examples:
```python
# Set person properties
from hanzo_insights import capture
from posthog import capture
capture(
'distinct_id',
event='event_name',
@@ -366,7 +343,7 @@ def set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
Examples:
```python
# Set property once
from hanzo_insights import capture
from posthog import capture
capture(
'distinct_id',
event='event_name',
@@ -406,7 +383,7 @@ def group_identify(
Examples:
```python
# Group identify
from hanzo_insights import group_identify
from posthog import group_identify
group_identify('company', 'company_id_in_your_db', {
'name': 'Awesome Inc.',
'employees': 11
@@ -451,7 +428,7 @@ def alias(
Examples:
```python
# Alias user
from hanzo_insights import alias
from posthog import alias
alias(previous_id='distinct_id', distinct_id='alias_id')
```
Category:
@@ -479,12 +456,12 @@ def capture_exception(
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`.
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. 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 posthog.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 `posthog.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
from posthog import capture_exception
try:
risky_operation()
except Exception as e:
@@ -506,7 +483,6 @@ def feature_enabled(
only_evaluate_locally=False, # type: bool
send_feature_flag_events=True, # type: bool
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
):
# type: (...) -> bool
"""
@@ -523,12 +499,12 @@ def feature_enabled(
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.
You can call `posthog.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
from posthog 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')
@@ -546,7 +522,6 @@ def feature_enabled(
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -559,7 +534,6 @@ def get_feature_flag(
only_evaluate_locally=False, # type: bool
send_feature_flag_events=True, # type: bool
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
) -> Optional[FeatureFlag]:
"""
Get feature flag variant for users. Used with experiments.
@@ -575,12 +549,12 @@ def get_feature_flag(
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}}.
`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": "PostHog", "employees": 11}}.
Examples:
```python
# Multivariate feature flag
from hanzo_insights import get_feature_flag, get_feature_flag_payload
from posthog 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')
@@ -598,7 +572,6 @@ def get_feature_flag(
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -609,7 +582,6 @@ def get_all_flags(
group_properties=None, # type: Optional[dict]
only_evaluate_locally=False, # type: bool
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
) -> Optional[dict[str, FeatureFlag]]:
"""
Get all flags for a given user.
@@ -628,7 +600,7 @@ def get_all_flags(
Examples:
```python
# All flags for user
from hanzo_insights import get_all_flags
from posthog import get_all_flags
get_all_flags('distinct_id_of_your_user')
```
Category:
@@ -642,7 +614,6 @@ def get_all_flags(
group_properties=group_properties or {},
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -655,7 +626,6 @@ def get_feature_flag_result(
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
):
# type: (...) -> Optional[FeatureFlagResult]
"""
@@ -670,7 +640,7 @@ def get_feature_flag_result(
Example:
```python
result = hanzo_insights.get_feature_flag_result('beta-feature', 'distinct_id')
result = posthog.get_feature_flag_result('beta-feature', 'distinct_id')
if result and result.enabled:
# Use the variant and payload
print(f"Variant: {result.variant}")
@@ -687,7 +657,6 @@ def get_feature_flag_result(
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -701,7 +670,6 @@ def get_feature_flag_payload(
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
) -> Optional[str]:
return _proxy(
"get_feature_flag_payload",
@@ -714,7 +682,6 @@ def get_feature_flag_payload(
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -745,7 +712,6 @@ def get_all_flags_and_payloads(
group_properties=None, # type: Optional[dict]
only_evaluate_locally=False,
disable_geoip=None, # type: Optional[bool]
device_id=None, # type: Optional[str]
) -> FlagsAndPayloads:
return _proxy(
"get_all_flags_and_payloads",
@@ -755,7 +721,6 @@ def get_all_flags_and_payloads(
group_properties=group_properties or {},
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
device_id=device_id,
)
@@ -768,7 +733,7 @@ def feature_flag_definitions():
Examples:
```python
from hanzo_insights import feature_flag_definitions
from posthog import feature_flag_definitions
definitions = feature_flag_definitions()
```
@@ -780,11 +745,11 @@ def feature_flag_definitions():
def load_feature_flags():
"""
Load feature flag definitions from the server.
Load feature flag definitions from PostHog.
Examples:
```python
from hanzo_insights import load_feature_flags
from posthog import load_feature_flags
load_feature_flags()
```
@@ -800,7 +765,7 @@ def flush():
Examples:
```python
from hanzo_insights import flush
from posthog import flush
flush()
```
@@ -816,7 +781,7 @@ def join():
Examples:
```python
from hanzo_insights import join
from posthog import join
join()
```
@@ -832,7 +797,7 @@ def shutdown():
Examples:
```python
from hanzo_insights import shutdown
from posthog import shutdown
shutdown()
```
@@ -888,7 +853,5 @@ def _proxy(method, *args, **kwargs):
return fn(*args, **kwargs)
class Insights(Client):
"""Hanzo Insights client for product analytics."""
class Posthog(Client):
pass
@@ -10,38 +10,38 @@ 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 (
from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
from posthog.ai.utils import (
call_llm_and_track_usage,
merge_usage_stats,
)
from hanzo_insights.ai.anthropic.anthropic_converter import (
from posthog.ai.anthropic.anthropic_converter import (
extract_anthropic_usage_from_event,
handle_anthropic_content_block_start,
handle_anthropic_text_delta,
handle_anthropic_tool_delta,
finalize_anthropic_tool_input,
)
from hanzo_insights.ai.sanitization import sanitize_anthropic
from hanzo_insights.client import Client as InsightsClient
from hanzo_insights import setup
from posthog.ai.sanitization import sanitize_anthropic
from posthog.client import Client as PostHogClient
from posthog import setup
class Anthropic(anthropic.Anthropic):
"""
A wrapper around the Anthropic SDK that automatically sends LLM usage events to Insights.
A wrapper around the Anthropic SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
insights_client: Insights client for tracking usage
posthog_client: PostHog client for tracking usage
**kwargs: Additional arguments passed to the Anthropic client
"""
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = WrappedMessages(self)
@@ -50,46 +50,46 @@ class WrappedMessages(Messages):
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Create a message using Anthropic's API while tracking usage in Insights.
Create a message using Anthropic's API while tracking usage in PostHog.
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
posthog_distinct_id: Optional ID to associate with the usage event
posthog_trace_id: Optional trace UUID for linking events
posthog_properties: Optional dictionary of extra properties to include in the event
posthog_privacy_mode: Whether to redact sensitive information in tracking
posthog_groups: Optional group analytics properties
**kwargs: Arguments passed to Anthropic's messages.create
"""
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_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,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return call_llm_and_track_usage(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"anthropic",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
**kwargs,
@@ -97,32 +97,32 @@ class WrappedMessages(Messages):
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
return self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -188,11 +188,11 @@ class WrappedMessages(Messages):
latency = end_time - start_time
self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -204,23 +204,23 @@ class WrappedMessages(Messages):
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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
content_blocks: List[StreamingContentBlock],
accumulated_content: str,
):
from hanzo_insights.ai.types import StreamingEventData
from hanzo_insights.ai.anthropic.anthropic_converter import (
from posthog.ai.types import StreamingEventData
from posthog.ai.anthropic.anthropic_converter import (
format_anthropic_streaming_input,
format_anthropic_streaming_output_complete,
)
from hanzo_insights.ai.utils import capture_streaming_event
from posthog.ai.utils import capture_streaming_event
# Prepare standardized event data
formatted_input = format_anthropic_streaming_input(kwargs)
@@ -237,11 +237,11 @@ class WrappedMessages(Messages):
),
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,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
# Use the common capture function
@@ -10,38 +10,38 @@ 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 (
from posthog import setup
from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
from posthog.ai.utils import (
call_llm_and_track_usage_async,
merge_usage_stats,
)
from hanzo_insights.ai.anthropic.anthropic_converter import (
from posthog.ai.anthropic.anthropic_converter import (
extract_anthropic_usage_from_event,
handle_anthropic_content_block_start,
handle_anthropic_text_delta,
handle_anthropic_tool_delta,
finalize_anthropic_tool_input,
)
from hanzo_insights.ai.sanitization import sanitize_anthropic
from hanzo_insights.client import Client as InsightsClient
from posthog.ai.sanitization import sanitize_anthropic
from posthog.client import Client as PostHogClient
class AsyncAnthropic(anthropic.AsyncAnthropic):
"""
An async wrapper around the Anthropic SDK that automatically sends LLM usage events to Insights.
An async wrapper around the Anthropic SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
insights_client: Insights client for tracking usage
posthog_client: PostHog client for tracking usage
**kwargs: Additional arguments passed to the Anthropic client
"""
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = AsyncWrappedMessages(self)
@@ -50,46 +50,46 @@ class AsyncWrappedMessages(AsyncMessages):
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Create a message using Anthropic's API while tracking usage in Insights.
Create a message using Anthropic's API while tracking usage in PostHog.
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
posthog_distinct_id: Optional ID to associate with the usage event
posthog_trace_id: Optional trace UUID for linking events
posthog_properties: Optional dictionary of extra properties to include in the event
posthog_privacy_mode: Whether to redact sensitive information in tracking
posthog_groups: Optional group analytics properties
**kwargs: Arguments passed to Anthropic's messages.create
"""
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_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,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return await call_llm_and_track_usage_async(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"anthropic",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
**kwargs,
@@ -97,32 +97,32 @@ class AsyncWrappedMessages(AsyncMessages):
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
return await self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -188,11 +188,11 @@ class AsyncWrappedMessages(AsyncMessages):
latency = end_time - start_time
await self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -204,23 +204,23 @@ class AsyncWrappedMessages(AsyncMessages):
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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
content_blocks: List[StreamingContentBlock],
accumulated_content: str,
):
from hanzo_insights.ai.types import StreamingEventData
from hanzo_insights.ai.anthropic.anthropic_converter import (
from posthog.ai.types import StreamingEventData
from posthog.ai.anthropic.anthropic_converter import (
format_anthropic_streaming_input,
format_anthropic_streaming_output_complete,
)
from hanzo_insights.ai.utils import capture_streaming_event
from posthog.ai.utils import capture_streaming_event
# Prepare standardized event data
formatted_input = format_anthropic_streaming_input(kwargs)
@@ -237,11 +237,11 @@ class AsyncWrappedMessages(AsyncMessages):
),
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,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
# Use the common capture function
@@ -2,13 +2,13 @@
Anthropic-specific conversion utilities.
This module handles the conversion of Anthropic API responses and inputs
into standardized formats for Insights tracking.
into standardized formats for PostHog tracking.
"""
import json
from typing import Any, Dict, List, Optional, Tuple
from hanzo_insights.ai.types import (
from posthog.ai.types import (
FormattedContentItem,
FormattedFunctionCall,
FormattedMessage,
@@ -17,7 +17,6 @@ from hanzo_insights.ai.types import (
TokenUsage,
ToolInProgress,
)
from hanzo_insights.ai.utils import serialize_raw_usage
def format_anthropic_response(response: Any) -> List[FormattedMessage]:
@@ -222,12 +221,6 @@ def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
if web_search_count > 0:
result["web_search_count"] = web_search_count
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(response.usage)
if serialized:
result["raw_usage"] = serialized
return result
@@ -254,11 +247,6 @@ def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
usage["cache_read_input_tokens"] = getattr(
event.message.usage, "cache_read_input_tokens", 0
)
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(event.message.usage)
if serialized:
usage["raw_usage"] = serialized
# Handle usage stats from message_delta event
if hasattr(event, "usage") and event.usage:
@@ -274,12 +262,6 @@ def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
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
@@ -425,9 +407,9 @@ def format_anthropic_streaming_input(kwargs: Dict[str, Any]) -> Any:
kwargs: Keyword arguments passed to Anthropic API
Returns:
Formatted input ready for Insights tracking
Formatted input ready for PostHog tracking
"""
from hanzo_insights.ai.utils import merge_system_prompt
from posthog.ai.utils import merge_system_prompt
return merge_system_prompt(kwargs, "anthropic")
@@ -445,7 +427,7 @@ def format_anthropic_streaming_output_complete(
accumulated_content: Raw accumulated text content as fallback
Returns:
Formatted messages ready for Insights tracking
Formatted messages ready for PostHog tracking
"""
formatted_content = format_anthropic_streaming_content(content_blocks)
@@ -7,59 +7,59 @@ except ImportError:
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
from posthog.ai.anthropic.anthropic import WrappedMessages
from posthog.ai.anthropic.anthropic_async import AsyncWrappedMessages
from posthog.client import Client as PostHogClient
from posthog import setup
class AnthropicBedrock(anthropic.AnthropicBedrock):
"""
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to Insights.
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = WrappedMessages(self)
class AsyncAnthropicBedrock(anthropic.AsyncAnthropicBedrock):
"""
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to Insights.
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = AsyncWrappedMessages(self)
class AnthropicVertex(anthropic.AnthropicVertex):
"""
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to Insights.
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = WrappedMessages(self)
class AsyncAnthropicVertex(anthropic.AsyncAnthropicVertex):
"""
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to Insights.
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = AsyncWrappedMessages(self)
@@ -3,8 +3,8 @@ 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
from posthog.ai.types import TokenUsage, StreamingEventData
from posthog.ai.utils import merge_system_prompt
try:
from google import genai
@@ -13,40 +13,40 @@ except ImportError:
"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 (
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage,
capture_streaming_event,
merge_usage_stats,
)
from hanzo_insights.ai.gemini.gemini_converter import (
from posthog.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
from posthog.ai.sanitization import sanitize_gemini
from posthog.client import Client as PostHogClient
class Client:
"""
A drop-in replacement for genai.Client that automatically sends LLM usage events to Insights.
A drop-in replacement for genai.Client that automatically sends LLM usage events to PostHog.
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
posthog_client=posthog_client,
posthog_distinct_id="default_user", # Optional defaults
posthog_properties={"team": "ai"} # Optional defaults
)
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello world"],
insights_distinct_id="specific_user" # Override default
posthog_distinct_id="specific_user" # Override default
)
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(
self,
@@ -57,11 +57,11 @@ class Client:
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,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs,
):
"""
@@ -73,18 +73,18 @@ class Client:
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)
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls (can be overridden per call)
posthog_properties: Default properties for all calls (can be overridden per call)
posthog_privacy_mode: Default privacy mode for all calls (can be overridden per call)
posthog_groups: Default groups for all calls (can be overridden per call)
**kwargs: Additional arguments (for future compatibility)
"""
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("insights_client is required for Insights tracking")
raise ValueError("posthog_client is required for PostHog tracking")
self.models = Models(
api_key=api_key,
@@ -94,21 +94,21 @@ class Client:
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,
posthog_client=self._ph_client,
posthog_distinct_id=posthog_distinct_id,
posthog_properties=posthog_properties,
posthog_privacy_mode=posthog_privacy_mode,
posthog_groups=posthog_groups,
**kwargs,
)
class Models:
"""
Models interface that mimics genai.Client().models with Insights tracking.
Models interface that mimics genai.Client().models with PostHog tracking.
"""
_ph_client: InsightsClient # Not None after __init__ validation
_ph_client: PostHogClient # Not None after __init__ validation
def __init__(
self,
@@ -119,11 +119,11 @@ class Models:
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,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs,
):
"""
@@ -135,24 +135,24 @@ class Models:
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
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls
posthog_properties: Default properties for all calls
posthog_privacy_mode: Default privacy mode for all calls
posthog_groups: Default groups for all calls
**kwargs: Additional arguments (for future compatibility)
"""
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("insights_client is required for Insights tracking")
raise ValueError("posthog_client is required for PostHog 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
# Store default PostHog settings
self._default_distinct_id = posthog_distinct_id
self._default_properties = posthog_properties or {}
self._default_privacy_mode = posthog_privacy_mode
self._default_groups = posthog_groups
# Build genai.Client arguments
client_args: Dict[str, Any] = {}
@@ -196,7 +196,7 @@ class Models:
self._client = genai.Client(**client_args)
self._base_url = "https://generativelanguage.googleapis.com"
def _merge_insights_params(
def _merge_posthog_params(
self,
call_distinct_id: Optional[str],
call_trace_id: Optional[str],
@@ -204,7 +204,7 @@ class Models:
call_privacy_mode: Optional[bool],
call_groups: Optional[Dict[str, Any]],
):
"""Merge call-level Insights parameters with client defaults."""
"""Merge call-level PostHog parameters with client defaults."""
# Use call-level values if provided, otherwise fall back to defaults
distinct_id = (
@@ -234,38 +234,38 @@ class Models:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: Optional[bool] = None,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Generate content using Gemini's API while tracking usage in Insights.
Generate content using Gemini's API while tracking usage in PostHog.
This method signature exactly matches genai.Client().models.generate_content()
with additional Insights tracking parameters.
with additional PostHog 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)
posthog_distinct_id: ID to associate with the usage event (overrides client default)
posthog_trace_id: Trace UUID for linking events (auto-generated if not provided)
posthog_properties: Extra properties to include in the event (merged with client defaults)
posthog_privacy_mode: Whether to redact sensitive information (overrides client default)
posthog_groups: Group analytics properties (overrides client default)
**kwargs: Arguments passed to Gemini's generate_content
"""
# Merge Insights parameters
# Merge PostHog 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,
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
@@ -380,7 +380,7 @@ class Models:
capture_streaming_event(self._ph_client, event_data)
def _format_input(self, contents, **kwargs):
"""Format input contents for Insights tracking"""
"""Format input contents for PostHog tracking"""
# Create kwargs dict with contents for merge_system_prompt
input_kwargs = {"contents": contents, **kwargs}
@@ -390,21 +390,21 @@ class Models:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: Optional[bool] = None,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
# Merge Insights parameters
# Merge PostHog 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,
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
@@ -3,8 +3,8 @@ 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
from posthog.ai.types import TokenUsage, StreamingEventData
from posthog.ai.utils import merge_system_prompt
try:
from google import genai
@@ -13,40 +13,40 @@ except ImportError:
"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 (
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage_async,
capture_streaming_event,
merge_usage_stats,
)
from hanzo_insights.ai.gemini.gemini_converter import (
from posthog.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
from posthog.ai.sanitization import sanitize_gemini
from posthog.client import Client as PostHogClient
class AsyncClient:
"""
An async drop-in replacement for genai.Client that automatically sends LLM usage events to Insights.
An async drop-in replacement for genai.Client that automatically sends LLM usage events to PostHog.
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
posthog_client=posthog_client,
posthog_distinct_id="default_user", # Optional defaults
posthog_properties={"team": "ai"} # Optional defaults
)
response = await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello world"],
insights_distinct_id="specific_user" # Override default
posthog_distinct_id="specific_user" # Override default
)
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(
self,
@@ -57,11 +57,11 @@ class AsyncClient:
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,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs,
):
"""
@@ -73,18 +73,18 @@ class AsyncClient:
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)
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls (can be overridden per call)
posthog_properties: Default properties for all calls (can be overridden per call)
posthog_privacy_mode: Default privacy mode for all calls (can be overridden per call)
posthog_groups: Default groups for all calls (can be overridden per call)
**kwargs: Additional arguments (for future compatibility)
"""
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("insights_client is required for Insights tracking")
raise ValueError("posthog_client is required for PostHog tracking")
self.models = AsyncModels(
api_key=api_key,
@@ -94,21 +94,21 @@ class AsyncClient:
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,
posthog_client=self._ph_client,
posthog_distinct_id=posthog_distinct_id,
posthog_properties=posthog_properties,
posthog_privacy_mode=posthog_privacy_mode,
posthog_groups=posthog_groups,
**kwargs,
)
class AsyncModels:
"""
Async Models interface that mimics genai.Client().aio.models with Insights tracking.
Async Models interface that mimics genai.Client().aio.models with PostHog tracking.
"""
_ph_client: InsightsClient # Not None after __init__ validation
_ph_client: PostHogClient # Not None after __init__ validation
def __init__(
self,
@@ -119,11 +119,11 @@ class AsyncModels:
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,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs,
):
"""
@@ -135,24 +135,24 @@ class AsyncModels:
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
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls
posthog_properties: Default properties for all calls
posthog_privacy_mode: Default privacy mode for all calls
posthog_groups: Default groups for all calls
**kwargs: Additional arguments (for future compatibility)
"""
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("insights_client is required for Insights tracking")
raise ValueError("posthog_client is required for PostHog 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
# Store default PostHog settings
self._default_distinct_id = posthog_distinct_id
self._default_properties = posthog_properties or {}
self._default_privacy_mode = posthog_privacy_mode
self._default_groups = posthog_groups
# Build genai.Client arguments
client_args: Dict[str, Any] = {}
@@ -196,7 +196,7 @@ class AsyncModels:
self._client = genai.Client(**client_args)
self._base_url = "https://generativelanguage.googleapis.com"
def _merge_insights_params(
def _merge_posthog_params(
self,
call_distinct_id: Optional[str],
call_trace_id: Optional[str],
@@ -204,7 +204,7 @@ class AsyncModels:
call_privacy_mode: Optional[bool],
call_groups: Optional[Dict[str, Any]],
):
"""Merge call-level Insights parameters with client defaults."""
"""Merge call-level PostHog parameters with client defaults."""
# Use call-level values if provided, otherwise fall back to defaults
distinct_id = (
@@ -234,38 +234,38 @@ class AsyncModels:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: Optional[bool] = None,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Generate content using Gemini's API while tracking usage in Insights.
Generate content using Gemini's API while tracking usage in PostHog.
This method signature exactly matches genai.Client().aio.models.generate_content()
with additional Insights tracking parameters.
with additional PostHog 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)
posthog_distinct_id: ID to associate with the usage event (overrides client default)
posthog_trace_id: Trace UUID for linking events (auto-generated if not provided)
posthog_properties: Extra properties to include in the event (merged with client defaults)
posthog_privacy_mode: Whether to redact sensitive information (overrides client default)
posthog_groups: Group analytics properties (overrides client default)
**kwargs: Arguments passed to Gemini's generate_content
"""
# Merge Insights parameters
# Merge PostHog 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,
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
@@ -383,7 +383,7 @@ class AsyncModels:
capture_streaming_event(self._ph_client, event_data)
def _format_input(self, contents, **kwargs):
"""Format input contents for Insights tracking"""
"""Format input contents for PostHog tracking"""
# Create kwargs dict with contents for merge_system_prompt
input_kwargs = {"contents": contents, **kwargs}
@@ -393,21 +393,21 @@ class AsyncModels:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: Optional[bool] = None,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
# Merge Insights parameters
# Merge PostHog 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,
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
@@ -2,17 +2,16 @@
Gemini-specific conversion utilities.
This module handles the conversion of Gemini API responses and inputs
into standardized formats for Insights tracking.
into standardized formats for PostHog tracking.
"""
from typing import Any, Dict, List, Optional, TypedDict, Union
from hanzo_insights.ai.types import (
from posthog.ai.types import (
FormattedContentItem,
FormattedMessage,
TokenUsage,
)
from hanzo_insights.ai.utils import serialize_raw_usage
class GeminiPart(TypedDict, total=False):
@@ -349,7 +348,7 @@ def format_gemini_input_with_system(
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
from posthog.ai.types import FormattedMessage
system_message: FormattedMessage = {
"role": "system",
@@ -362,7 +361,7 @@ def format_gemini_input_with_system(
def format_gemini_input(contents: Any) -> List[FormattedMessage]:
"""
Format Gemini input contents into standardized message format for Insights tracking.
Format Gemini input contents into standardized message format for PostHog tracking.
This function handles various input formats:
- String inputs
@@ -488,12 +487,6 @@ def _extract_usage_from_metadata(metadata: Any) -> TokenUsage:
if reasoning_tokens and reasoning_tokens > 0:
usage["reasoning_tokens"] = reasoning_tokens
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(metadata)
if serialized:
usage["raw_usage"] = serialized
return usage
@@ -41,12 +41,12 @@ from langchain_core.messages import (
from langchain_core.outputs import ChatGeneration, LLMResult
from pydantic import BaseModel
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
from posthog import setup
from posthog.ai.sanitization import sanitize_langchain
from posthog.ai.utils import get_model_params, with_privacy_mode
from posthog.client import Client
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
@dataclass
@@ -79,8 +79,8 @@ class GenerationMetadata(SpanMetadata):
"""Base URL of the provider's API used in the run."""
tools: Optional[List[Dict[str, Any]]] = None
"""Tools provided to the model."""
insights_properties: Optional[Dict[str, Any]] = None
"""Insights properties of the run."""
posthog_properties: Optional[Dict[str, Any]] = None
"""PostHog properties of the run."""
RunMetadata = Union[SpanMetadata, GenerationMetadata]
@@ -89,11 +89,11 @@ RunMetadataStorage = Dict[UUID, RunMetadata]
class CallbackHandler(BaseCallbackHandler):
"""
The Insights LLM observability callback handler for LangChain.
The PostHog LLM observability callback handler for LangChain.
"""
_ph_client: Client
"""Insights client instance."""
"""PostHog client instance."""
_distinct_id: Optional[Union[str, int, UUID]]
"""Distinct ID of the user to associate the trace with."""
@@ -131,12 +131,12 @@ class CallbackHandler(BaseCallbackHandler):
):
"""
Args:
client: Insights client instance.
client: PostHog client instance.
distinct_id: Optional distinct ID of the user to associate the trace with.
trace_id: Optional trace ID to use for the event.
properties: Optional additional metadata to use for the trace.
privacy_mode: Whether to redact the input and output of the trace.
groups: Optional additional Insights groups to use for the trace.
groups: Optional additional PostHog groups to use for the trace.
"""
self._ph_client = client or setup()
self._distinct_id = distinct_id
@@ -423,7 +423,7 @@ class CallbackHandler(BaseCallbackHandler):
if provider := metadata.get("ls_provider"):
generation.provider = provider
generation.insights_properties = metadata.get("insights_properties")
generation.posthog_properties = metadata.get("posthog_properties")
try:
base_url = serialized["kwargs"]["openai_api_base"]
if base_url is not None:
@@ -578,8 +578,8 @@ class CallbackHandler(BaseCallbackHandler):
"$ai_framework": "langchain",
}
if isinstance(run.insights_properties, dict):
event_properties.update(run.insights_properties)
if isinstance(run.posthog_properties, dict):
event_properties.update(run.posthog_properties)
if run.tools:
event_properties["$ai_tools"] = run.tools
@@ -2,7 +2,7 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from hanzo_insights.ai.types import TokenUsage
from posthog.ai.types import TokenUsage
try:
import openai
@@ -11,40 +11,40 @@ except ImportError:
"Please install the OpenAI SDK to use this feature: 'pip install openai'"
)
from hanzo_insights.ai.utils import (
from posthog.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 (
from posthog.ai.openai.openai_converter import (
extract_openai_usage_from_chunk,
extract_openai_content_from_chunk,
extract_openai_tool_calls_from_chunk,
accumulate_openai_tool_calls,
)
from hanzo_insights.ai.sanitization import sanitize_openai, sanitize_openai_response
from hanzo_insights.client import Client as InsightsClient
from hanzo_insights import setup
from posthog.ai.sanitization import sanitize_openai, sanitize_openai_response
from posthog.client import Client as PostHogClient
from posthog import setup
class OpenAI(openai.OpenAI):
"""
A wrapper around the OpenAI SDK that automatically sends LLM usage events to Insights.
A wrapper around the OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
api_key: OpenAI API key.
insights_client: If provided, events will be captured via this client instead of the global client.
posthog_client: If provided, events will be captured via this client instead of the global `posthog`.
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
"""
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -67,7 +67,7 @@ class OpenAI(openai.OpenAI):
class WrappedResponses:
"""Wrapper for OpenAI responses that tracks usage in Insights."""
"""Wrapper for OpenAI responses that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_responses):
self._client = client
@@ -79,34 +79,34 @@ class WrappedResponses:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_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,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return call_llm_and_track_usage(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.create,
**kwargs,
@@ -114,11 +114,11 @@ class WrappedResponses:
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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -160,11 +160,11 @@ class WrappedResponses:
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,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -177,11 +177,11 @@ class WrappedResponses:
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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
@@ -189,12 +189,12 @@ class WrappedResponses:
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 (
from posthog.ai.types import StreamingEventData
from posthog.ai.openai.openai_converter import (
format_openai_streaming_input,
format_openai_streaming_output,
)
from hanzo_insights.ai.utils import capture_streaming_event
from posthog.ai.utils import capture_streaming_event
# Prepare standardized event data
formatted_input = format_openai_streaming_input(kwargs, "responses")
@@ -212,11 +212,11 @@ class WrappedResponses:
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,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
# Use the common capture function
@@ -224,35 +224,35 @@ class WrappedResponses:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in Insights.
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
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.
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
Returns:
The response from OpenAI's responses.parse call.
"""
return call_llm_and_track_usage(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
@@ -260,7 +260,7 @@ class WrappedResponses:
class WrappedChat:
"""Wrapper for OpenAI chat that tracks usage in Insights."""
"""Wrapper for OpenAI chat that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_chat):
self._client = client
@@ -276,7 +276,7 @@ class WrappedChat:
class WrappedCompletions:
"""Wrapper for OpenAI chat completions that tracks usage in Insights."""
"""Wrapper for OpenAI chat completions that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_completions):
self._client = client
@@ -288,34 +288,34 @@ class WrappedCompletions:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_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,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return call_llm_and_track_usage(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.create,
**kwargs,
@@ -323,11 +323,11 @@ class WrappedCompletions:
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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -385,11 +385,11 @@ class WrappedCompletions:
)
self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -403,11 +403,11 @@ class WrappedCompletions:
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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
@@ -416,12 +416,12 @@ class WrappedCompletions:
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 (
from posthog.ai.types import StreamingEventData
from posthog.ai.openai.openai_converter import (
format_openai_streaming_input,
format_openai_streaming_output,
)
from hanzo_insights.ai.utils import capture_streaming_event
from posthog.ai.utils import capture_streaming_event
# Prepare standardized event data
formatted_input = format_openai_streaming_input(kwargs, "chat")
@@ -439,11 +439,11 @@ class WrappedCompletions:
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,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
# Use the common capture function
@@ -451,7 +451,7 @@ class WrappedCompletions:
class WrappedEmbeddings:
"""Wrapper for OpenAI embeddings that tracks usage in Insights."""
"""Wrapper for OpenAI embeddings that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_embeddings):
self._client = client
@@ -463,30 +463,30 @@ class WrappedEmbeddings:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in Insights.
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in PostHog.
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.
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Embeddings API.
Returns:
The response from OpenAI's embeddings.create call.
"""
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
start_time = time.time()
response = self._original.create(**kwargs)
@@ -508,34 +508,34 @@ class WrappedEmbeddings:
"$ai_model": kwargs.get("model"),
"$ai_input": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
sanitize_openai_response(kwargs.get("input")),
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": insights_trace_id,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(insights_properties or {}),
**(posthog_properties or {}),
}
if insights_distinct_id is None:
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
# Send capture event for embeddings
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=insights_distinct_id or insights_trace_id,
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_embedding",
properties=event_properties,
groups=insights_groups,
groups=posthog_groups,
)
return response
class WrappedBeta:
"""Wrapper for OpenAI beta features that tracks usage in Insights."""
"""Wrapper for OpenAI beta features that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_beta):
self._client = client
@@ -551,7 +551,7 @@ class WrappedBeta:
class WrappedBetaChat:
"""Wrapper for OpenAI beta chat that tracks usage in Insights."""
"""Wrapper for OpenAI beta chat that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_beta_chat):
self._client = client
@@ -567,7 +567,7 @@ class WrappedBetaChat:
class WrappedBetaCompletions:
"""Wrapper for OpenAI beta chat completions that tracks usage in Insights."""
"""Wrapper for OpenAI beta chat completions that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_beta_completions):
self._client = client
@@ -579,21 +579,21 @@ class WrappedBetaCompletions:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
return call_llm_and_track_usage(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
@@ -2,7 +2,7 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from hanzo_insights.ai.types import TokenUsage
from posthog.ai.types import TokenUsage
try:
import openai
@@ -11,43 +11,43 @@ except ImportError:
"Please install the OpenAI SDK to use this feature: 'pip install openai'"
)
from hanzo_insights import setup
from hanzo_insights.ai.utils import (
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage_async,
extract_available_tool_calls,
get_model_params,
merge_usage_stats,
with_privacy_mode,
)
from hanzo_insights.ai.openai.openai_converter import (
from posthog.ai.openai.openai_converter import (
extract_openai_usage_from_chunk,
extract_openai_content_from_chunk,
extract_openai_tool_calls_from_chunk,
accumulate_openai_tool_calls,
format_openai_streaming_output,
)
from hanzo_insights.ai.sanitization import sanitize_openai, sanitize_openai_response
from hanzo_insights.client import Client as InsightsClient
from posthog.ai.sanitization import sanitize_openai, sanitize_openai_response
from posthog.client import Client as PostHogClient
class AsyncOpenAI(openai.AsyncOpenAI):
"""
An async wrapper around the OpenAI SDK that automatically sends LLM usage events to Insights.
An async wrapper around the OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = 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.
posthog_client: If provided, events will be captured via this client instead
of the global posthog.
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
"""
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -70,7 +70,7 @@ class AsyncOpenAI(openai.AsyncOpenAI):
class WrappedResponses:
"""Async wrapper for OpenAI responses that tracks usage in Insights."""
"""Async wrapper for OpenAI responses that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_responses):
self._client = client
@@ -83,34 +83,34 @@ class WrappedResponses:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_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,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return await call_llm_and_track_usage_async(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.create,
**kwargs,
@@ -118,11 +118,11 @@ class WrappedResponses:
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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -165,11 +165,11 @@ class WrappedResponses:
output = final_content
await self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -182,11 +182,11 @@ class WrappedResponses:
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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
@@ -194,8 +194,8 @@ class WrappedResponses:
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())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
# Use model from kwargs, fallback to model from response
model = kwargs.get("model") or model_from_response or "unknown"
@@ -206,12 +206,12 @@ class WrappedResponses:
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
sanitize_openai_response(kwargs.get("input")),
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
format_openai_streaming_output(output, "responses"),
),
"$ai_http_status": 200,
@@ -222,9 +222,9 @@ class WrappedResponses:
),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": insights_trace_id,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(insights_properties or {}),
**(posthog_properties or {}),
}
# Add web search count if present
@@ -239,48 +239,48 @@ class WrappedResponses:
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if insights_distinct_id is None:
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=insights_distinct_id or insights_trace_id,
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=insights_groups,
groups=posthog_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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in Insights.
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
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.
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
Returns:
The response from OpenAI's responses.parse call.
"""
return await call_llm_and_track_usage_async(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
@@ -288,7 +288,7 @@ class WrappedResponses:
class WrappedChat:
"""Async wrapper for OpenAI chat that tracks usage in Insights."""
"""Async wrapper for OpenAI chat that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_chat):
self._client = client
@@ -304,7 +304,7 @@ class WrappedChat:
class WrappedCompletions:
"""Async wrapper for OpenAI chat completions that tracks usage in Insights."""
"""Async wrapper for OpenAI chat completions that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_completions):
self._client = client
@@ -316,35 +316,35 @@ class WrappedCompletions:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
# If streaming, handle streaming specifically
if kwargs.get("stream", False):
return await self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
response = await call_llm_and_track_usage_async(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.create,
**kwargs,
@@ -353,11 +353,11 @@ class WrappedCompletions:
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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -414,11 +414,11 @@ class WrappedCompletions:
)
await self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -432,11 +432,11 @@ class WrappedCompletions:
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]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
@@ -445,8 +445,8 @@ class WrappedCompletions:
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())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
# Use model from kwargs, fallback to model from response
model = kwargs.get("model") or model_from_response or "unknown"
@@ -457,12 +457,12 @@ class WrappedCompletions:
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
sanitize_openai(kwargs.get("messages")),
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
format_openai_streaming_output(output, "chat", tool_calls),
),
"$ai_http_status": 200,
@@ -473,9 +473,9 @@ class WrappedCompletions:
),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": insights_trace_id,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(insights_properties or {}),
**(posthog_properties or {}),
}
# Add web search count if present
@@ -491,20 +491,20 @@ class WrappedCompletions:
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if insights_distinct_id is None:
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=insights_distinct_id or insights_trace_id,
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=insights_groups,
groups=posthog_groups,
)
class WrappedEmbeddings:
"""Async wrapper for OpenAI embeddings that tracks usage in Insights."""
"""Async wrapper for OpenAI embeddings that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_embeddings):
self._client = client
@@ -517,30 +517,30 @@ class WrappedEmbeddings:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in Insights.
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in PostHog.
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.
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Embeddings API.
Returns:
The response from OpenAI's embeddings.create call.
"""
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
start_time = time.time()
response = await self._original.create(**kwargs)
@@ -563,34 +563,34 @@ class WrappedEmbeddings:
"$ai_model": kwargs.get("model"),
"$ai_input": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
sanitize_openai_response(kwargs.get("input")),
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": insights_trace_id,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(insights_properties or {}),
**(posthog_properties or {}),
}
if insights_distinct_id is None:
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
# Send capture event for embeddings
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=insights_distinct_id or insights_trace_id,
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_embedding",
properties=event_properties,
groups=insights_groups,
groups=posthog_groups,
)
return response
class WrappedBeta:
"""Async wrapper for OpenAI beta features that tracks usage in Insights."""
"""Async wrapper for OpenAI beta features that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_beta):
self._client = client
@@ -607,7 +607,7 @@ class WrappedBeta:
class WrappedBetaChat:
"""Async wrapper for OpenAI beta chat that tracks usage in Insights."""
"""Async wrapper for OpenAI beta chat that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_beta_chat):
self._client = client
@@ -624,7 +624,7 @@ class WrappedBetaChat:
class WrappedBetaCompletions:
"""Async wrapper for OpenAI beta chat completions that tracks usage in Insights."""
"""Async wrapper for OpenAI beta chat completions that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_beta_completions):
self._client = client
@@ -637,21 +637,21 @@ class WrappedBetaCompletions:
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,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
return await call_llm_and_track_usage_async(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
@@ -2,13 +2,13 @@
OpenAI-specific conversion utilities.
This module handles the conversion of OpenAI API responses and inputs
into standardized formats for Insights tracking. It supports both
into standardized formats for PostHog tracking. It supports both
Chat Completions API and Responses API formats.
"""
from typing import Any, Dict, List, Optional
from hanzo_insights.ai.types import (
from posthog.ai.types import (
FormattedContentItem,
FormattedFunctionCall,
FormattedImageContent,
@@ -16,7 +16,6 @@ from hanzo_insights.ai.types import (
FormattedTextContent,
TokenUsage,
)
from hanzo_insights.ai.utils import serialize_raw_usage
def format_openai_response(response: Any) -> List[FormattedMessage]:
@@ -430,12 +429,6 @@ def extract_openai_usage_from_response(response: Any) -> TokenUsage:
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
@@ -489,12 +482,6 @@ def extract_openai_usage_from_chunk(
chunk.usage.completion_tokens_details.reasoning_tokens
)
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(chunk.usage)
if serialized:
usage["raw_usage"] = serialized
elif provider_type == "responses":
# For Responses API, usage is only in chunk.response.usage for completed events
if hasattr(chunk, "type") and chunk.type == "response.completed":
@@ -529,12 +516,6 @@ def extract_openai_usage_from_chunk(
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
@@ -753,8 +734,8 @@ def format_openai_streaming_input(
api_type: Either "chat" or "responses"
Returns:
Formatted input ready for Insights tracking
Formatted input ready for PostHog tracking
"""
from hanzo_insights.ai.utils import merge_system_prompt
from posthog.ai.utils import merge_system_prompt
return merge_system_prompt(kwargs, "openai")
@@ -5,39 +5,39 @@ except ImportError:
"Please install the Open AI SDK to use this feature: 'pip install openai'"
)
from hanzo_insights.ai.openai.openai import (
from posthog.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 posthog.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta
from posthog.ai.openai.openai_async import WrappedChat as AsyncWrappedChat
from posthog.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings
from posthog.ai.openai.openai_async import WrappedResponses as AsyncWrappedResponses
from typing import Optional
from hanzo_insights.client import Client as InsightsClient
from hanzo_insights import setup
from posthog.client import Client as PostHogClient
from posthog import setup
class AzureOpenAI(openai.AzureOpenAI):
"""
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to Insights.
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = 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.
posthog_client: If provided, events will be captured via this client instead
of the global posthog.
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
"""
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -61,21 +61,21 @@ class AzureOpenAI(openai.AzureOpenAI):
class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
"""
An async wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to Insights.
An async wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = 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.
posthog_client: If provided, events will be captured via this client instead
of the global posthog.
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
"""
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -83,12 +83,6 @@ def sanitize_openai_image(item: Any) -> Any:
if not isinstance(item, dict):
return item
if item.get("type") == "input_image" and isinstance(item.get("image_url"), str):
return {
**item,
"image_url": redact_base64_data_url(item["image_url"]),
}
if (
item.get("type") == "image_url"
and isinstance(item.get("image_url"), dict)
@@ -1,5 +1,5 @@
"""
Common type definitions for Insights AI SDK.
Common type definitions for PostHog AI SDK.
These types are used for formatting messages and responses across different AI providers
(Anthropic, OpenAI, Gemini, etc.) to ensure consistency in tracking and data structure.
@@ -41,10 +41,10 @@ FormattedContentItem = Union[
class FormattedMessage(TypedDict):
"""
Standardized message format for Insights tracking.
Standardized message format for PostHog tracking.
Used across all providers to ensure consistent message structure
when sending events to Insights.
when sending events to PostHog.
"""
role: str
@@ -64,7 +64,6 @@ class TokenUsage(TypedDict, total=False):
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):
@@ -2,85 +2,15 @@ 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 (
from posthog import get_tags, identify_context, new_context, tag
from posthog.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
from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage
from posthog.client import Client as PostHogClient
def merge_usage_stats(
@@ -130,17 +60,6 @@ def merge_usage_stats(
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:
@@ -157,9 +76,6 @@ def merge_usage_stats(
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'")
@@ -193,19 +109,19 @@ def get_usage(response, provider: str) -> TokenUsage:
Delegates to provider-specific converter functions.
"""
if provider == "anthropic":
from hanzo_insights.ai.anthropic.anthropic_converter import (
from posthog.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 (
from posthog.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 (
from posthog.ai.gemini.gemini_converter import (
extract_gemini_usage_from_response,
)
@@ -219,15 +135,15 @@ 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
from posthog.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
from posthog.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
from posthog.ai.gemini.gemini_converter import format_gemini_response
return format_gemini_response(response)
return []
@@ -238,15 +154,15 @@ 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
from posthog.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
from posthog.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
from posthog.ai.openai.openai_converter import extract_openai_tools
return extract_openai_tools(kwargs)
return None
@@ -259,19 +175,19 @@ def merge_system_prompt(
Merge system prompts and format messages for the given provider.
"""
if provider == "anthropic":
from hanzo_insights.ai.anthropic.anthropic_converter import format_anthropic_input
from posthog.ai.anthropic.anthropic_converter import format_anthropic_input
messages = kwargs.get("messages") or []
system = kwargs.get("system")
return format_anthropic_input(messages, system)
elif provider == "gemini":
from hanzo_insights.ai.gemini.gemini_converter import format_gemini_input_with_system
from posthog.ai.gemini.gemini_converter import format_gemini_input_with_system
contents = kwargs.get("contents", [])
config = kwargs.get("config")
return format_gemini_input_with_system(contents, config)
elif provider == "openai":
from hanzo_insights.ai.openai.openai_converter import format_openai_input
from posthog.ai.openai.openai_converter import format_openai_input
# For OpenAI, handle both Chat Completions and Responses API
messages_param = kwargs.get("messages")
@@ -319,13 +235,13 @@ def merge_system_prompt(
def call_llm_and_track_usage(
insights_distinct_id: Optional[str],
ph_client: InsightsClient,
posthog_distinct_id: Optional[str],
ph_client: PostHogClient,
provider: str,
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
base_url: str,
call_method: Callable[..., Any],
**kwargs: Any,
@@ -342,8 +258,8 @@ def call_llm_and_track_usage(
error_params: Dict[str, Any] = {}
with new_context(client=ph_client, capture_exceptions=False):
if insights_distinct_id:
identify_context(insights_distinct_id)
if posthog_distinct_id:
identify_context(posthog_distinct_id)
try:
response = call_method(**kwargs)
@@ -363,18 +279,8 @@ def call_llm_and_track_usage(
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and (
hasattr(response, "usage")
@@ -390,19 +296,19 @@ def call_llm_and_track_usage(
tag("$ai_model_parameters", get_model_params(kwargs))
tag(
"$ai_input",
with_privacy_mode(ph_client, insights_privacy_mode, sanitized_messages),
with_privacy_mode(ph_client, posthog_privacy_mode, sanitized_messages),
)
tag(
"$ai_output_choices",
with_privacy_mode(
ph_client, insights_privacy_mode, format_response(response, provider)
ph_client, posthog_privacy_mode, format_response(response, provider)
),
)
tag("$ai_http_status", http_status)
tag("$ai_input_tokens", usage.get("input_tokens", 0))
tag("$ai_output_tokens", usage.get("output_tokens", 0))
tag("$ai_latency", latency)
tag("$ai_trace_id", insights_trace_id)
tag("$ai_trace_id", posthog_trace_id)
tag("$ai_base_url", str(base_url))
available_tool_calls = extract_available_tool_calls(provider, kwargs)
@@ -426,12 +332,7 @@ def call_llm_and_track_usage(
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:
if posthog_distinct_id is None:
tag("$process_person_profile", False)
# Process instructions for Responses API
@@ -439,26 +340,21 @@ def call_llm_and_track_usage(
tag(
"$ai_instructions",
with_privacy_mode(
ph_client, insights_privacy_mode, kwargs.get("instructions")
ph_client, posthog_privacy_mode, kwargs.get("instructions")
),
)
# send the event to Insights
# send the event to posthog
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(),
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=merged_properties,
groups=insights_groups,
properties={
**get_tags(),
**(posthog_properties or {}),
**(error_params or {}),
},
groups=posthog_groups,
)
if error:
@@ -468,13 +364,13 @@ def call_llm_and_track_usage(
async def call_llm_and_track_usage_async(
insights_distinct_id: Optional[str],
ph_client: InsightsClient,
posthog_distinct_id: Optional[str],
ph_client: PostHogClient,
provider: str,
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
base_url: str,
call_async_method: Callable[..., Any],
**kwargs: Any,
@@ -487,8 +383,8 @@ async def call_llm_and_track_usage_async(
error_params: Dict[str, Any] = {}
with new_context(client=ph_client, capture_exceptions=False):
if insights_distinct_id:
identify_context(insights_distinct_id)
if posthog_distinct_id:
identify_context(posthog_distinct_id)
try:
response = await call_async_method(**kwargs)
@@ -508,18 +404,8 @@ async def call_llm_and_track_usage_async(
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and (
hasattr(response, "usage")
@@ -535,19 +421,19 @@ async def call_llm_and_track_usage_async(
tag("$ai_model_parameters", get_model_params(kwargs))
tag(
"$ai_input",
with_privacy_mode(ph_client, insights_privacy_mode, sanitized_messages),
with_privacy_mode(ph_client, posthog_privacy_mode, sanitized_messages),
)
tag(
"$ai_output_choices",
with_privacy_mode(
ph_client, insights_privacy_mode, format_response(response, provider)
ph_client, posthog_privacy_mode, format_response(response, provider)
),
)
tag("$ai_http_status", http_status)
tag("$ai_input_tokens", usage.get("input_tokens", 0))
tag("$ai_output_tokens", usage.get("output_tokens", 0))
tag("$ai_latency", latency)
tag("$ai_trace_id", insights_trace_id)
tag("$ai_trace_id", posthog_trace_id)
tag("$ai_base_url", str(base_url))
available_tool_calls = extract_available_tool_calls(provider, kwargs)
@@ -571,12 +457,7 @@ async def call_llm_and_track_usage_async(
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:
if posthog_distinct_id is None:
tag("$process_person_profile", False)
# Process instructions for Responses API
@@ -584,26 +465,21 @@ async def call_llm_and_track_usage_async(
tag(
"$ai_instructions",
with_privacy_mode(
ph_client, insights_privacy_mode, kwargs.get("instructions")
ph_client, posthog_privacy_mode, kwargs.get("instructions")
),
)
# send the event to Insights
# send the event to posthog
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(),
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=merged_properties,
groups=insights_groups,
properties={
**get_tags(),
**(posthog_properties or {}),
**(error_params or {}),
},
groups=posthog_groups,
)
if error:
@@ -625,14 +501,14 @@ def sanitize_messages(data: Any, provider: str) -> Any:
return data
def with_privacy_mode(ph_client: InsightsClient, privacy_mode: bool, value: Any):
def with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any):
if ph_client.privacy_mode or privacy_mode:
return None
return value
def capture_streaming_event(
ph_client: InsightsClient,
ph_client: PostHogClient,
event_data: StreamingEventData,
):
"""
@@ -642,15 +518,15 @@ def capture_streaming_event(
All provider-specific formatting should be done BEFORE calling this function.
The function handles:
- Building Insights event properties
- Building PostHog event properties
- Extracting and adding tools based on provider
- Applying privacy mode
- Adding special token fields (cache, reasoning)
- Provider-specific fields (e.g., OpenAI instructions)
- Sending the event to Insights
- Sending the event to PostHog
Args:
ph_client: Insights client instance
ph_client: PostHog client instance
event_data: Standardized streaming event data containing all necessary information
"""
trace_id = event_data.get("trace_id") or str(uuid.uuid4())
@@ -679,15 +555,6 @@ def capture_streaming_event(
**(event_data.get("properties") or {}),
}
# Determine token source: SDK-computed vs externally overridden
sdk_token_tags = {
"$ai_input_tokens": event_data["usage_stats"].get("input_tokens", 0),
"$ai_output_tokens": event_data["usage_stats"].get("output_tokens", 0),
}
event_properties["$ai_tokens_source"] = _get_tokens_source(
sdk_token_tags, event_data.get("properties")
)
# Extract and add tools based on provider
available_tools = extract_available_tool_calls(
event_data["provider"],
@@ -727,12 +594,6 @@ def capture_streaming_event(
):
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"
@@ -747,7 +608,7 @@ def capture_streaming_event(
if event_data.get("distinct_id") is None:
event_properties["$process_person_profile"] = False
# Send event to Insights
# Send event to PostHog
if hasattr(ph_client, "capture"):
ph_client.capture(
distinct_id=event_data.get("distinct_id") or trace_id,
+1 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime
import numbers
from uuid import UUID
from hanzo_insights.types import SendFeatureFlagsOptions
from posthog.types import SendFeatureFlagsOptions
ID_TYPES = Union[numbers.Number, str, UUID, int]
+76 -155
View File
@@ -11,20 +11,19 @@ from dateutil.tz import tzutc
from six import string_types
from typing_extensions import Unpack
from hanzo_insights.args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
from hanzo_insights.consumer import Consumer
from hanzo_insights.contexts import (
from posthog.args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
from posthog.consumer import Consumer
from posthog.contexts import (
_get_current_context,
get_capture_exception_code_variables_context,
get_code_variables_ignore_patterns_context,
get_code_variables_mask_patterns_context,
get_context_device_id,
get_context_distinct_id,
get_context_session_id,
new_context,
)
from hanzo_insights.exception_capture import ExceptionCapture
from hanzo_insights.exception_utils import (
from posthog.exception_capture import ExceptionCapture
from posthog.exception_utils import (
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
exc_info_from_error,
@@ -34,18 +33,17 @@ from hanzo_insights.exception_utils import (
mark_exception_as_captured,
try_attach_code_variables_to_frames,
)
from hanzo_insights.feature_flags import (
from posthog.feature_flags import (
InconclusiveMatchError,
RequiresServerEvaluation,
match_feature_flag_properties,
resolve_bucketing_value,
)
from hanzo_insights.flag_definition_cache import (
from posthog.flag_definition_cache import (
FlagDefinitionCacheData,
FlagDefinitionCacheProvider,
)
from hanzo_insights.poller import Poller
from hanzo_insights.request import (
from posthog.poller import Poller
from posthog.request import (
DEFAULT_HOST,
APIError,
QuotaLimitError,
@@ -57,7 +55,7 @@ from hanzo_insights.request import (
get,
remote_config,
)
from hanzo_insights.types import (
from posthog.types import (
FeatureFlag,
FeatureFlagError,
FeatureFlagResult,
@@ -71,7 +69,7 @@ from hanzo_insights.types import (
to_payloads,
to_values,
)
from hanzo_insights.utils import (
from posthog.utils import (
FlagCache,
RedisFlagCache,
SizeLimitedDict,
@@ -79,7 +77,7 @@ from hanzo_insights.utils import (
guess_timezone,
system_context,
)
from hanzo_insights.version import VERSION
from posthog.version import VERSION
try:
import queue
@@ -149,22 +147,22 @@ def no_throw(default_return=None):
class Client(object):
"""
This is the SDK reference for the Hanzo Insights Python SDK.
This is the SDK reference for the PostHog Python SDK.
You can learn more about example usage in the [Python SDK documentation](/docs/libraries/python).
You can also follow [Flask](/docs/libraries/flask) and [Django](/docs/libraries/django)
guides to integrate Insights into your project.
guides to integrate PostHog into your project.
Examples:
```python
from hanzo_insights import Insights
client = Insights('<ph_project_api_key>', host='<ph_client_api_host>')
hanzo_insights.debug = True
from posthog import Posthog
posthog = Posthog('<ph_project_api_key>', host='<ph_client_api_host>')
posthog.debug = True
if settings.TEST:
hanzo_insights.disabled = True
posthog.disabled = True
```
"""
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
def __init__(
self,
@@ -202,7 +200,7 @@ class Client(object):
in_app_modules: list[str] | None = None,
):
"""
Initialize a new Insights client instance.
Initialize a new PostHog client instance.
Args:
project_api_key: The project API key.
@@ -211,9 +209,9 @@ class Client(object):
Examples:
```python
from hanzo_insights import Insights
from posthog import Posthog
client = Insights('<ph_project_api_key>', host='<ph_app_host>')
posthog = Posthog('<ph_project_api_key>', host='<ph_app_host>')
```
Category:
@@ -342,9 +340,9 @@ class Client(object):
Examples:
```python
with hanzo_insights.new_context():
with posthog.new_context():
identify_context('<distinct_id>')
hanzo_insights.capture('event_name')
posthog.capture('event_name')
```
Category:
@@ -384,7 +382,6 @@ class Client(object):
group_properties=None,
disable_geoip=None,
flag_keys_to_evaluate: Optional[list[str]] = None,
device_id: Optional[str] = None,
) -> dict[str, Union[bool, str]]:
"""
Get feature flag variants for a user by calling decide.
@@ -397,7 +394,6 @@ class Client(object):
disable_geoip: Whether to disable GeoIP for this request.
flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
only these flags will be evaluated, improving performance.
device_id: The device ID for this request.
Category:
Feature flags
@@ -409,7 +405,6 @@ class Client(object):
group_properties,
disable_geoip,
flag_keys_to_evaluate,
device_id=device_id,
)
return to_values(resp_data) or {}
@@ -421,7 +416,6 @@ class Client(object):
group_properties=None,
disable_geoip=None,
flag_keys_to_evaluate: Optional[list[str]] = None,
device_id: Optional[str] = None,
) -> dict[str, str]:
"""
Get feature flag payloads for a user by calling decide.
@@ -434,11 +428,10 @@ class Client(object):
disable_geoip: Whether to disable GeoIP for this request.
flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
only these flags will be evaluated, improving performance.
device_id: The device ID for this request.
Examples:
```python
payloads = hanzo_insights.get_feature_payloads('<distinct_id>')
payloads = posthog.get_feature_payloads('<distinct_id>')
```
Category:
@@ -451,7 +444,6 @@ class Client(object):
group_properties,
disable_geoip,
flag_keys_to_evaluate,
device_id=device_id,
)
return to_payloads(resp_data) or {}
@@ -463,7 +455,6 @@ class Client(object):
group_properties=None,
disable_geoip=None,
flag_keys_to_evaluate: Optional[list[str]] = None,
device_id: Optional[str] = None,
) -> FlagsAndPayloads:
"""
Get feature flags and payloads for a user by calling decide.
@@ -476,11 +467,10 @@ class Client(object):
disable_geoip: Whether to disable GeoIP for this request.
flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
only these flags will be evaluated, improving performance.
device_id: The device ID for this request.
Examples:
```python
result = hanzo_insights.get_feature_flags_and_payloads('<distinct_id>')
result = posthog.get_feature_flags_and_payloads('<distinct_id>')
```
Category:
@@ -493,7 +483,6 @@ class Client(object):
group_properties,
disable_geoip,
flag_keys_to_evaluate,
device_id=device_id,
)
return to_flags_and_payloads(resp)
@@ -505,7 +494,6 @@ class Client(object):
group_properties=None,
disable_geoip=None,
flag_keys_to_evaluate: Optional[list[str]] = None,
device_id: Optional[str] = None,
) -> FlagsResponse:
"""
Get feature flags decision.
@@ -518,11 +506,10 @@ class Client(object):
disable_geoip: Whether to disable GeoIP for this request.
flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
only these flags will be evaluated, improving performance.
device_id: The device ID for this request.
Examples:
```python
decision = hanzo_insights.get_flags_decision('user123')
decision = posthog.get_flags_decision('user123')
```
Category:
@@ -535,9 +522,6 @@ class Client(object):
if distinct_id is None:
distinct_id = get_context_distinct_id()
if device_id is None:
device_id = get_context_device_id()
if disable_geoip is None:
disable_geoip = self.disable_geoip
@@ -550,7 +534,6 @@ class Client(object):
"person_properties": person_properties,
"group_properties": group_properties,
"geoip_disable": disable_geoip,
"device_id": device_id,
}
if flag_keys_to_evaluate:
@@ -570,7 +553,7 @@ class Client(object):
self, event: str, **kwargs: Unpack[OptionalCaptureArgs]
) -> Optional[str]:
"""
Captures an event manually. [Learn about capture best practices](https://insights.hanzo.ai/docs/product-analytics/capture-events)
Captures an event manually. [Learn about capture best practices](https://posthog.com/docs/product-analytics/capture-events)
Args:
event: The event name to capture.
@@ -585,20 +568,20 @@ class Client(object):
Examples:
```python
# Anonymous event
hanzo_insights.capture('some-anon-event')
posthog.capture('some-anon-event')
```
```python
# Context usage
from hanzo_insights import identify_context, new_context
from posthog import identify_context, new_context
with new_context():
identify_context('distinct_id_of_the_user')
hanzo_insights.capture('user_signed_up')
hanzo_insights.capture('user_logged_in')
hanzo_insights.capture('some-custom-action', distinct_id='distinct_id_of_the_user')
posthog.capture('user_signed_up')
posthog.capture('user_logged_in')
posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')
```
```python
# Set event properties
hanzo_insights.capture(
posthog.capture(
"user_signed_up",
distinct_id="distinct_id_of_the_user",
properties={
@@ -609,7 +592,7 @@ class Client(object):
```
```python
# Page view event
hanzo_insights.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'})
posthog.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'})
```
Category:
@@ -769,7 +752,7 @@ class Client(object):
Examples:
```python
# Set with distinct id
hanzo_insights.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})
posthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})
```
Category:
@@ -816,7 +799,7 @@ class Client(object):
Examples:
```python
hanzo_insights.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})
posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})
```
Category:
@@ -873,7 +856,7 @@ class Client(object):
Examples:
```python
hanzo_insights.group_identify('company', 'company_id_in_your_db', {
posthog.group_identify('company', 'company_id_in_your_db', {
'name': 'Awesome Inc.',
'employees': 11
})
@@ -928,7 +911,7 @@ class Client(object):
Examples:
```python
hanzo_insights.alias(previous_id='distinct_id', distinct_id='alias_id')
posthog.alias(previous_id='distinct_id', distinct_id='alias_id')
```
Category:
@@ -978,7 +961,7 @@ class Client(object):
# Some code that might fail
pass
except Exception as e:
hanzo_insights.capture_exception(e, 'user_distinct_id', properties=additional_properties)
posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties)
```
Category:
@@ -1108,7 +1091,7 @@ class Client(object):
if not msg.get("properties"):
msg["properties"] = {}
msg["properties"]["$lib"] = "insights-python"
msg["properties"]["$lib"] = "posthog-python"
msg["properties"]["$lib_version"] = VERSION
if disable_geoip is None:
@@ -1168,8 +1151,8 @@ class Client(object):
Examples:
```python
hanzo_insights.capture('event_name')
hanzo_insights.flush() # Ensures the event is sent immediately
posthog.capture('event_name')
posthog.flush() # Ensures the event is sent immediately
```
"""
queue = self.queue
@@ -1184,7 +1167,7 @@ class Client(object):
Examples:
```python
hanzo_insights.join()
posthog.join()
```
"""
if self.consumers:
@@ -1212,7 +1195,7 @@ class Client(object):
Examples:
```python
hanzo_insights.shutdown()
posthog.shutdown()
```
"""
self.flush()
@@ -1285,7 +1268,7 @@ class Client(object):
self._fetch_feature_flags_from_api()
def _fetch_feature_flags_from_api(self):
"""Fetch feature flags from the Insights API."""
"""Fetch feature flags from the PostHog API."""
try:
# Store old flags to detect changes
old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
@@ -1334,25 +1317,18 @@ class Client(object):
except APIError as e:
if e.status == 401:
self.log.error(
"[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://insights.hanzo.ai/docs/api/overview"
"[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://posthog.com/docs/api/overview"
)
self.feature_flags = []
self.group_type_mapping = {}
self.cohorts = {}
if self.flag_cache:
self.flag_cache.clear()
if self.debug:
raise APIError(
status=401,
message="You are using a write-only key with feature flags. "
"To use feature flags, please set a personal_api_key "
"More information: https://insights.hanzo.ai/docs/api/overview",
"More information: https://posthog.com/docs/api/overview",
)
elif e.status == 402:
self.log.warning(
"[FEATURE FLAGS] Insights feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://insights.hanzo.ai/docs/billing/limits-alerts"
"[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts"
)
# Reset all feature flag data when quota limited
self.feature_flags = []
@@ -1366,7 +1342,7 @@ class Client(object):
if self.debug:
raise APIError(
status=402,
message="Insights feature flags quota limited",
message="PostHog feature flags quota limited",
)
else:
self.log.error(f"[FEATURE FLAGS] Error loading feature flags: {e}")
@@ -1385,7 +1361,7 @@ class Client(object):
Examples:
```python
hanzo_insights.load_feature_flags()
posthog.load_feature_flags()
```
Category:
@@ -1419,7 +1395,6 @@ class Client(object):
person_properties=None,
group_properties=None,
warn_on_unknown_groups=True,
device_id=None,
) -> FlagValue:
groups = groups or {}
person_properties = person_properties or {}
@@ -1460,35 +1435,22 @@ class Client(object):
)
return False
if group_name not in group_properties:
raise InconclusiveMatchError(
f"Flag has no group properties for group '{group_name}'"
)
focused_group_properties = group_properties[group_name]
group_key = groups[group_name]
return match_feature_flag_properties(
feature_flag,
group_key,
groups[group_name],
focused_group_properties,
cohort_properties=self.cohorts,
flags_by_key=self.feature_flags_by_key,
evaluation_cache=evaluation_cache,
device_id=device_id,
bucketing_value=group_key,
self.feature_flags_by_key,
evaluation_cache,
)
else:
bucketing_value = resolve_bucketing_value(
feature_flag, distinct_id, device_id
)
return match_feature_flag_properties(
feature_flag,
distinct_id,
person_properties,
cohort_properties=self.cohorts,
flags_by_key=self.feature_flags_by_key,
evaluation_cache=evaluation_cache,
device_id=device_id,
bucketing_value=bucketing_value,
self.cohorts,
self.feature_flags_by_key,
evaluation_cache,
)
def feature_enabled(
@@ -1502,7 +1464,6 @@ class Client(object):
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None,
device_id: Optional[str] = None,
):
"""
Check if a feature flag is enabled for a user.
@@ -1516,15 +1477,14 @@ class Client(object):
only_evaluate_locally: Whether to only evaluate locally.
send_feature_flag_events: Whether to send feature flag events.
disable_geoip: Whether to disable GeoIP for this request.
device_id: The device ID for this request.
Examples:
```python
is_my_flag_enabled = hanzo_insights.feature_enabled('flag-key', 'distinct_id_of_your_user')
is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')
if is_my_flag_enabled:
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = hanzo_insights.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
```
Category:
@@ -1539,7 +1499,6 @@ class Client(object):
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
if response is None:
@@ -1571,7 +1530,6 @@ class Client(object):
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None,
device_id: Optional[str] = None,
) -> Optional[FeatureFlagResult]:
if self.disabled:
return None
@@ -1595,12 +1553,8 @@ class Client(object):
evaluated_at = None
feature_flag_error: Optional[str] = None
# Resolve device_id from context if not provided
if device_id is None:
device_id = get_context_device_id()
flag_value = self._locally_evaluate_flag(
key, distinct_id, groups, person_properties, group_properties, device_id
key, distinct_id, groups, person_properties, group_properties
)
flag_was_locally_evaluated = flag_value is not None
@@ -1620,13 +1574,7 @@ class Client(object):
self.flag_cache.set_cached_flag(
distinct_id, key, flag_result, self.flag_definition_version
)
elif only_evaluate_locally:
if self.feature_flags is None:
self.log.warning(
"[FEATURE FLAGS] Local evaluation called but feature flag definitions are not loaded yet. "
"Returning None. You can call load_feature_flags() to load flags explicitly."
)
else:
elif not only_evaluate_locally:
try:
flag_details, request_id, evaluated_at, errors_while_computing = (
self._get_feature_flag_details_from_server(
@@ -1636,7 +1584,6 @@ class Client(object):
person_properties,
group_properties,
disable_geoip,
device_id=device_id,
)
)
errors = []
@@ -1709,7 +1656,6 @@ class Client(object):
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None,
device_id: Optional[str] = None,
) -> Optional[FeatureFlagResult]:
"""
Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely
@@ -1718,7 +1664,7 @@ class Client(object):
Examples:
```python
flag_result = hanzo_insights.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')
flag_result = posthog.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')
if flag_result and flag_result.get_value() == 'variant-key':
# Do something differently for this user
# Optional: fetch the payload
@@ -1734,7 +1680,6 @@ class Client(object):
only_evaluate_locally: Whether to only evaluate locally.
send_feature_flag_events: Whether to send feature flag events.
disable_geoip: Whether to disable GeoIP for this request.
device_id: The device ID for this request.
Returns:
Optional[FeatureFlagResult]: The feature flag result or None if disabled/not found.
@@ -1748,7 +1693,6 @@ class Client(object):
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(
@@ -1762,7 +1706,6 @@ class Client(object):
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None,
device_id: Optional[str] = None,
) -> Optional[FlagValue]:
"""
Get multivariate feature flag value for a user.
@@ -1776,15 +1719,14 @@ class Client(object):
only_evaluate_locally: Whether to only evaluate locally.
send_feature_flag_events: Whether to send feature flag events.
disable_geoip: Whether to disable GeoIP for this request.
device_id: The device ID for this request.
Examples:
```python
enabled_variant = hanzo_insights.get_feature_flag('flag-key', 'distinct_id_of_your_user')
enabled_variant = posthog.get_feature_flag('flag-key', 'distinct_id_of_your_user')
if enabled_variant == 'variant-key': # replace 'variant-key' with the key of your variant
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = hanzo_insights.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
```
Category:
@@ -1799,7 +1741,6 @@ class Client(object):
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
return feature_flag_result.get_value() if feature_flag_result else None
@@ -1810,7 +1751,6 @@ class Client(object):
groups: dict[str, str],
person_properties: dict[str, str],
group_properties: dict[str, str],
device_id: Optional[str] = None,
) -> Optional[FlagValue]:
if self.feature_flags is None and self.personal_api_key:
self.load_feature_flags()
@@ -1830,7 +1770,6 @@ class Client(object):
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
device_id=device_id,
)
self.log.debug(
f"Successfully computed flag locally: {key} -> {response}"
@@ -1855,7 +1794,6 @@ class Client(object):
only_evaluate_locally=False,
send_feature_flag_events=False,
disable_geoip=None,
device_id: Optional[str] = None,
):
"""
Get the payload for a feature flag.
@@ -1870,16 +1808,15 @@ class Client(object):
only_evaluate_locally: Whether to only evaluate locally.
send_feature_flag_events: Deprecated. Use get_feature_flag() instead if you need events.
disable_geoip: Whether to disable GeoIP for this request.
device_id: The device ID for this request.
Examples:
```python
is_my_flag_enabled = hanzo_insights.feature_enabled('flag-key', 'distinct_id_of_your_user')
is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')
if is_my_flag_enabled:
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = hanzo_insights.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
```
Category:
@@ -1903,7 +1840,6 @@ class Client(object):
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
device_id=device_id,
)
return feature_flag_result.payload if feature_flag_result else None
@@ -1915,7 +1851,6 @@ class Client(object):
person_properties: dict[str, str],
group_properties: dict[str, str],
disable_geoip: Optional[bool],
device_id: Optional[str] = None,
) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]:
"""
Calls /flags and returns the flag details, request id, evaluated at timestamp,
@@ -1928,7 +1863,6 @@ class Client(object):
group_properties,
disable_geoip,
flag_keys_to_evaluate=[key],
device_id=device_id,
)
request_id = resp_data.get("requestId")
evaluated_at = resp_data.get("evaluatedAt")
@@ -1955,12 +1889,10 @@ class Client(object):
f"{key}_{'::null::' if response is None else str(response)}"
)
reported_flags = self.distinct_ids_feature_flags_reported.get(distinct_id)
if reported_flags is None:
reported_flags = set()
self.distinct_ids_feature_flags_reported[distinct_id] = reported_flags
if feature_flag_reported_key not in reported_flags:
if (
feature_flag_reported_key
not in self.distinct_ids_feature_flags_reported[distinct_id]
):
properties: dict[str, Any] = {
"$feature_flag": key,
"$feature_flag_response": response,
@@ -1996,7 +1928,9 @@ class Client(object):
groups=groups,
disable_geoip=disable_geoip,
)
reported_flags.add(feature_flag_reported_key)
self.distinct_ids_feature_flags_reported[distinct_id].add(
feature_flag_reported_key
)
def get_remote_config_payload(self, key: str):
if self.disabled:
@@ -2053,7 +1987,6 @@ class Client(object):
only_evaluate_locally=False,
disable_geoip=None,
flag_keys_to_evaluate: Optional[list[str]] = None,
device_id: Optional[str] = None,
) -> Optional[dict[str, Union[bool, str]]]:
"""
Get all feature flags for a user.
@@ -2067,11 +2000,10 @@ class Client(object):
disable_geoip: Whether to disable GeoIP for this request.
flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
only these flags will be evaluated, improving performance.
device_id: The device ID for this request.
Examples:
```python
hanzo_insights.get_all_flags('distinct_id_of_your_user')
posthog.get_all_flags('distinct_id_of_your_user')
```
Category:
@@ -2085,7 +2017,6 @@ class Client(object):
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
flag_keys_to_evaluate=flag_keys_to_evaluate,
device_id=device_id,
)
return response["featureFlags"]
@@ -2100,7 +2031,6 @@ class Client(object):
only_evaluate_locally=False,
disable_geoip=None,
flag_keys_to_evaluate: Optional[list[str]] = None,
device_id: Optional[str] = None,
) -> FlagsAndPayloads:
"""
Get all feature flags and their payloads for a user.
@@ -2114,11 +2044,10 @@ class Client(object):
disable_geoip: Whether to disable GeoIP for this request.
flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
only these flags will be evaluated, improving performance.
device_id: The device ID for this request.
Examples:
```python
hanzo_insights.get_all_flags_and_payloads('distinct_id_of_your_user')
posthog.get_all_flags_and_payloads('distinct_id_of_your_user')
```
Category:
@@ -2133,17 +2062,12 @@ class Client(object):
)
)
# Resolve device_id from context if not provided
if device_id is None:
device_id = get_context_device_id()
response, fallback_to_flags = self._get_all_flags_and_payloads_locally(
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
flag_keys_to_evaluate=flag_keys_to_evaluate,
device_id=device_id,
)
if fallback_to_flags and not only_evaluate_locally:
@@ -2155,7 +2079,6 @@ class Client(object):
group_properties=group_properties,
disable_geoip=disable_geoip,
flag_keys_to_evaluate=flag_keys_to_evaluate,
device_id=device_id,
)
return to_flags_and_payloads(decide_response)
except Exception as e:
@@ -2174,7 +2097,6 @@ class Client(object):
group_properties=None,
warn_on_unknown_groups=False,
flag_keys_to_evaluate: Optional[list[str]] = None,
device_id: Optional[str] = None,
) -> tuple[FlagsAndPayloads, bool]:
person_properties = person_properties or {}
group_properties = group_properties or {}
@@ -2204,7 +2126,6 @@ class Client(object):
person_properties=person_properties,
group_properties=group_properties,
warn_on_unknown_groups=warn_on_unknown_groups,
device_id=device_id,
)
matched_payload = self._compute_payload_locally(
flag["key"], flags[flag["key"]]
@@ -2231,7 +2152,7 @@ class Client(object):
"""Initialize feature flag cache for graceful degradation during service outages.
When enabled, the cache stores flag evaluation results and serves them as fallback
when the Insights API is unavailable. This ensures your application continues to
when the PostHog API is unavailable. This ensures your application continues to
receive flag values even during outages.
Args:
@@ -3,7 +3,9 @@ import logging
import time
from threading import Thread
from hanzo_insights.request import APIError, DatetimeSerializer, batch_post
import backoff
from posthog.request import APIError, DatetimeSerializer, batch_post
try:
from queue import Empty
@@ -21,7 +23,7 @@ BATCH_SIZE_LIMIT = 5 * 1024 * 1024
class Consumer(Thread):
"""Consumes the messages from the client's queue."""
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
def __init__(
self,
@@ -82,10 +84,7 @@ class Consumer(Thread):
self.log.error("error uploading: %s", e)
success = False
if self.on_error:
try:
self.on_error(e, batch)
except Exception as e:
self.log.error("on_error handler failed: %s", e)
self.on_error(e, batch)
finally:
# mark items as acknowledged from queue
for item in batch:
@@ -126,41 +125,29 @@ class Consumer(Thread):
def request(self, batch):
"""Attempt to upload the batch and retry before raising an error"""
def is_retryable(exc):
def fatal_exception(exc):
if isinstance(exc, APIError):
# retry on server errors and client errors
# with 408 (request timeout) or 429 (rate limited),
# with 429 status code (rate limited),
# don't retry on other client errors
if exc.status == "N/A":
return False
return not ((400 <= exc.status < 500) and exc.status not in (408, 429))
return (400 <= exc.status < 500) and exc.status != 429
else:
# retry on all other errors (eg. network)
return True
return False
last_exc = None
for attempt in range(self.retries + 1):
try:
batch_post(
self.api_key,
self.host,
gzip=self.gzip,
timeout=self.timeout,
batch=batch,
historical_migration=self.historical_migration,
)
return
except Exception as e:
last_exc = e
if not is_retryable(e):
raise
if attempt < self.retries:
# Respect Retry-After header if present, otherwise use exponential backoff
retry_after = getattr(e, "retry_after", None)
if retry_after and retry_after > 0:
time.sleep(retry_after)
else:
time.sleep(min(2**attempt, 30))
@backoff.on_exception(
backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception
)
def send_request():
batch_post(
self.api_key,
self.host,
gzip=self.gzip,
timeout=self.timeout,
batch=batch,
historical_migration=self.historical_migration,
)
if last_exc:
raise last_exc
send_request()
@@ -4,7 +4,7 @@ from typing import Optional, Any, Callable, Dict, TypeVar, cast, TYPE_CHECKING
if TYPE_CHECKING:
# To avoid circular imports
from hanzo_insights.client import Client
from posthog.client import Client
class ContextScope:
@@ -21,7 +21,6 @@ class ContextScope:
self.capture_exceptions = capture_exceptions
self.session_id: Optional[str] = None
self.distinct_id: Optional[str] = None
self.device_id: Optional[str] = None
self.tags: Dict[str, Any] = {}
self.capture_exception_code_variables: Optional[bool] = None
self.code_variables_mask_patterns: Optional[list] = None
@@ -33,9 +32,6 @@ class ContextScope:
def set_distinct_id(self, distinct_id: str):
self.distinct_id = distinct_id
def set_device_id(self, device_id: str):
self.device_id = device_id
def add_tag(self, key: str, value: Any):
self.tags[key] = value
@@ -65,13 +61,6 @@ class ContextScope:
return self.parent.get_distinct_id()
return None
def get_device_id(self) -> Optional[str]:
if self.device_id is not None:
return self.device_id
if self.parent is not None and not self.fresh:
return self.parent.get_device_id()
return None
def collect_tags(self) -> Dict[str, Any]:
if self.parent and not self.fresh:
# We want child tags to take precedence over parent tags,
@@ -104,7 +93,7 @@ class ContextScope:
_context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar(
"insights_context_stack", default=None
"posthog_context_stack", default=None
)
@@ -134,32 +123,32 @@ def new_context(
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
within the context via `Client.capture` or `posthog.capture` will still carry the context
state (tags, identity, session id), but will be captured by the client directly used (or
the global one, in the case of `hanzo_insights.capture`)
the global one, in the case of `posthog.capture`)
Examples:
```python
# Inherit parent context tags
with hanzo_insights.new_context():
hanzo_insights.tag("request_id", "123")
with posthog.new_context():
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
hanzo_insights.capture("event_name", {"property": "value"})
posthog.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")
with posthog.new_context(fresh=True):
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
hanzo_insights.capture("event_name", {"property": "value"})
posthog.capture("event_name", {"property": "value"})
raise ValueError("Something went wrong")
```
Category:
Contexts
"""
from hanzo_insights import capture_exception
from posthog import capture_exception
current_context = _get_current_context()
new_context = ContextScope(current_context, fresh, capture_exceptions, client)
@@ -189,7 +178,7 @@ def tag(key: str, value: Any) -> None:
Example:
```python
hanzo_insights.tag("user_id", "123")
posthog.tag("user_id", "123")
```
Category:
@@ -221,7 +210,7 @@ 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
distinct id's passed directly to posthog.capture and related methods (identify, set etc). Entering a
fresh context will clear the context-level distinct ID. The distinct-id passed should be uniquely associated
with one of your users. Events captured outside of a context, or in a context with no associated distinct
ID, will be assigned a random UUID, and captured as "personless".
@@ -244,7 +233,7 @@ def set_context_session(session_id: str) -> None:
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
session_id: The session ID to associate with the current context and its children. See https://posthog.com/docs/data/sessions
Category:
Contexts
@@ -286,39 +275,6 @@ def get_context_distinct_id() -> Optional[str]:
return None
def set_context_device_id(device_id: str) -> None:
"""
Set the device ID for the current context, associating all feature flag requests in this or
child contexts with the given device ID (unless set_context_device_id is called again).
Entering a fresh context will clear the context-level device ID.
Args:
device_id: The device ID to associate with the current context and its children.
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
current_context.set_device_id(device_id)
def get_context_device_id() -> Optional[str]:
"""
Get the device ID for the current context.
Returns:
The device ID if set, None otherwise
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
return current_context.get_device_id()
return None
def set_capture_exception_code_variables_context(enabled: bool) -> None:
"""
Set whether code variables are captured for the current context.
@@ -373,20 +329,20 @@ 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.
the function in a with posthog.new_context(): block.
Args:
fresh: Whether to start with a fresh context (default: False)
capture_exceptions: Whether to capture and track exceptions with Insights error tracking (default: True)
capture_exceptions: Whether to capture and track exceptions with posthog error tracking (default: True)
Example:
@hanzo_insights.scoped()
@posthog.scoped()
def process_payment(payment_id):
hanzo_insights.tag("payment_id", payment_id)
hanzo_insights.tag("payment_method", "credit_card")
posthog.tag("payment_id", payment_id)
posthog.tag("payment_method", "credit_card")
# This event will be captured with tags
hanzo_insights.capture("payment_started")
posthog.capture("payment_started")
# If this raises an exception, it will be captured with tags
# and then re-raised
some_risky_function()
@@ -9,13 +9,13 @@ import threading
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hanzo_insights.client import Client
from posthog.client import Client
class ExceptionCapture:
# TODO: Add client side rate limiting to prevent spamming the server with exceptions
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
def __init__(self, client: "Client"):
self.client = client
@@ -30,7 +30,7 @@ from typing import ( # noqa: F401
cast,
)
from hanzo_insights.args import ExceptionArg, ExcInfo # noqa: F401
from posthog.args import ExceptionArg, ExcInfo # noqa: F401
try:
# Python 3.11
@@ -43,31 +43,26 @@ 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",
r"(?i).*password.*",
r"(?i).*secret.*",
r"(?i).*passwd.*",
r"(?i).*pwd.*",
r"(?i).*api_key.*",
r"(?i).*apikey.*",
r"(?i).*auth.*",
r"(?i).*credentials.*",
r"(?i).*privatekey.*",
r"(?i).*private_key.*",
r"(?i).*token.*",
r"(?i).*aws_access_key_id.*",
r"(?i).*_pass",
r"(?i)sk_.*",
r"(?i).*jwt.*",
]
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
CODE_VARIABLES_REDACTED_VALUE = "$$_insights_redacted_based_on_masking_rules_$$"
CODE_VARIABLES_TOO_LONG_VALUE = "$$_insights_value_too_long_$$"
_MAX_VALUE_LENGTH_FOR_PATTERN_MATCH = 5_000
_MAX_COLLECTION_ITEMS_TO_SCAN = 100
_REGEX_METACHARACTERS = frozenset(r"\.^$*+?{}[]|()")
CODE_VARIABLES_REDACTED_VALUE = "$$_posthog_redacted_based_on_masking_rules_$$"
DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 20 * 1024
@@ -768,12 +763,12 @@ def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=No
def exception_is_already_captured(error):
# type: (ExceptionArg) -> bool
if isinstance(error, BaseException):
return hasattr(error, "__insights_exception_captured")
return hasattr(error, "__posthog_exception_captured")
# Autocaptured exceptions are passed as a tuple from our system hooks,
# the second item is the exception value (the first is the exception type)
elif isinstance(error, tuple) and len(error) > 1:
return error[1] is not None and hasattr(
error[1], "__insights_exception_captured"
error[1], "__posthog_exception_captured"
)
else:
return False # type: ignore[unreachable]
@@ -782,14 +777,14 @@ def exception_is_already_captured(error):
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)
setattr(error, "__posthog_exception_captured", True)
setattr(error, "__posthog_exception_uuid", uuid)
# Autocaptured exceptions are passed as a tuple from our system hooks,
# the second item is the exception value (the first is the exception type)
elif isinstance(error, tuple) and len(error) > 1:
if error[1] is not None:
setattr(error[1], "__insights_exception_captured", True)
setattr(error[1], "__insights_exception_uuid", uuid)
setattr(error[1], "__posthog_exception_captured", True)
setattr(error[1], "__posthog_exception_uuid", uuid)
def exc_info_from_error(error):
@@ -933,87 +928,40 @@ def strip_string(value, max_length=None):
)
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 = []
compiled = []
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)
try:
compiled.append(re.compile(pattern))
except Exception:
pass
return compiled
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:
for pattern in patterns:
if pattern.search(name):
return True
return False
def _mask_sensitive_data(value, compiled_mask, _seen=None):
def _mask_sensitive_data(value, compiled_mask):
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):
if _pattern_matches(key_str, compiled_mask):
result[k] = CODE_VARIABLES_REDACTED_VALUE
else:
result[k] = _mask_sensitive_data(v, compiled_mask, _seen)
result[k] = _mask_sensitive_data(v, compiled_mask)
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
]
masked_items = [_mask_sensitive_data(item, compiled_mask) 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
@@ -1034,9 +982,7 @@ def _serialize_variable_value(value, limiter, max_length=1024, compiled_mask=Non
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):
if compiled_mask and _pattern_matches(value, compiled_mask):
result = CODE_VARIABLES_REDACTED_VALUE
else:
result = value
@@ -2,46 +2,21 @@ 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
from posthog import utils
from posthog.types import FlagValue
from posthog.utils import convert_to_datetime_aware, is_valid_regex
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
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
@@ -59,18 +34,18 @@ class RequiresServerEvaluation(Exception):
pass
# This function takes a bucketing value and a feature flag key and returns a float between 0 and 1.
# Given the same bucketing value and key, it'll always return the same float. These floats are
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
# Given the same distinct_id and key, it'll always return the same float. These floats are
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
# we can do _hash(key, bucketing_value) < 0.2
def _hash(key: str, bucketing_value: str, salt: str = "") -> float:
hash_key = f"{key}.{bucketing_value}{salt}"
# we can do _hash(key, distinct_id) < 0.2
def _hash(key: str, distinct_id: str, salt: str = "") -> float:
hash_key = f"{key}.{distinct_id}{salt}"
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
return hash_val / __LONG_SCALE__
def get_matching_variant(flag, bucketing_value):
hash_value = _hash(flag["key"], bucketing_value, salt="variant")
def get_matching_variant(flag, distinct_id):
hash_value = _hash(flag["key"], distinct_id, salt="variant")
for variant in variant_lookup_table(flag):
if hash_value >= variant["value_min"] and hash_value < variant["value_max"]:
return variant["key"]
@@ -93,13 +68,7 @@ def variant_lookup_table(feature_flag):
def evaluate_flag_dependency(
property,
flags_by_key,
evaluation_cache,
distinct_id,
properties,
cohort_properties,
device_id=None,
property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties
):
"""
Evaluate a flag dependency property according to the dependency chain algorithm.
@@ -111,7 +80,6 @@ def evaluate_flag_dependency(
distinct_id: The distinct ID being evaluated
properties: Person properties for evaluation
cohort_properties: Cohort properties for evaluation
device_id: The device ID for bucketing (optional)
Returns:
bool: True if all dependencies in the chain evaluate to True, False otherwise
@@ -156,27 +124,13 @@ def evaluate_flag_dependency(
else:
# Recursively evaluate the dependency
try:
dep_flag_filters = dep_flag.get("filters") or {}
dep_aggregation_group_type_index = dep_flag_filters.get(
"aggregation_group_type_index"
)
if dep_aggregation_group_type_index is not None:
# Group flags should continue bucketing by the group key
# from the current evaluation context.
dep_bucketing_value = distinct_id
else:
dep_bucketing_value = resolve_bucketing_value(
dep_flag, distinct_id, device_id
)
dep_result = match_feature_flag_properties(
dep_flag,
distinct_id,
properties,
cohort_properties=cohort_properties,
flags_by_key=flags_by_key,
evaluation_cache=evaluation_cache,
device_id=device_id,
bucketing_value=dep_bucketing_value,
cohort_properties,
flags_by_key,
evaluation_cache,
)
evaluation_cache[dep_flag_key] = dep_result
except InconclusiveMatchError as e:
@@ -261,54 +215,21 @@ def matches_dependency_value(expected_value, actual_value):
return False
def resolve_bucketing_value(flag, distinct_id, device_id=None):
"""Resolve the bucketing value for a flag based on its bucketing_identifier setting.
Returns:
The appropriate identifier string to use for hashing/bucketing.
Raises:
InconclusiveMatchError: If the flag requires device_id but none was provided.
"""
flag_filters = flag.get("filters") or {}
bucketing_identifier = flag.get("bucketing_identifier") or flag_filters.get(
"bucketing_identifier"
)
if bucketing_identifier == "device_id":
if not device_id:
raise InconclusiveMatchError(
"Flag requires device_id for bucketing but none was provided"
)
return device_id
return distinct_id
def match_feature_flag_properties(
flag,
distinct_id,
properties,
*,
cohort_properties=None,
flags_by_key=None,
evaluation_cache=None,
device_id=None,
bucketing_value=None,
) -> FlagValue:
if bucketing_value is None:
warnings.warn(
"Calling match_feature_flag_properties() without bucketing_value is deprecated. "
"Pass bucketing_value explicitly. This fallback will be removed in a future major release.",
DeprecationWarning,
stacklevel=2,
)
bucketing_value = resolve_bucketing_value(flag, distinct_id, device_id)
flag_filters = flag.get("filters") or {}
flag_conditions = flag_filters.get("groups") or []
flag_conditions = (flag.get("filters") or {}).get("groups") or []
is_inconclusive = False
cohort_properties = cohort_properties or {}
# Some filters can be explicitly set to null, which require accessing variants like so
flag_variants = (flag_filters.get("multivariate") or {}).get("variants") or []
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get(
"variants"
) or []
valid_variant_keys = [variant["key"] for variant in flag_variants]
for condition in flag_conditions:
@@ -323,14 +244,12 @@ def match_feature_flag_properties(
cohort_properties,
flags_by_key,
evaluation_cache,
bucketing_value=bucketing_value,
device_id=device_id,
):
variant_override = condition.get("variant")
if variant_override and variant_override in valid_variant_keys:
variant = variant_override
else:
variant = get_matching_variant(flag, bucketing_value)
variant = get_matching_variant(flag, distinct_id)
return variant or True
except RequiresServerEvaluation:
# Static cohort or other missing server-side data - must fallback to API
@@ -358,9 +277,6 @@ def is_condition_match(
cohort_properties,
flags_by_key=None,
evaluation_cache=None,
*,
bucketing_value,
device_id=None,
) -> bool:
rollout_percentage = condition.get("rollout_percentage")
if len(condition.get("properties") or []) > 0:
@@ -374,7 +290,6 @@ def is_condition_match(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
elif property_type == "flag":
matches = evaluate_flag_dependency(
@@ -384,7 +299,6 @@ def is_condition_match(
distinct_id,
properties,
cohort_properties,
device_id=device_id,
)
else:
matches = match_property(prop, properties)
@@ -394,9 +308,9 @@ def is_condition_match(
if rollout_percentage is None:
return True
if rollout_percentage is not None and _hash(
feature_flag["key"], bucketing_value
) > (rollout_percentage / 100):
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (
rollout_percentage / 100
):
return False
return True
@@ -409,9 +323,6 @@ def match_property(property, property_values) -> bool:
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"
@@ -532,64 +443,7 @@ def match_property(property, property_values) -> bool:
"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.
# if we get here, we don't know how to handle the operator
raise InconclusiveMatchError(f"Unknown operator {operator}")
@@ -600,7 +454,6 @@ def match_cohort(
flags_by_key=None,
evaluation_cache=None,
distinct_id=None,
device_id=None,
) -> bool:
# Cohort properties are in the form of property groups like this:
# {
@@ -625,7 +478,6 @@ def match_cohort(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
@@ -636,7 +488,6 @@ def match_property_group(
flags_by_key=None,
evaluation_cache=None,
distinct_id=None,
device_id=None,
) -> bool:
if not property_group:
return True
@@ -661,7 +512,6 @@ def match_property_group(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
if property_group_type == "AND":
if not matches:
@@ -695,7 +545,6 @@ def match_property_group(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
elif prop.get("type") == "flag":
matches = evaluate_flag_dependency(
@@ -705,7 +554,6 @@ def match_property_group(
distinct_id,
property_values,
cohort_properties,
device_id=device_id,
)
else:
matches = match_property(prop, property_values)
@@ -770,75 +618,3 @@ def relative_date_parse_for_feature_flag_matching(
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)
@@ -9,11 +9,11 @@ functions) to share flag definitions and reduce API calls.
Usage:
from hanzo_insights import Insights
from hanzo_insights.flag_definition_cache import FlagDefinitionCacheProvider
from posthog import Posthog
from posthog.flag_definition_cache import FlagDefinitionCacheProvider
cache = RedisFlagDefinitionCache(redis_client, "my-team")
client = Insights(
posthog = Posthog(
"<project_api_key>",
personal_api_key="<personal_api_key>",
flag_definition_cache_provider=cache,
@@ -63,7 +63,7 @@ class FlagDefinitionCacheProvider(Protocol):
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
4. `shutdown()` - Called when the PostHog client shuts down. Release any
distributed locks and clean up resources.
Error Handling:
@@ -104,7 +104,7 @@ class FlagDefinitionCacheProvider(Protocol):
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
"""
Called after successfully receiving new flag definitions from Insights.
Called after successfully receiving new flag definitions from PostHog.
Use this to store the data in your external cache and release any
distributed locks acquired in `should_fetch_flag_definitions()`.
@@ -117,7 +117,7 @@ class FlagDefinitionCacheProvider(Protocol):
def shutdown(self) -> None:
"""
Called when the Insights client shuts down.
Called when the PostHog client shuts down.
Use this to release any distributed locks and clean up resources.
This method is called even if `should_fetch_flag_definitions()`
@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING, cast
from hanzo_insights import contexts
from hanzo_insights.client import Client
from posthog import contexts
from posthog.client import Client
try:
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
@@ -21,26 +21,25 @@ if TYPE_CHECKING:
from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
class InsightsContextMiddleware:
class PosthogContextMiddleware:
"""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`)
This middleware wraps all calls with a posthog context. It attempts to extract the following from the request headers:
- Session ID, (extracted from `X-POSTHOG-SESSION-ID`)
- Distinct ID, (extracted from `X-POSTHOG-DISTINCT-ID`)
- Request URL as $current_url
- Request Method as $request_method
The context will also auto-capture exceptions and send them to 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 context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting
`POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings. The exceptions are captured using the
global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance
The middleware behaviour is customisable through 3 additional functions:
- `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.
- `POSTHOG_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
- `POSTHOG_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked.
- `POSTHOG_MW_TAG_MAP`, which is a Callable[[Dict[str, Any]], Dict[str, Any]], which you can use to modify the tags before they're added to the context.
You can use the `INSIGHTS_MW_TAG_MAP` function to remove any default tags you don't want to capture, or override them with your own values.
You can use the `POSTHOG_MW_TAG_MAP` function to remove any default tags you don't want to capture, or override them with your own values.
Context tags are automatically included as properties on all events captured within a context, including exceptions.
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
@@ -67,48 +66,47 @@ class InsightsContextMiddleware:
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):
if hasattr(settings, "POSTHOG_MW_EXTRA_TAGS") and callable(
settings.POSTHOG_MW_EXTRA_TAGS
):
self.extra_tags = cast(
"Optional[Callable[[HttpRequest], Dict[str, Any]]]",
extra_tags,
settings.POSTHOG_MW_EXTRA_TAGS,
)
else:
self.extra_tags = None
request_filter = _get_setting("REQUEST_FILTER")
if request_filter and callable(request_filter):
if hasattr(settings, "POSTHOG_MW_REQUEST_FILTER") and callable(
settings.POSTHOG_MW_REQUEST_FILTER
):
self.request_filter = cast(
"Optional[Callable[[HttpRequest], bool]]",
request_filter,
settings.POSTHOG_MW_REQUEST_FILTER,
)
else:
self.request_filter = None
tag_map = _get_setting("TAG_MAP")
if tag_map and callable(tag_map):
if hasattr(settings, "POSTHOG_MW_TAG_MAP") and callable(
settings.POSTHOG_MW_TAG_MAP
):
self.tag_map = cast(
"Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]",
tag_map,
settings.POSTHOG_MW_TAG_MAP,
)
else:
self.tag_map = None
capture_exceptions = _get_setting("CAPTURE_EXCEPTIONS")
if isinstance(capture_exceptions, bool):
self.capture_exceptions = capture_exceptions
if hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS") and isinstance(
settings.POSTHOG_MW_CAPTURE_EXCEPTIONS, bool
):
self.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS
else:
self.capture_exceptions = True
mw_client = _get_setting("CLIENT")
if isinstance(mw_client, Client):
self.client = cast("Optional[Client]", mw_client)
if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance(
settings.POSTHOG_MW_CLIENT, Client
):
self.client = cast("Optional[Client]", settings.POSTHOG_MW_CLIENT)
else:
self.client = None
@@ -127,13 +125,13 @@ class InsightsContextMiddleware:
"""
tags = {}
# Extract session ID from X-INSIGHTS-SESSION-ID header
session_id = request.headers.get("X-INSIGHTS-SESSION-ID")
# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
if session_id:
contexts.set_context_session(session_id)
# Extract distinct ID from X-INSIGHTS-DISTINCT-ID header or request user id
distinct_id = request.headers.get("X-INSIGHTS-DISTINCT-ID") or user_id
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id
if distinct_id:
contexts.identify_context(distinct_id)
@@ -157,7 +155,7 @@ class InsightsContextMiddleware:
# Extract IP address
ip_address = request.headers.get("X-Forwarded-For")
if ip_address:
tags["$ip"] = ip_address
tags["$ip_address"] = ip_address
# Extract user agent
user_agent = request.headers.get("User-Agent")
@@ -316,6 +314,6 @@ class InsightsContextMiddleware:
if self.client:
self.client.capture_exception(exception)
else:
from hanzo_insights import capture_exception
from posthog import capture_exception
capture_exception(exception)
@@ -3,7 +3,7 @@ import logging
import re
import socket
from dataclasses import dataclass
from datetime import date, datetime, timezone
from datetime import date, datetime
from gzip import GzipFile
from io import BytesIO
from typing import Any, List, Optional, Tuple, Union
@@ -14,8 +14,8 @@ 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
from posthog.utils import remove_trailing_slash
from posthog.version import VERSION
SocketOptions = List[Tuple[int, int, Union[int, bytes]]]
@@ -137,7 +137,7 @@ def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
Configure socket options for all HTTP connections.
Example:
from hanzo_insights import set_socket_options
from posthog import set_socket_options
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
"""
global _session, _flags_session, _socket_options
@@ -159,19 +159,19 @@ def disable_connection_reuse() -> None:
_pooling_enabled = False
US_INGESTION_ENDPOINT = "https://us.i.insights.hanzo.ai"
EU_INGESTION_ENDPOINT = "https://eu.i.insights.hanzo.ai"
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
DEFAULT_HOST = US_INGESTION_ENDPOINT
USER_AGENT = "hanzo-insights-python/" + VERSION
USER_AGENT = "posthog-python/" + VERSION
def determine_server_host(host: Optional[str]) -> str:
"""Determines the server host to use."""
host_or_default = host or DEFAULT_HOST
trimmed_host = remove_trailing_slash(host_or_default)
if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com", "https://insights.hanzo.ai", "https://us.insights.hanzo.ai"):
if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com"):
return US_INGESTION_ENDPOINT
elif trimmed_host in ("https://eu.posthog.com", "https://eu.insights.hanzo.ai"):
elif trimmed_host == "https://eu.posthog.com":
return EU_INGESTION_ENDPOINT
else:
return host_or_default
@@ -187,7 +187,7 @@ def post(
**kwargs,
) -> requests.Response:
"""Post the `kwargs` to the API"""
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
body = kwargs
body["sentAt"] = datetime.now(tz=tzutc()).isoformat()
url = remove_trailing_slash(host or DEFAULT_HOST) + path
@@ -217,7 +217,7 @@ def post(
def _process_response(
res: requests.Response, success_message: str, *, return_json: bool = True
) -> Union[requests.Response, Any]:
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
if res.status_code == 200:
log.debug(success_message)
response = res.json() if return_json else res
@@ -231,35 +231,16 @@ def _process_response(
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"
"[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts"
)
raise QuotaLimitError(res.status_code, "Feature flags quota limited")
return response
retry_after = None
retry_after_header = res.headers.get("Retry-After")
if retry_after_header:
try:
retry_after = float(retry_after_header)
except (ValueError, TypeError):
try:
from email.utils import parsedate_to_datetime
retry_after = max(
0.0,
(
parsedate_to_datetime(retry_after_header)
- datetime.now(timezone.utc)
).total_seconds(),
)
except (ValueError, TypeError):
pass
try:
payload = res.json()
log.debug("received response: %s", payload)
raise APIError(res.status_code, payload["detail"], retry_after=retry_after)
raise APIError(res.status_code, payload["detail"])
except (KeyError, ValueError):
raise APIError(res.status_code, res.text, retry_after=retry_after)
raise APIError(res.status_code, res.text)
def decide(
@@ -341,7 +322,7 @@ def get(
- 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")
log = logging.getLogger("posthog")
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
@@ -367,15 +348,12 @@ def get(
class APIError(Exception):
def __init__(
self, status: Union[int, str], message: str, retry_after: Optional[float] = None
):
def __init__(self, status: Union[int, str], message: str):
self.message = message
self.status = status
self.retry_after = retry_after
def __str__(self):
msg = "[Insights] {0} ({1})"
msg = "[PostHog] {0} ({1})"
return msg.format(self.message, self.status)
@@ -6,7 +6,7 @@ import unittest
def all_names():
for _, modname, _ in pkgutil.iter_modules(__path__):
yield "hanzo_insights.test." + modname
yield "posthog.test." + modname
def all():
View File
@@ -1,14 +1,11 @@
import json
from unittest.mock import patch
import pytest
from hanzo_insights import identify_context, new_context
try:
from anthropic.types import Message, Usage
from hanzo_insights.ai.anthropic import Anthropic, AsyncAnthropic
from posthog.ai.anthropic import Anthropic, AsyncAnthropic
ANTHROPIC_AVAILABLE = True
except ImportError:
@@ -105,7 +102,7 @@ class MockDelta:
@pytest.fixture
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@@ -279,12 +276,12 @@ def test_basic_completion(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response
@@ -308,46 +305,19 @@ def test_basic_completion(mock_client, mock_anthropic_response):
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert props["$ai_tokens_source"] == "sdk"
assert isinstance(props["$ai_latency"], float)
# Verify raw usage metadata is passed for backend processing
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "input_tokens" in props["$ai_usage"]
assert "output_tokens" in props["$ai_usage"]
def test_tokens_source_passthrough(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"$ai_input_tokens": 99999},
)
props = mock_client.capture.call_args[1]["properties"]
assert props["$ai_tokens_source"] == "passthrough"
assert props["$ai_input_tokens"] == 99999
def test_groups(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_groups={"company": "test_company"},
posthog_distinct_id="test-id",
posthog_groups={"company": "test_company"},
)
assert response == mock_anthropic_response
@@ -361,12 +331,12 @@ def test_privacy_mode_local(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_privacy_mode=True,
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
assert response == mock_anthropic_response
@@ -383,12 +353,12 @@ def test_privacy_mode_global(mock_client, mock_anthropic_response):
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
mock_client.privacy_mode = True
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_privacy_mode=False,
posthog_distinct_id="test-id",
posthog_privacy_mode=False,
)
assert response == mock_anthropic_response
@@ -407,14 +377,14 @@ def test_basic_integration(mock_client):
"anthropic.resources.Messages.create",
return_value=create_mock_response(),
):
client = Anthropic(insights_client=mock_client)
client = Anthropic(posthog_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Foo"}],
max_tokens=1,
temperature=0,
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
system="You must always answer with 'Bar'.",
)
@@ -452,7 +422,7 @@ async def test_basic_async_integration(mock_client):
"anthropic.resources.messages.AsyncMessages.create",
side_effect=mock_async_create,
):
client = AsyncAnthropic(insights_client=mock_client)
client = AsyncAnthropic(posthog_client=mock_client)
await client.messages.create(
model="claude-3-opus-20240229",
messages=[
@@ -460,8 +430,8 @@ async def test_basic_async_integration(mock_client):
],
max_tokens=1,
temperature=0,
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert mock_client.capture.call_count == 1
@@ -513,7 +483,7 @@ async def test_async_streaming_system_prompt(mock_client):
"anthropic.resources.messages.AsyncMessages.create",
side_effect=async_create_wrapper,
):
client = AsyncAnthropic(insights_client=mock_client)
client = AsyncAnthropic(posthog_client=mock_client)
response = await client.messages.create(
model="claude-3-opus-20240229",
system="You must always answer with 'Bar'.",
@@ -541,7 +511,7 @@ def test_error(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", side_effect=Exception("Test error")
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
with pytest.raises(Exception):
client.messages.create(
model="claude-3-opus-20240229",
@@ -561,12 +531,12 @@ def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_with_cached_tokens,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response_with_cached_tokens
@@ -600,7 +570,7 @@ def test_tool_definition(mock_client, mock_anthropic_response):
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
tools = [
{
@@ -625,8 +595,8 @@ def test_tool_definition(mock_client, mock_anthropic_response):
temperature=0.7,
tools=tools,
messages=[{"role": "user", "content": "hey"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response
@@ -662,7 +632,7 @@ def test_tool_calls_in_output_choices(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_with_tool_calls,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
@@ -680,7 +650,7 @@ def test_tool_calls_in_output_choices(
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_anthropic_response_with_tool_calls
@@ -724,7 +694,7 @@ def test_tool_calls_only_no_content(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_tool_calls_only,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
@@ -743,7 +713,7 @@ def test_tool_calls_only_no_content(
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_anthropic_response_tool_calls_only
@@ -790,7 +760,7 @@ def test_async_tool_calls_in_output_choices(
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
async def run_test():
return await async_client.messages.create(
@@ -810,7 +780,7 @@ def test_async_tool_calls_in_output_choices(
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
response = asyncio.run(run_test())
@@ -855,7 +825,7 @@ def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools
"anthropic.resources.Messages.create",
return_value=mock_anthropic_stream_with_tools,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
system="You are a helpful weather assistant.",
@@ -877,7 +847,7 @@ def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools
}
],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the stream - this triggers the finally block synchronously
@@ -947,17 +917,6 @@ def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools
assert props["$ai_output_tokens"] == 25
assert props["$ai_cache_read_input_tokens"] == 5
assert props["$ai_cache_creation_input_tokens"] == 0
assert props["$ai_tokens_source"] == "sdk"
# Verify raw usage is captured in streaming mode (merged from events)
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure (merged from message_start and message_delta)
assert isinstance(props["$ai_usage"], dict)
assert "input_tokens" in props["$ai_usage"]
assert "output_tokens" in props["$ai_usage"]
def test_async_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools):
@@ -977,7 +936,7 @@ def test_async_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
async def run_test():
response = await async_client.messages.create(
@@ -1001,7 +960,7 @@ def test_async_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with
}
],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the async stream
@@ -1101,11 +1060,11 @@ def test_web_search_count(mock_client):
mock_response = MockResponseWithWebSearch()
with patch("anthropic.resources.Messages.create", return_value=mock_response):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1179,12 +1138,12 @@ def test_streaming_with_web_search(mock_client, mock_anthropic_stream_with_web_s
"anthropic.resources.Messages.create",
return_value=mock_anthropic_stream_with_web_search,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the stream - this triggers the finally block synchronously
@@ -1234,13 +1193,13 @@ def test_async_with_web_search(mock_client):
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
async def run_test():
response = await async_client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
return response
@@ -1278,14 +1237,14 @@ def test_async_streaming_with_web_search(
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
async def run_test():
response = await async_client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the async stream
@@ -1304,99 +1263,3 @@ def test_async_streaming_with_web_search(
assert props["$ai_web_search_count"] == 2
assert props["$ai_input_tokens"] == 50
assert props["$ai_output_tokens"] == 25
# =======================
# Distinct ID Context Tests
# =======================
def test_no_distinct_id_uses_trace_id_and_personless(
mock_client, mock_anthropic_response
):
"""When no distinct_id is provided and no outer context, trace_id is used and event is personless."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "trace-123"
assert props["$process_person_profile"] is False
def test_explicit_distinct_id_creates_person_profile(
mock_client, mock_anthropic_response
):
"""When insights_distinct_id is explicitly passed, it is used and event is not personless."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="user-123",
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "user-123"
assert (
"$process_person_profile" not in props
or props["$process_person_profile"] is not False
)
def test_outer_context_distinct_id_is_used(mock_client, mock_anthropic_response):
"""When an outer context has a distinct_id, it should be used instead of trace_id."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
with new_context():
identify_context("outer-user-456")
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "outer-user-456"
assert (
"$process_person_profile" not in props
or props["$process_person_profile"] is not False
)
def test_explicit_distinct_id_overrides_outer_context(
mock_client, mock_anthropic_response
):
"""When both outer context and explicit insights_distinct_id are set, explicit wins."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
with new_context():
identify_context("outer-user-456")
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="explicit-user-789",
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
assert call_args["distinct_id"] == "explicit-user-789"
@@ -1,4 +1,3 @@
import json
from unittest.mock import MagicMock, patch
import pytest
@@ -6,7 +5,7 @@ import pytest
try:
from google import genai as google_genai
from hanzo_insights.ai.gemini import Client
from posthog.ai.gemini import Client
GEMINI_AVAILABLE = True
except ImportError:
@@ -19,7 +18,7 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@@ -35,13 +34,6 @@ def mock_gemini_response():
# Ensure cache and reasoning tokens are not present (not MagicMock)
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
mock_usage.model_dump.return_value = {
"prompt_token_count": 20,
"candidates_token_count": 10,
"cached_content_token_count": 0,
"thoughts_token_count": 0,
}
mock_response.usage_metadata = mock_usage
mock_candidate = MagicMock()
@@ -77,13 +69,6 @@ def mock_gemini_response_with_function_calls():
mock_usage.candidates_token_count = 15
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
mock_usage.model_dump.return_value = {
"prompt_token_count": 25,
"candidates_token_count": 15,
"cached_content_token_count": 0,
"thoughts_token_count": 0,
}
mock_response.usage_metadata = mock_usage
# Mock function call
@@ -132,13 +117,6 @@ def mock_gemini_response_function_calls_only():
mock_usage.candidates_token_count = 12
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
mock_usage.model_dump.return_value = {
"prompt_token_count": 30,
"candidates_token_count": 12,
"cached_content_token_count": 0,
"thoughts_token_count": 0,
}
mock_response.usage_metadata = mock_usage
# Mock function call
@@ -172,13 +150,13 @@ def test_new_client_basic_generation(
"""Test the new Client/Models API structure"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = 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"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_gemini_response
@@ -196,15 +174,6 @@ def test_new_client_basic_generation(
assert props["foo"] == "bar"
assert "$ai_trace_id" in props
assert props["$ai_latency"] > 0
# Verify raw usage metadata is passed for backend processing
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "prompt_token_count" in props["$ai_usage"]
assert "candidates_token_count" in props["$ai_usage"]
def test_new_client_streaming_with_generate_content_stream(
@@ -239,13 +208,13 @@ def test_new_client_streaming_with_generate_content_stream(
mock_streaming_response()
)
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content_stream(
model="gemini-2.0-flash",
contents=["Write a short story"],
insights_distinct_id="test-id",
insights_properties={"feature": "streaming"},
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming"},
)
chunks = list(response)
@@ -298,7 +267,7 @@ def test_new_client_streaming_with_tools(mock_client, mock_google_genai_client):
mock_streaming_response()
)
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
# Create mock tools configuration
mock_tool = MagicMock()
@@ -326,8 +295,8 @@ def test_new_client_streaming_with_tools(mock_client, mock_google_genai_client):
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"},
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming_with_tools"},
)
chunks = list(response)
@@ -357,13 +326,13 @@ def test_new_client_groups(mock_client, mock_google_genai_client, mock_gemini_re
"""Test groups functionality with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
insights_groups={"company": "company_123"},
posthog_distinct_id="test-id",
posthog_groups={"company": "company_123"},
)
call_args = mock_client.capture.call_args[1]
@@ -376,13 +345,13 @@ def test_new_client_privacy_mode_local(
"""Test local privacy mode with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
insights_privacy_mode=True,
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
call_args = mock_client.capture.call_args[1]
@@ -399,12 +368,12 @@ def test_new_client_privacy_mode_global(
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
@@ -419,11 +388,11 @@ def test_new_client_different_input_formats(
"""Test different input formats with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
# Test string input
client.models.generate_content(
model="gemini-2.0-flash", contents="Hello", insights_distinct_id="test-id"
model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -434,7 +403,7 @@ def test_new_client_different_input_formats(
client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "hey"}]}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -447,7 +416,7 @@ def test_new_client_different_input_formats(
client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "Hello "}, {"text": "world"}]}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -464,7 +433,7 @@ def test_new_client_different_input_formats(
# Test list input with string
mock_client.capture.reset_mock()
client.models.generate_content(
model="gemini-2.0-flash", contents=["List item"], insights_distinct_id="test-id"
model="gemini-2.0-flash", contents=["List item"], posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -477,12 +446,12 @@ def test_new_client_model_parameters(
"""Test model parameters with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
temperature=0.7,
max_tokens=100,
)
@@ -496,16 +465,16 @@ def test_new_client_model_parameters(
def test_new_client_default_settings(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test client with default Insights settings"""
"""Test client with default PostHog settings"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(
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"},
posthog_client=mock_client,
posthog_distinct_id="default_user",
posthog_properties={"team": "ai"},
posthog_privacy_mode=False,
posthog_groups={"company": "acme_corp"},
)
# Call without overriding defaults
@@ -527,21 +496,21 @@ def test_new_client_override_defaults(
client = Client(
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"},
posthog_client=mock_client,
posthog_distinct_id="default_user",
posthog_properties={"team": "ai"},
posthog_privacy_mode=False,
posthog_groups={"company": "acme_corp"},
)
# Override defaults in call
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"},
posthog_distinct_id="specific_user",
posthog_properties={"feature": "chat", "urgent": True},
posthog_privacy_mode=True,
posthog_groups={"organization": "special_org"},
)
call_args = mock_client.capture.call_args[1]
@@ -577,7 +546,7 @@ def test_vertex_ai_parameters_passed_through(
location="us-central1",
debug_config=mock_debug_config,
http_options=mock_http_options,
insights_client=mock_client,
posthog_client=mock_client,
)
# Verify genai.Client was called with correct parameters
@@ -597,7 +566,7 @@ def test_api_key_mode(mock_client, mock_google_genai_client):
# Create client with just API key (traditional mode)
Client(
api_key="test-api-key",
insights_client=mock_client,
posthog_client=mock_client,
)
# Verify genai.Client was called with only api_key
@@ -618,7 +587,7 @@ def test_vertex_ai_mode_with_optional_api_key(
api_key="test-api-key",
credentials=mock_credentials,
project="test-project",
insights_client=mock_client,
posthog_client=mock_client,
)
# Verify genai.Client was called with both Vertex AI params and API key
@@ -634,7 +603,7 @@ def test_tool_use_response(mock_client, mock_google_genai_client, mock_gemini_re
"""Test that tools defined in config are captured in $ai_tools property"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
# Create mock tools configuration
mock_tool = MagicMock()
@@ -664,8 +633,8 @@ def test_tool_use_response(mock_client, mock_google_genai_client, mock_gemini_re
model="gemini-2.5-flash",
contents=["hey"],
config=mock_config,
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_gemini_response
@@ -702,12 +671,12 @@ def test_function_calls_in_output_choices(
mock_gemini_response_with_function_calls
)
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=["What's the weather in San Francisco?"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_with_function_calls
@@ -751,12 +720,12 @@ def test_function_calls_only_no_content(
mock_gemini_response_function_calls_only
)
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=["Get weather for New York"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_function_calls_only
@@ -810,12 +779,12 @@ def test_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-pro",
contents="Test with cache",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -841,13 +810,6 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
chunk1_usage.candidates_token_count = 5
chunk1_usage.cached_content_token_count = 30 # Cache tokens
chunk1_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
chunk1_usage.model_dump.return_value = {
"prompt_token_count": 100,
"candidates_token_count": 5,
"cached_content_token_count": 30,
"thoughts_token_count": 0,
}
chunk1.usage_metadata = chunk1_usage
chunk2 = MagicMock()
@@ -857,31 +819,24 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
chunk2_usage.candidates_token_count = 10
chunk2_usage.cached_content_token_count = 30 # Same cache tokens
chunk2_usage.thoughts_token_count = 5 # Reasoning tokens
# Make model_dump() return a proper dict for serialization
chunk2_usage.model_dump.return_value = {
"prompt_token_count": 100,
"candidates_token_count": 10,
"cached_content_token_count": 30,
"thoughts_token_count": 5,
}
chunk2.usage_metadata = chunk2_usage
mock_stream = iter([chunk1, chunk2])
mock_google_genai_client.models.generate_content_stream.return_value = mock_stream
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content_stream(
model="gemini-2.5-pro",
contents="Test streaming with cache",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the stream
result = list(response)
assert len(result) == 2
# Check Insights capture was called
# Check PostHog capture was called
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
@@ -893,16 +848,6 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
assert props["$ai_cache_read_input_tokens"] == 30
assert props["$ai_reasoning_tokens"] == 5
# Verify raw usage is captured in streaming mode (merged from chunks)
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "prompt_token_count" in props["$ai_usage"]
assert "candidates_token_count" in props["$ai_usage"]
def test_web_search_grounding(mock_client, mock_google_genai_client):
"""Test web search detection via grounding_metadata."""
@@ -946,11 +891,11 @@ def test_web_search_grounding(mock_client, mock_google_genai_client):
# Mock the generate_content method
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="What's the latest news?",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1015,12 +960,12 @@ def test_streaming_with_web_search(mock_client, mock_google_genai_client):
mock_streaming_response()
)
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content_stream(
model="gemini-2.5-flash",
contents="What's the latest news?",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
chunks = list(response)
@@ -1080,12 +1025,12 @@ def test_empty_grounding_metadata_no_web_search(mock_client, mock_google_genai_c
# Mock the generate_content method
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Hello",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1143,12 +1088,12 @@ def test_empty_array_grounding_metadata_no_web_search(
# Mock the generate_content method
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="What can you do?",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -5,7 +5,7 @@ import pytest
try:
from google import genai as google_genai
from hanzo_insights.ai.gemini import AsyncClient
from posthog.ai.gemini import AsyncClient
GEMINI_AVAILABLE = True
except ImportError:
@@ -21,7 +21,7 @@ pytestmark = [
@pytest.fixture
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@@ -121,13 +121,13 @@ async def test_async_client_basic_generation(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Tell me a fun fact about hedgehogs"],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_gemini_response
@@ -178,13 +178,13 @@ async def test_async_client_streaming_with_generate_content_stream(
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content_stream(
model="gemini-2.0-flash",
contents=["Write a short story"],
insights_distinct_id="test-id",
insights_properties={"feature": "streaming"},
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming"},
)
chunks = []
@@ -239,7 +239,7 @@ async def test_async_client_streaming_with_tools(mock_client, mock_google_genai_
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
# Create mock tools configuration
mock_tool = MagicMock()
@@ -267,8 +267,8 @@ async def test_async_client_streaming_with_tools(mock_client, mock_google_genai_
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"},
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming_with_tools"},
)
chunks = []
@@ -305,13 +305,13 @@ async def test_async_client_groups(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_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"},
posthog_distinct_id="test-id",
posthog_groups={"company": "company_123"},
)
call_args = mock_client.capture.call_args[1]
@@ -326,13 +326,13 @@ async def test_async_client_privacy_mode_local(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
insights_privacy_mode=True,
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
call_args = mock_client.capture.call_args[1]
@@ -351,12 +351,12 @@ async def test_async_client_privacy_mode_global(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
@@ -373,11 +373,11 @@ async def test_async_client_different_input_formats(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
# Test string input
await client.models.generate_content(
model="gemini-2.0-flash", contents="Hello", insights_distinct_id="test-id"
model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -388,7 +388,7 @@ async def test_async_client_different_input_formats(
await client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "hey"}]}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -401,7 +401,7 @@ async def test_async_client_different_input_formats(
await client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "Hello "}, {"text": "world"}]}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -418,7 +418,7 @@ async def test_async_client_different_input_formats(
# 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"
model="gemini-2.0-flash", contents=["List item"], posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -433,12 +433,12 @@ async def test_async_client_model_parameters(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
temperature=0.7,
max_tokens=100,
)
@@ -452,18 +452,18 @@ async def test_async_client_model_parameters(
async def test_async_client_default_settings(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test async client with default Insights settings"""
"""Test async client with default PostHog settings"""
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response
)
client = AsyncClient(
api_key="test-key",
insights_client=mock_client,
insights_distinct_id="default_user",
insights_properties={"team": "ai"},
insights_privacy_mode=False,
insights_groups={"company": "acme_corp"},
posthog_client=mock_client,
posthog_distinct_id="default_user",
posthog_properties={"team": "ai"},
posthog_privacy_mode=False,
posthog_groups={"company": "acme_corp"},
)
# Call without overriding defaults
@@ -487,21 +487,21 @@ async def test_async_client_override_defaults(
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"},
posthog_client=mock_client,
posthog_distinct_id="default_user",
posthog_properties={"team": "ai"},
posthog_privacy_mode=False,
posthog_groups={"company": "acme_corp"},
)
# Override defaults in call
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="specific_user",
insights_properties={"feature": "chat", "urgent": True},
insights_privacy_mode=True,
insights_groups={"organization": "special_org"},
posthog_distinct_id="specific_user",
posthog_properties={"feature": "chat", "urgent": True},
posthog_privacy_mode=True,
posthog_groups={"organization": "special_org"},
)
call_args = mock_client.capture.call_args[1]
@@ -539,7 +539,7 @@ async def test_async_vertex_ai_parameters_passed_through(
location="us-central1",
debug_config=mock_debug_config,
http_options=mock_http_options,
insights_client=mock_client,
posthog_client=mock_client,
)
# Verify genai.Client was called with correct parameters
@@ -559,7 +559,7 @@ async def test_async_api_key_mode(mock_client, mock_google_genai_client):
# Create async client with just API key (traditional mode)
AsyncClient(
api_key="test-api-key",
insights_client=mock_client,
posthog_client=mock_client,
)
# Verify genai.Client was called with only api_key
@@ -574,12 +574,12 @@ async def test_async_function_calls_in_output_choices(
return_value=mock_gemini_response_with_function_calls
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content(
model="gemini-2.5-flash",
contents=["What's the weather in San Francisco?"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_with_function_calls
@@ -637,12 +637,12 @@ async def test_async_cache_and_reasoning_tokens(mock_client, mock_google_genai_c
return_value=mock_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content(
model="gemini-2.5-pro",
contents="Test with cache",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -689,12 +689,12 @@ async def test_async_streaming_cache_and_reasoning_tokens(
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content_stream(
model="gemini-2.5-pro",
contents="Test streaming with cache",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the stream
@@ -704,7 +704,7 @@ async def test_async_streaming_cache_and_reasoning_tokens(
assert len(result) == 2
# Check Insights capture was called
# Check PostHog capture was called
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
@@ -761,11 +761,11 @@ async def test_async_web_search_grounding(mock_client, mock_google_genai_client)
return_value=mock_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content(
model="gemini-2.5-flash",
contents="What's the latest news?",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -829,12 +829,12 @@ async def test_async_streaming_with_web_search(mock_client, mock_google_genai_cl
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content_stream(
model="gemini-2.5-flash",
contents="What's the latest news?",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
chunks = []
@@ -21,8 +21,8 @@ try:
from langgraph.graph.state import END, START, StateGraph
from langgraph.prebuilt import create_react_agent
from hanzo_insights.ai.langchain import CallbackHandler
from hanzo_insights.ai.langchain.callbacks import GenerationMetadata, SpanMetadata
from posthog.ai.langchain import CallbackHandler
from posthog.ai.langchain.callbacks import GenerationMetadata, SpanMetadata
LANGCHAIN_AVAILABLE = True
except ImportError:
@@ -53,9 +53,9 @@ ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
@pytest.fixture(scope="function")
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
logging.getLogger("hanzo_insights").setLevel(logging.DEBUG)
logging.getLogger("posthog").setLevel(logging.DEBUG)
yield mock_client
@@ -101,7 +101,7 @@ def test_metadata_capture(mock_client):
run_id,
messages=[{"role": "user", "content": "Who won the world series in 2020?"}],
invocation_params={"temperature": 0.5},
metadata={"ls_model_name": "hog-mini", "ls_provider": "hanzo_insights"},
metadata={"ls_model_name": "hog-mini", "ls_provider": "posthog"},
name="test",
)
expected = GenerationMetadata(
@@ -109,11 +109,11 @@ def test_metadata_capture(mock_client):
input=[{"role": "user", "content": "Who won the world series in 2020?"}],
start_time=1234567890,
model_params={"temperature": 0.5},
provider="hanzo_insights",
provider="posthog",
base_url="https://us.posthog.com",
name="test",
end_time=None,
insights_properties=None,
posthog_properties=None,
)
assert callbacks._runs[run_id] == expected
with patch("time.time", return_value=1234567891):
@@ -1049,7 +1049,7 @@ def test_base_url_retrieval(mock_client):
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
chain = prompt | ChatOpenAI(
api_key="test",
model="insights-mini",
model="posthog-mini",
base_url="https://test.posthog.com",
)
callbacks = CallbackHandler(mock_client)
@@ -1257,7 +1257,7 @@ def test_metadata_tools(mock_client):
run_id,
messages=[{"role": "user", "content": "What's the weather like in SF?"}],
invocation_params={"temperature": 0.5, "tools": tools},
metadata={"ls_model_name": "hog-mini", "ls_provider": "hanzo_insights"},
metadata={"ls_model_name": "hog-mini", "ls_provider": "posthog"},
name="test",
)
expected = GenerationMetadata(
@@ -1265,12 +1265,12 @@ def test_metadata_tools(mock_client):
input=[{"role": "user", "content": "What's the weather like in SF?"}],
start_time=1234567890,
model_params={"temperature": 0.5},
provider="hanzo_insights",
provider="posthog",
base_url="https://us.posthog.com",
name="test",
tools=tools,
end_time=None,
insights_properties=None,
posthog_properties=None,
)
assert callbacks._runs[run_id] == expected
with patch("time.time", return_value=1234567891):
@@ -1867,8 +1867,8 @@ def test_openai_reasoning_tokens_o4_mini(mock_client):
def test_callback_handler_without_client():
"""Test that CallbackHandler works properly when no Insights client is passed."""
with patch("hanzo_insights.ai.langchain.callbacks.setup") as mock_setup:
"""Test that CallbackHandler works properly when no PostHog client is passed."""
with patch("posthog.ai.langchain.callbacks.setup") as mock_setup:
mock_client = mock_setup.return_value
callbacks = CallbackHandler()
@@ -1894,7 +1894,7 @@ def test_callback_handler_without_client():
def test_convert_message_to_dict_tool_calls():
"""Test that _convert_message_to_dict properly converts tool calls in AIMessage."""
from hanzo_insights.ai.langchain.callbacks import _convert_message_to_dict
from posthog.ai.langchain.callbacks import _convert_message_to_dict
from langchain_core.messages import AIMessage
from langchain_core.messages.tool import ToolCall
@@ -1984,7 +1984,7 @@ def test_tool_definition(mock_client):
assert run == expected
assert callbacks._runs == {}
# Now test that the tools are properly captured in the Insights event
# Now test that the tools are properly captured in the PostHog event
mock_response = MagicMock()
mock_response.generations = [[MagicMock()]]
@@ -2236,7 +2236,7 @@ def test_agent_action_and_finish_imports():
from langchain.schema.agent import AgentAction, AgentFinish # type: ignore
# Verify they're available in the callbacks module
from hanzo_insights.ai.langchain.callbacks import CallbackHandler
from posthog.ai.langchain.callbacks import CallbackHandler
# Test on_agent_action with mock data
mock_client = MagicMock()
@@ -2266,8 +2266,8 @@ def test_agent_action_and_finish_imports():
assert call_args["event"] == "$ai_span"
def test_insights_properties_field_in_generation_metadata(mock_client):
"""Test that insights_properties is properly stored in GenerationMetadata."""
def test_posthog_properties_field_in_generation_metadata(mock_client):
"""Test that posthog_properties is properly stored in GenerationMetadata."""
callbacks = CallbackHandler(mock_client)
run_id = uuid.uuid4()
@@ -2281,7 +2281,7 @@ def test_insights_properties_field_in_generation_metadata(mock_client):
metadata={
"ls_model_name": "gpt-4o",
"ls_provider": "openai",
"insights_properties": {"$ai_billable": True},
"posthog_properties": {"$ai_billable": True},
},
name="test",
)
@@ -2294,11 +2294,11 @@ def test_insights_properties_field_in_generation_metadata(mock_client):
provider="openai",
base_url="https://api.openai.com",
name="test",
insights_properties={"$ai_billable": True},
posthog_properties={"$ai_billable": True},
end_time=None,
)
assert callbacks._runs[run_id] == expected
assert callbacks._runs[run_id].insights_properties == {"$ai_billable": True}
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
callbacks._pop_run_metadata(run_id)
@@ -2313,15 +2313,15 @@ def test_insights_properties_field_in_generation_metadata(mock_client):
metadata={
"ls_model_name": "gpt-4o",
"ls_provider": "openai",
"insights_properties": {"$ai_billable": False},
"posthog_properties": {"$ai_billable": False},
},
name="test",
)
assert callbacks._runs[run_id2].insights_properties == {"$ai_billable": False}
assert callbacks._runs[run_id2].posthog_properties == {"$ai_billable": False}
callbacks._pop_run_metadata(run_id2)
# Test when insights_properties not provided
# Test when posthog_properties not provided
run_id3 = uuid.uuid4()
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
@@ -2333,7 +2333,7 @@ def test_insights_properties_field_in_generation_metadata(mock_client):
name="test",
)
assert callbacks._runs[run_id3].insights_properties is None
assert callbacks._runs[run_id3].posthog_properties is None
def test_billable_property_in_generation_event(mock_client):
@@ -2349,7 +2349,7 @@ def test_billable_property_in_generation_event(mock_client):
run_id,
messages=[{"role": "user", "content": "Test"}],
metadata={
"insights_properties": {"$ai_billable": True},
"posthog_properties": {"$ai_billable": True},
"ls_model_name": "test-model",
},
invocation_params={},
@@ -2412,12 +2412,12 @@ def test_billable_with_real_chain(mock_client):
metadata={
"ls_model_name": "fake-model",
"ls_provider": "fake",
"insights_properties": {"$ai_billable": True},
"posthog_properties": {"$ai_billable": True},
},
invocation_params={"temperature": 0.7},
)
assert callbacks._runs[run_id].insights_properties == {"$ai_billable": True}
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
mock_response = MagicMock()
mock_response.generations = [[MagicMock()]]
@@ -1,4 +1,3 @@
import json
import time
from unittest.mock import AsyncMock, patch
@@ -34,8 +33,8 @@ try:
ParsedResponseOutputText,
)
from hanzo_insights.ai.openai import OpenAI
from hanzo_insights.ai.openai.openai_async import AsyncOpenAI
from posthog.ai.openai import OpenAI
from posthog.ai.openai.openai_async import AsyncOpenAI
OPENAI_AVAILABLE = True
except ImportError:
@@ -49,7 +48,7 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@@ -467,12 +466,12 @@ def test_basic_completion(mock_client, mock_openai_response):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response
@@ -497,15 +496,6 @@ def test_basic_completion(mock_client, mock_openai_response):
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify raw usage metadata is passed for backend processing
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "prompt_tokens" in props["$ai_usage"]
assert "completion_tokens" in props["$ai_usage"]
def test_embeddings(mock_client, mock_embedding_response):
@@ -513,12 +503,12 @@ def test_embeddings(mock_client, mock_embedding_response):
"openai.resources.embeddings.Embeddings.create",
return_value=mock_embedding_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.embeddings.create(
model="text-embedding-3-small",
input="Hello world",
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_embedding_response
@@ -543,12 +533,12 @@ def test_groups(mock_client, mock_openai_response):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_groups={"company": "test_company"},
posthog_distinct_id="test-id",
posthog_groups={"company": "test_company"},
)
assert response == mock_openai_response
@@ -564,12 +554,12 @@ def test_privacy_mode_local(mock_client, mock_openai_response):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_privacy_mode=True,
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
assert response == mock_openai_response
@@ -587,12 +577,12 @@ def test_privacy_mode_global(mock_client, mock_openai_response):
return_value=mock_openai_response,
):
mock_client.privacy_mode = True
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_privacy_mode=False,
posthog_distinct_id="test-id",
posthog_privacy_mode=False,
)
assert response == mock_openai_response
@@ -609,7 +599,7 @@ def test_error(mock_client, mock_openai_response):
"openai.resources.chat.completions.Completions.create",
side_effect=Exception("Test error"),
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
with pytest.raises(Exception):
client.chat.completions.create(
model="gpt-4", messages=[{"role": "user", "content": "Hello"}]
@@ -628,12 +618,12 @@ def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response_with_cached_tokens,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response_with_cached_tokens
@@ -666,7 +656,7 @@ def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response_with_tool_calls,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[
@@ -682,7 +672,7 @@ def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_openai_response_with_tool_calls
@@ -739,7 +729,7 @@ def test_tool_calls_only_no_content(mock_client, mock_openai_response_tool_calls
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response_tool_calls_only,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Get weather for New York"}],
@@ -753,7 +743,7 @@ def test_tool_calls_only_no_content(mock_client, mock_openai_response_tool_calls
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_openai_response_tool_calls_only
@@ -793,7 +783,7 @@ def test_responses_api_tool_calls(mock_client, mock_responses_api_with_tool_call
"openai.resources.responses.Responses.create",
return_value=mock_responses_api_with_tool_calls,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.create(
model="gpt-4o-mini",
input=[{"role": "user", "content": "What's the weather in Chicago?"}],
@@ -808,7 +798,7 @@ def test_responses_api_tool_calls(mock_client, mock_responses_api_with_tool_call
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_responses_api_with_tool_calls
@@ -851,7 +841,7 @@ def test_streaming_with_tool_calls(mock_client, streaming_tool_call_chunks):
# Set up the mock to return our chunks when iterated
mock_create.return_value = streaming_tool_call_chunks
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Call the streaming method
response_generator = client.chat.completions.create(
@@ -870,7 +860,7 @@ def test_streaming_with_tool_calls(mock_client, streaming_tool_call_chunks):
}
],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the generator to trigger the event capture
@@ -932,16 +922,6 @@ def test_streaming_with_tool_calls(mock_client, streaming_tool_call_chunks):
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
# Verify raw usage is captured in streaming mode
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure (merged from chunks)
assert isinstance(props["$ai_usage"], dict)
assert "prompt_tokens" in props["$ai_usage"]
assert "completion_tokens" in props["$ai_usage"]
# test responses api
def test_responses_api(mock_client, mock_openai_response_with_responses_api):
@@ -949,12 +929,12 @@ def test_responses_api(mock_client, mock_openai_response_with_responses_api):
"openai.resources.responses.Responses.create",
return_value=mock_openai_response_with_responses_api,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.create(
model="gpt-4o-mini",
input="Hello",
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response_with_responses_api
assert mock_client.capture.call_count == 1
@@ -986,7 +966,7 @@ def test_responses_parse(mock_client, mock_parsed_response):
"openai.resources.responses.Responses.parse",
return_value=mock_parsed_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.parse(
model="gpt-4o-2024-08-06",
input=[
@@ -1016,8 +996,8 @@ def test_responses_parse(mock_client, mock_parsed_response):
},
}
},
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_parsed_response
@@ -1101,15 +1081,15 @@ def test_responses_api_streaming_with_tokens(mock_client):
"openai.resources.responses.Responses.create",
side_effect=mock_streaming_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Consume the streaming response
response = client.responses.create(
model="gpt-4o-mini",
input=[{"role": "user", "content": "Test message"}],
stream=True,
insights_distinct_id="test-id",
insights_properties={"test": "streaming"},
posthog_distinct_id="test-id",
posthog_properties={"test": "streaming"},
)
# Consume all chunks
@@ -1153,7 +1133,7 @@ async def test_async_chat_streaming_with_tool_calls(
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.chat.completions.create(
model="gpt-4",
@@ -1171,7 +1151,7 @@ async def test_async_chat_streaming_with_tool_calls(
}
],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
chunks = []
@@ -1239,14 +1219,14 @@ async def test_async_responses_streaming_with_tokens(mock_client):
return chunk_iterable()
with patch("openai.resources.responses.AsyncResponses.create", new=mock_create):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.responses.create(
model="gpt-4o-mini",
input=[{"role": "user", "content": "Test message"}],
stream=True,
insights_distinct_id="test-id",
insights_properties={"test": "streaming"},
posthog_distinct_id="test-id",
posthog_properties={"test": "streaming"},
)
async for _ in response_stream:
@@ -1274,13 +1254,13 @@ async def test_async_embeddings_create(mock_client, mock_embedding_response):
mock_create = AsyncMock(return_value=mock_embedding_response)
with patch("openai.resources.embeddings.AsyncEmbeddings.create", new=mock_create):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response = await client.embeddings.create(
model="text-embedding-3-small",
input="Hello world",
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_embedding_response
@@ -1304,7 +1284,7 @@ def test_tool_definition(mock_client, mock_openai_response):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Define tools to be passed to the create function
tools = [
@@ -1331,8 +1311,8 @@ def test_tool_definition(mock_client, mock_openai_response):
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hey"}],
tools=tools,
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response
@@ -1392,11 +1372,11 @@ def test_web_search_perplexity_style(mock_client):
mock_response = MockResponseWithAnnotations()
with patch("openai.resources.chat.Completions.create", return_value=mock_response):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4-turbo",
messages=[{"role": "user", "content": "What's happening in tech?"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1439,19 +1419,19 @@ def test_web_search_responses_api(mock_client):
"openai.resources.responses.Responses.create", return_value=mock_response
):
# Manually call the tracking since we're testing the converter logic
from hanzo_insights.ai.utils import call_llm_and_track_usage
from posthog.ai.utils import call_llm_and_track_usage
def mock_create_call(**kwargs):
return mock_response
result = call_llm_and_track_usage(
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
ph_client=mock_client,
provider="openai",
insights_trace_id=None,
insights_properties=None,
insights_privacy_mode=False,
insights_groups=None,
posthog_trace_id=None,
posthog_properties=None,
posthog_privacy_mode=False,
posthog_groups=None,
base_url="https://api.openai.com/v1",
call_method=mock_create_call,
model="gpt-4o",
@@ -1533,12 +1513,12 @@ def test_streaming_with_web_search(mock_client, streaming_web_search_chunks):
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = streaming_web_search_chunks
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the generator to trigger the event capture
@@ -1569,12 +1549,12 @@ def test_streaming_with_web_search_on_non_usage_chunk(
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = streaming_web_search_chunks
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the generator to trigger the event capture
@@ -1629,12 +1609,12 @@ async def test_async_chat_with_web_search(mock_client):
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response = await client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1672,13 +1652,13 @@ async def test_async_chat_streaming_with_web_search(
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
chunks = []
@@ -1742,13 +1722,13 @@ def test_streaming_chat_extracts_model_from_chunk_when_not_in_kwargs(mock_client
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = chunks
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs - simulates stored prompt usage
response_generator = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the generator
@@ -1788,13 +1768,13 @@ def test_streaming_chat_prefers_kwargs_model_over_chunk_model(mock_client):
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = chunks
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
model="gpt-4o-from-kwargs", # Explicitly passed model
messages=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
list(response_generator)
@@ -1839,13 +1819,13 @@ def test_streaming_responses_api_extracts_model_from_response_object(mock_client
with patch("openai.resources.responses.Responses.create") as mock_create:
mock_create.return_value = iter(chunks)
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model - simulates stored prompt
response_generator = client.responses.create(
input=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
list(response_generator)
@@ -1886,12 +1866,12 @@ def test_non_streaming_extracts_model_from_response(mock_client):
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs
response = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1948,12 +1928,12 @@ def test_non_streaming_responses_api_extracts_model_from_response(mock_client):
"openai.resources.responses.Responses.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs
response = client.responses.create(
input="Hello",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1995,12 +1975,12 @@ def test_non_streaming_returns_none_when_no_model(mock_client):
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs and response has no model
client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
@@ -2032,12 +2012,12 @@ def test_streaming_falls_back_to_unknown_when_no_model(mock_client):
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = [chunk]
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
list(response_generator)
@@ -2083,13 +2063,13 @@ async def test_async_streaming_chat_extracts_model_from_chunk(mock_client):
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model
response_stream = await client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
async for _ in response_stream:
@@ -2137,12 +2117,12 @@ async def test_async_streaming_responses_extracts_model_from_response(mock_clien
return chunk_iterable()
with patch("openai.resources.responses.AsyncResponses.create", new=mock_create):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.responses.create(
input=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
async for _ in response_stream:
@@ -1,7 +1,7 @@
import os
import unittest
from hanzo_insights.ai.sanitization import (
from posthog.ai.sanitization import (
redact_base64_data_url,
sanitize_openai,
sanitize_openai_response,
@@ -69,25 +69,6 @@ class TestSanitization(unittest.TestCase):
)
self.assertEqual(result[0]["content"][1]["image_url"]["detail"], "high")
def test_sanitize_openai_input_image(self):
input_data = [
{
"role": "user",
"content": [
{
"type": "input_image",
"image_url": self.sample_base64_image,
}
],
}
]
result = sanitize_openai(input_data)
self.assertEqual(
result[0]["content"][0]["image_url"], REDACTED_IMAGE_PLACEHOLDER
)
def test_sanitize_openai_preserves_regular_urls(self):
input_data = [
{
@@ -13,8 +13,8 @@ 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
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
class TestSystemPromptCapture(unittest.TestCase):
@@ -26,7 +26,7 @@ class TestSystemPromptCapture(unittest.TestCase):
self.test_user_message = "Hello, how are you?"
self.test_response = "I'm doing well, thank you!"
# Create mock Insights client
# Create mock PostHog client
self.client = Client(FAKE_TEST_API_KEY)
self.client._enqueue = MagicMock()
self.client.privacy_mode = False
@@ -61,7 +61,7 @@ class TestSystemPromptCapture(unittest.TestCase):
from openai.types.chat.chat_completion import Choice
from openai.types.completion_usage import CompletionUsage
from hanzo_insights.ai.openai import OpenAI
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
@@ -88,7 +88,7 @@ class TestSystemPromptCapture(unittest.TestCase):
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(insights_client=self.client, api_key="test")
client = OpenAI(posthog_client=self.client, api_key="test")
messages = [
{"role": "system", "content": self.test_system_prompt},
@@ -96,7 +96,7 @@ class TestSystemPromptCapture(unittest.TestCase):
]
client.chat.completions.create(
model="gpt-4", messages=messages, insights_distinct_id="test-user"
model="gpt-4", messages=messages, posthog_distinct_id="test-user"
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -110,7 +110,7 @@ class TestSystemPromptCapture(unittest.TestCase):
from openai.types.chat.chat_completion import Choice
from openai.types.completion_usage import CompletionUsage
from hanzo_insights.ai.openai import OpenAI
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
@@ -137,7 +137,7 @@ class TestSystemPromptCapture(unittest.TestCase):
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(insights_client=self.client, api_key="test")
client = OpenAI(posthog_client=self.client, api_key="test")
messages = [{"role": "user", "content": self.test_user_message}]
@@ -145,7 +145,7 @@ class TestSystemPromptCapture(unittest.TestCase):
model="gpt-4",
messages=messages,
system=self.test_system_prompt,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -162,7 +162,7 @@ class TestSystemPromptCapture(unittest.TestCase):
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
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
@@ -201,7 +201,7 @@ class TestSystemPromptCapture(unittest.TestCase):
"openai.resources.chat.completions.Completions.create",
return_value=[chunk1, chunk2],
):
client = OpenAI(insights_client=self.client, api_key="test")
client = OpenAI(posthog_client=self.client, api_key="test")
messages = [{"role": "user", "content": self.test_user_message}]
@@ -210,7 +210,7 @@ class TestSystemPromptCapture(unittest.TestCase):
messages=messages,
system=self.test_system_prompt,
stream=True,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
list(response_generator) # Consume generator
@@ -223,7 +223,7 @@ class TestSystemPromptCapture(unittest.TestCase):
def test_anthropic_messages_array_system_prompt(self):
"""Test Anthropic with system prompt in messages array."""
try:
from hanzo_insights.ai.anthropic import Anthropic
from posthog.ai.anthropic import Anthropic
except ImportError:
self.skipTest("Anthropic package not available")
@@ -235,7 +235,7 @@ class TestSystemPromptCapture(unittest.TestCase):
mock_response.usage.cache_creation_input_tokens = None
mock_create.return_value = mock_response
client = Anthropic(insights_client=self.client, api_key="test")
client = Anthropic(posthog_client=self.client, api_key="test")
messages = [
{"role": "system", "content": self.test_system_prompt},
@@ -245,7 +245,7 @@ class TestSystemPromptCapture(unittest.TestCase):
client.messages.create(
model="claude-3-5-sonnet-20241022",
messages=messages,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -255,7 +255,7 @@ class TestSystemPromptCapture(unittest.TestCase):
def test_anthropic_separate_system_parameter(self):
"""Test Anthropic with system prompt as separate parameter."""
try:
from hanzo_insights.ai.anthropic import Anthropic
from posthog.ai.anthropic import Anthropic
except ImportError:
self.skipTest("Anthropic package not available")
@@ -267,7 +267,7 @@ class TestSystemPromptCapture(unittest.TestCase):
mock_response.usage.cache_creation_input_tokens = None
mock_create.return_value = mock_response
client = Anthropic(insights_client=self.client, api_key="test")
client = Anthropic(posthog_client=self.client, api_key="test")
messages = [{"role": "user", "content": self.test_user_message}]
@@ -275,7 +275,7 @@ class TestSystemPromptCapture(unittest.TestCase):
model="claude-3-5-sonnet-20241022",
messages=messages,
system=self.test_system_prompt,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -286,7 +286,7 @@ class TestSystemPromptCapture(unittest.TestCase):
def test_gemini_contents_array_system_prompt(self):
"""Test Gemini with system prompt in contents array."""
try:
from hanzo_insights.ai.gemini import Client
from posthog.ai.gemini import Client
except ImportError:
self.skipTest("Gemini package not available")
@@ -306,7 +306,7 @@ class TestSystemPromptCapture(unittest.TestCase):
mock_client_instance.models = mock_models_instance
mock_genai_class.return_value = mock_client_instance
client = Client(insights_client=self.client, api_key="test")
client = Client(posthog_client=self.client, api_key="test")
contents = [
{"role": "system", "content": self.test_system_prompt},
@@ -316,7 +316,7 @@ class TestSystemPromptCapture(unittest.TestCase):
client.models.generate_content(
model="gemini-2.0-flash",
contents=contents,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -326,7 +326,7 @@ class TestSystemPromptCapture(unittest.TestCase):
def test_gemini_system_instruction_parameter(self):
"""Test Gemini with system_instruction in config parameter."""
try:
from hanzo_insights.ai.gemini import Client
from posthog.ai.gemini import Client
except ImportError:
self.skipTest("Gemini package not available")
@@ -346,7 +346,7 @@ class TestSystemPromptCapture(unittest.TestCase):
mock_client_instance.models = mock_models_instance
mock_genai_class.return_value = mock_client_instance
client = Client(insights_client=self.client, api_key="test")
client = Client(posthog_client=self.client, api_key="test")
contents = [{"role": "user", "content": self.test_user_message}]
config = {"system_instruction": self.test_system_prompt}
@@ -355,7 +355,7 @@ class TestSystemPromptCapture(unittest.TestCase):
model="gemini-2.0-flash",
contents=contents,
config=config,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -1,4 +1,4 @@
from hanzo_insights.contexts import (
from posthog.contexts import (
new_context,
get_context_session_id,
get_context_distinct_id,
@@ -20,7 +20,7 @@ if not settings.configured:
)
django.setup()
from hanzo_insights.integrations.django import InsightsContextMiddleware
from posthog.integrations.django import PosthogContextMiddleware
class MockRequest:
@@ -45,7 +45,7 @@ class MockRequest:
return f"{scheme}://{self._host}{self.path}"
class TestInsightsContextMiddleware(unittest.TestCase):
class TestPosthogContextMiddleware(unittest.TestCase):
def create_middleware(
self,
extra_tags=None,
@@ -60,24 +60,24 @@ class TestInsightsContextMiddleware(unittest.TestCase):
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
mock_settings.POSTHOG_MW_EXTRA_TAGS = extra_tags
mock_settings.POSTHOG_MW_REQUEST_FILTER = request_filter
mock_settings.POSTHOG_MW_TAG_MAP = tag_map
mock_settings.POSTHOG_MW_CAPTURE_EXCEPTIONS = capture_exceptions
mock_settings.POSTHOG_MW_CLIENT = None
# Make hasattr work correctly
def mock_hasattr(obj, name):
return name in [
"INSIGHTS_MW_EXTRA_TAGS",
"INSIGHTS_MW_REQUEST_FILTER",
"INSIGHTS_MW_TAG_MAP",
"INSIGHTS_MW_CAPTURE_EXCEPTIONS",
"INSIGHTS_MW_CLIENT",
"POSTHOG_MW_EXTRA_TAGS",
"POSTHOG_MW_REQUEST_FILTER",
"POSTHOG_MW_TAG_MAP",
"POSTHOG_MW_CAPTURE_EXCEPTIONS",
"POSTHOG_MW_CLIENT",
]
with patch("builtins.hasattr", side_effect=mock_hasattr):
middleware = InsightsContextMiddleware(get_response)
middleware = PosthogContextMiddleware(get_response)
return middleware
@@ -87,8 +87,8 @@ class TestInsightsContextMiddleware(unittest.TestCase):
middleware = self.create_middleware()
request = MockRequest(
headers={
"X-INSIGHTS-SESSION-ID": "session-123",
"X-INSIGHTS-DISTINCT-ID": "user-456",
"X-POSTHOG-SESSION-ID": "session-123",
"X-POSTHOG-DISTINCT-ID": "user-456",
},
method="POST",
path="/api/test",
@@ -104,7 +104,7 @@ class TestInsightsContextMiddleware(unittest.TestCase):
self.assertEqual(tags["$request_method"], "POST")
def test_extract_tags_missing_headers(self):
"""Test tag extraction when Insights headers are missing"""
"""Test tag extraction when PostHog headers are missing"""
with new_context():
middleware = self.create_middleware()
@@ -118,12 +118,12 @@ class TestInsightsContextMiddleware(unittest.TestCase):
self.assertEqual(tags["$request_method"], "GET")
def test_extract_tags_partial_headers(self):
"""Test tag extraction with only some Insights headers present"""
"""Test tag extraction with only some PostHog headers present"""
with new_context():
middleware = self.create_middleware()
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "session-only"}, method="PUT"
headers={"X-POSTHOG-SESSION-ID": "session-only"}, method="PUT"
)
tags = middleware.extract_tags(request)
@@ -141,7 +141,7 @@ class TestInsightsContextMiddleware(unittest.TestCase):
with new_context():
middleware = self.create_middleware(extra_tags=extra_tags_func)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "session-123"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "session-123"}, method="GET"
)
tags = middleware.extract_tags(request)
@@ -167,7 +167,7 @@ class TestInsightsContextMiddleware(unittest.TestCase):
tag_map=tag_map_func, extra_tags=extra_tags_func
)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "session-123"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "session-123"}, method="GET"
)
tags = middleware.extract_tags(request)
@@ -230,7 +230,7 @@ class TestInsightsContextMiddleware(unittest.TestCase):
middleware.client = mock_client
request = MockRequest(
headers={"X-INSIGHTS-DISTINCT-ID": "test-user"},
headers={"X-POSTHOG-DISTINCT-ID": "test-user"},
method="POST",
path="/api/endpoint",
)
@@ -282,7 +282,7 @@ class TestInsightsContextMiddleware(unittest.TestCase):
mock_client.capture_exception.assert_not_called()
class TestInsightsContextMiddlewareSync(unittest.TestCase):
class TestPosthogContextMiddlewareSync(unittest.TestCase):
"""Test synchronous middleware behavior"""
def test_sync_middleware_call(self):
@@ -291,13 +291,13 @@ class TestInsightsContextMiddlewareSync(unittest.TestCase):
get_response = Mock(return_value=mock_response)
# Create middleware with sync get_response
middleware = InsightsContextMiddleware(get_response)
middleware = PosthogContextMiddleware(get_response)
# Verify sync mode detected
self.assertFalse(middleware._is_coroutine)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"},
headers={"X-POSTHOG-SESSION-ID": "test-session"},
method="GET",
path="/test",
)
@@ -318,7 +318,7 @@ class TestInsightsContextMiddlewareSync(unittest.TestCase):
def request_filter(req):
return False
middleware = InsightsContextMiddleware.__new__(InsightsContextMiddleware)
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
middleware.get_response = get_response
middleware._is_coroutine = False
middleware.request_filter = request_filter
@@ -351,7 +351,7 @@ class TestInsightsContextMiddlewareSync(unittest.TestCase):
mock_client = Mock()
get_response = Mock(return_value=Mock(status_code=500))
middleware = InsightsContextMiddleware(get_response)
middleware = PosthogContextMiddleware(get_response)
middleware.client = mock_client
def get_response_simulating_django(request):
@@ -380,7 +380,7 @@ class TestInsightsContextMiddlewareSync(unittest.TestCase):
)
class TestInsightsContextMiddlewareAsync(unittest.TestCase):
class TestPosthogContextMiddlewareAsync(unittest.TestCase):
"""Test asynchronous middleware behavior"""
def test_async_middleware_detection(self):
@@ -389,7 +389,7 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
async def async_get_response(request):
return Mock()
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
# Verify async mode detected
self.assertTrue(middleware._is_coroutine)
@@ -403,10 +403,10 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
async def async_get_response(request):
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "async-session"},
headers={"X-POSTHOG-SESSION-ID": "async-session"},
method="POST",
path="/async-test",
)
@@ -434,7 +434,7 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
return mock_response
# Properly initialize middleware
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
# Override request filter after initialization
middleware.request_filter = lambda req: False
@@ -459,10 +459,10 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
self.assertEqual(session_id, "async-session-123")
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "async-session-123"},
headers={"X-POSTHOG-SESSION-ID": "async-session-123"},
method="GET",
)
@@ -483,7 +483,7 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
raise ValueError("Async test exception")
# Properly initialize middleware
middleware = InsightsContextMiddleware(raise_exception)
middleware = PosthogContextMiddleware(raise_exception)
middleware.client = mock_client # Override with mock client
request = MockRequest()
@@ -525,11 +525,11 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
self.assertEqual(distinct_id, "123")
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
# Mock auser() to return authenticated user
@@ -561,11 +561,11 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
self.assertIsNone(distinct_id)
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
async def mock_auser():
@@ -591,12 +591,12 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
async def async_get_response(request):
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
# Request without auser method (no auth middleware)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
with new_context():
@@ -621,12 +621,12 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
async def async_get_response(request):
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.extra_tags = extra_tags_callback
middleware.client = Mock()
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
# Mock auser for no user
@@ -658,12 +658,12 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
async def async_get_response(request):
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.tag_map = tag_map_callback
middleware.client = Mock()
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
# Mock auser for no user
@@ -699,12 +699,12 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
self.assertEqual(session_id, "async-sess-123")
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
request = MockRequest(
headers={
"X-INSIGHTS-SESSION-ID": "async-sess-123",
"X-POSTHOG-SESSION-ID": "async-sess-123",
"X-Forwarded-For": "192.168.1.1",
"User-Agent": "TestAgent/1.0",
},
@@ -725,13 +725,13 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
asyncio.run(run_test())
class TestInsightsContextMiddlewareHybrid(unittest.TestCase):
class TestPosthogContextMiddlewareHybrid(unittest.TestCase):
"""Test hybrid middleware behavior with mixed sync/async chains"""
def test_hybrid_flags_set(self):
"""Test that both capability flags are set"""
self.assertTrue(InsightsContextMiddleware.sync_capable)
self.assertTrue(InsightsContextMiddleware.async_capable)
self.assertTrue(PosthogContextMiddleware.sync_capable)
self.assertTrue(PosthogContextMiddleware.async_capable)
def test_sync_to_async_routing(self):
"""Test that __call__ routes to __acall__ when async"""
@@ -740,7 +740,7 @@ class TestInsightsContextMiddlewareHybrid(unittest.TestCase):
async def async_get_response(request):
return Mock()
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
# Verify routing happens
request = MockRequest()
@@ -759,7 +759,7 @@ class TestInsightsContextMiddlewareHybrid(unittest.TestCase):
def sync_get_response(request):
return mock_response
middleware = InsightsContextMiddleware(sync_get_response)
middleware = PosthogContextMiddleware(sync_get_response)
request = MockRequest()
result = middleware(request)
@@ -2,16 +2,16 @@ import unittest
import mock
from hanzo_insights.client import Client
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
class TestClient(unittest.TestCase):
@classmethod
def setUpClass(cls):
# This ensures no real HTTP POST requests are made
cls.client_post_patcher = mock.patch("hanzo_insights.client.batch_post")
cls.consumer_post_patcher = mock.patch("hanzo_insights.consumer.batch_post")
cls.client_post_patcher = mock.patch("posthog.client.batch_post")
cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post")
cls.client_post_patcher.start()
cls.consumer_post_patcher.start()
@@ -40,7 +40,7 @@ class TestClient(unittest.TestCase):
event["properties"]["processed_by_before_send"] = True
return event
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -73,7 +73,7 @@ class TestClient(unittest.TestCase):
return None
return event
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -101,7 +101,7 @@ class TestClient(unittest.TestCase):
def buggy_before_send(event):
raise ValueError("Oops!")
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -128,7 +128,7 @@ class TestClient(unittest.TestCase):
event["properties"]["marked"] = True
return event
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -153,7 +153,7 @@ class TestClient(unittest.TestCase):
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:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -189,7 +189,7 @@ class TestClient(unittest.TestCase):
return event
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
File diff suppressed because it is too large Load Diff
@@ -11,9 +11,9 @@ try:
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
from posthog.consumer import MAX_MSG_SIZE, Consumer
from posthog.request import APIError
from posthog.test.test_utils import TEST_API_KEY
def _track_event(event_name: str = "python event") -> dict[str, str]:
@@ -60,7 +60,7 @@ class TestConsumer(unittest.TestCase):
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:
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(3):
q.put(_track_event("python event %d" % i))
@@ -76,7 +76,7 @@ class TestConsumer(unittest.TestCase):
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:
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(flush_at * 2):
q.put(_track_event("python event %d" % i))
@@ -99,7 +99,7 @@ class TestConsumer(unittest.TestCase):
consumer = Consumer(None, TEST_API_KEY, retries=retries)
with mock.patch(
"hanzo_insights.consumer.batch_post", mock.Mock(side_effect=mock_post)
"posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)
):
if exception_count <= retries:
consumer.request([_track_event()])
@@ -159,7 +159,7 @@ class TestConsumer(unittest.TestCase):
return res
with mock.patch(
"hanzo_insights.request._session.post", side_effect=mock_post_fn
"posthog.request._session.post", side_effect=mock_post_fn
) as mock_post:
consumer.start()
for _ in range(0, n_msgs + 2):
@@ -167,63 +167,6 @@ class TestConsumer(unittest.TestCase):
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),
@@ -1,7 +1,7 @@
import unittest
from unittest.mock import patch
from hanzo_insights.contexts import (
from posthog.contexts import (
get_tags,
new_context,
scoped,
@@ -66,7 +66,7 @@ class TestContexts(unittest.TestCase):
# Back to level 1
assert get_tags() == {"level1": "value1"}
@patch("hanzo_insights.capture_exception")
@patch("posthog.capture_exception")
def test_scoped_decorator_success(self, mock_capture):
@scoped()
def successful_function(x, y):
@@ -85,7 +85,7 @@ class TestContexts(unittest.TestCase):
# Context should be cleared after function execution
assert get_tags() == {}
@patch("hanzo_insights.capture_exception")
@patch("posthog.capture_exception")
def test_scoped_decorator_exception(self, mock_capture):
test_exception = ValueError("Test exception")
@@ -111,7 +111,7 @@ class TestContexts(unittest.TestCase):
# Context should be cleared after function execution
assert get_tags() == {}
@patch("hanzo_insights.capture_exception")
@patch("posthog.capture_exception")
def test_new_context_exception_handling(self, mock_capture):
test_exception = RuntimeError("Context exception")
@@ -10,8 +10,8 @@ def test_excepthook(tmpdir):
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))
from posthog import Posthog
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
# frame_value = "LOL"
@@ -27,7 +27,7 @@ def test_excepthook(tmpdir):
assert b"ZeroDivisionError" in output
assert b"LOL" in output
assert b"DEBUG:hanzo_insights:data uploaded successfully" in output
assert b"DEBUG:posthog:data uploaded successfully" in output
assert (
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
in output
@@ -40,14 +40,14 @@ def test_code_variables_capture(tmpdir):
dedent(
"""
import os
from hanzo_insights import Insights
from posthog import Posthog
class UnserializableObject:
pass
client = Insights(
posthog = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
@@ -118,29 +118,29 @@ def test_code_variables_capture(tmpdir):
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_$$\\"}'
b'{\\"safe_key\\": \\"safe_value\\", \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"other_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}'
in output
)
assert (
b'{\\"level1\\": {\\"level2\\": {\\"api_key\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"data\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"safe\\": \\"visible\\"}}}'
b'{\\"level1\\": {\\"level2\\": {\\"api_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"data\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"safe\\": \\"visible\\"}}}'
in output
)
assert (
b'[\\"safe_item\\", \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"another_safe\\"]'
b'[\\"safe_item\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"another_safe\\"]'
in output
)
assert (
b'[\\"tuple_safe\\", \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"tuple_also_safe\\"]'
b'[\\"tuple_safe\\", \\"$$_posthog_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\\"}]'
b'[{\\"id\\": 1, \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}, {\\"id\\": 2, \\"value\\": \\"safe_value\\"}]'
in output
)
assert b"<__main__.UnserializableObject object at" in output
assert b"'my_password': '$$_insights_redacted_based_on_masking_rules_$$'" in output
assert b"'my_password': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
assert (
b"'my_innocent_var': '$$_insights_redacted_based_on_masking_rules_$$'" in output
b"'my_innocent_var': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
)
assert b"'__should_be_ignored':" not in output
@@ -160,12 +160,12 @@ def test_code_variables_context_override(tmpdir):
dedent(
"""
import os
import hanzo_insights
from hanzo_insights import Insights
import posthog
from posthog import Posthog
insights_client = Insights(
posthog_client = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=False,
@@ -178,10 +178,10 @@ def test_code_variables_context_override(tmpdir):
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([])
with posthog.new_context(client=posthog_client):
posthog.set_capture_exception_code_variables_context(True)
posthog.set_code_variables_mask_patterns_context([r"(?i).*bank.*"])
posthog.set_code_variables_ignore_patterns_context([])
process_data()
"""
@@ -195,7 +195,7 @@ def test_code_variables_context_override(tmpdir):
assert b"ZeroDivisionError" in output
assert b"code_variables" in output
assert b"'bank': '$$_insights_redacted_based_on_masking_rules_$$'" in output
assert b"'bank': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
assert b"'__dunder_var': 'should_be_visible'" in output
@@ -205,11 +205,11 @@ def test_code_variables_size_limiter(tmpdir):
dedent(
"""
import os
from hanzo_insights import Insights
from posthog import Posthog
client = Insights(
posthog = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
@@ -299,11 +299,11 @@ def test_code_variables_disabled_capture(tmpdir):
dedent(
"""
import os
from hanzo_insights import Insights
from posthog import Posthog
client = Insights(
posthog = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=False,
@@ -340,12 +340,12 @@ def test_code_variables_enabled_then_disabled_in_context(tmpdir):
dedent(
"""
import os
import hanzo_insights
from hanzo_insights import Insights
import posthog
from posthog import Posthog
insights_client = Insights(
posthog_client = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
@@ -358,8 +358,8 @@ def test_code_variables_enabled_then_disabled_in_context(tmpdir):
1/0
with hanzo_insights.new_context(client=insights_client):
hanzo_insights.set_capture_exception_code_variables_context(False)
with posthog.new_context(client=posthog_client):
posthog.set_capture_exception_code_variables_context(False)
process_data()
"""
@@ -388,15 +388,15 @@ def test_code_variables_repr_fallback(tmpdir):
from datetime import datetime, timedelta
from decimal import Decimal
from fractions import Fraction
from hanzo_insights import Insights
from posthog import Posthog
class CustomReprClass:
def __repr__(self):
return '<CustomReprClass: custom representation>'
client = Insights(
posthog = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
@@ -450,240 +450,3 @@ def test_code_variables_repr_fallback(tmpdir):
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
@@ -1,6 +1,6 @@
import unittest
from hanzo_insights.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata
from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata
class TestFeatureFlag(unittest.TestCase):
@@ -2,9 +2,9 @@ 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 (
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import (
FeatureFlag,
FeatureFlagError,
FeatureFlagResult,
@@ -328,7 +328,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags):
patch_flags.return_value = {
@@ -375,7 +375,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
captured_properties = patch_capture.call_args[1]["properties"]
self.assertNotIn("$feature_flag_error", captured_properties)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags):
patch_flags.return_value = {
@@ -421,7 +421,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
captured_properties = patch_capture.call_args[1]["properties"]
self.assertNotIn("$feature_flag_error", captured_properties)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags):
patch_flags.return_value = {
@@ -461,7 +461,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_with_errors_while_computing_flags(
self, patch_capture, patch_flags
@@ -507,7 +507,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_flag_not_in_response(
self, patch_capture, patch_flags
@@ -549,7 +549,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_errors_computing_and_flag_missing(
self, patch_capture, patch_flags
@@ -585,7 +585,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_unknown_error(self, patch_capture, patch_flags):
"""Test that unexpected exceptions are captured as unknown_error."""
@@ -608,11 +608,11 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_timeout_error(self, patch_capture, patch_flags):
"""Test that timeout errors are captured specifically."""
from hanzo_insights.request import RequestsTimeout
from posthog.request import RequestsTimeout
patch_flags.side_effect = RequestsTimeout("Request timed out")
@@ -633,11 +633,11 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_connection_error(self, patch_capture, patch_flags):
"""Test that connection errors are captured specifically."""
from hanzo_insights.request import RequestsConnectionError
from posthog.request import RequestsConnectionError
patch_flags.side_effect = RequestsConnectionError("Connection refused")
@@ -658,11 +658,11 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_api_error(self, patch_capture, patch_flags):
"""Test that API errors include the status code."""
from hanzo_insights.request import APIError
from posthog.request import APIError
patch_flags.side_effect = APIError(500, "Internal server error")
@@ -683,11 +683,11 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_quota_limited(self, patch_capture, patch_flags):
"""Test that quota limit errors are captured specifically."""
from hanzo_insights.request import QuotaLimitError
from posthog.request import QuotaLimitError
patch_flags.side_effect = QuotaLimitError(429, "Rate limit exceeded")
@@ -712,7 +712,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
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
When the PostHog API is unavailable (timeout, connection error, etc.), the SDK
falls back to stale cached flag values if available. These tests verify that:
1. The stale cached value is returned when an error occurs
2. The $feature_flag_error property is still set (for debugging)
@@ -741,11 +741,11 @@ class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
flag_definition_version=self.client.flag_definition_version,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_timeout_error_returns_stale_cached_value(self, patch_capture, patch_flags):
"""Test that timeout errors return stale cached value when available."""
from hanzo_insights.request import RequestsTimeout
from posthog.request import RequestsTimeout
# Pre-populate cache with a flag result
cached_result = FeatureFlagResult.from_value_and_payload(
@@ -779,13 +779,13 @@ class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_connection_error_returns_stale_cached_value(
self, patch_capture, patch_flags
):
"""Test that connection errors return stale cached value when available."""
from hanzo_insights.request import RequestsConnectionError
from posthog.request import RequestsConnectionError
# Pre-populate cache with a boolean flag result
cached_result = FeatureFlagResult.from_value_and_payload("my-flag", True, None)
@@ -816,11 +816,11 @@ class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_api_error_returns_stale_cached_value(self, patch_capture, patch_flags):
"""Test that API errors return stale cached value when available."""
from hanzo_insights.request import APIError
from posthog.request import APIError
# Pre-populate cache
cached_result = FeatureFlagResult.from_value_and_payload(
@@ -852,11 +852,11 @@ class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_error_without_cache_returns_none(self, patch_capture, patch_flags):
"""Test that errors return None when no stale cache is available."""
from hanzo_insights.request import RequestsTimeout
from posthog.request import RequestsTimeout
# Do NOT populate cache - no fallback available
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
"""
Tests for FlagDefinitionCacheProvider functionality.
These tests follow the patterns from the TypeScript implementation in insights-js/packages/node.
These tests follow the patterns from the TypeScript implementation in posthog-js/packages/node.
"""
import threading
@@ -9,13 +9,13 @@ import unittest
from typing import Optional
from unittest import mock
from hanzo_insights.client import Client
from hanzo_insights.flag_definition_cache import (
from posthog.client import Client
from posthog.flag_definition_cache import (
FlagDefinitionCacheData,
FlagDefinitionCacheProvider,
)
from hanzo_insights.request import GetResponse
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
from posthog.request import GetResponse
from posthog.test.test_utils import FAKE_TEST_API_KEY
class MockCacheProvider:
@@ -63,8 +63,8 @@ class TestFlagDefinitionCacheProvider(unittest.TestCase):
@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 = mock.patch("posthog.client.batch_post")
cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post")
cls.client_post_patcher.start()
cls.consumer_post_patcher.start()
@@ -102,7 +102,7 @@ class TestFlagDefinitionCacheProvider(unittest.TestCase):
class TestCacheInitialization(TestFlagDefinitionCacheProvider):
"""Tests for cache initialization behavior."""
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_uses_cached_data_when_should_fetch_returns_false(self, mock_get):
"""When should_fetch returns False and cache has data, use cached data."""
self.cache_provider.should_fetch_return_value = False
@@ -124,7 +124,7 @@ class TestCacheInitialization(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_fetches_from_api_when_should_fetch_returns_true(self, mock_get):
"""When should_fetch returns True, fetch from API."""
self.cache_provider.should_fetch_return_value = True
@@ -148,7 +148,7 @@ class TestCacheInitialization(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_emergency_fallback_when_cache_empty_and_no_flags(self, mock_get):
"""When should_fetch=False but cache is empty and no flags loaded, fetch anyway."""
self.cache_provider.should_fetch_return_value = False
@@ -169,7 +169,7 @@ class TestCacheInitialization(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_preserves_existing_flags_when_cache_returns_none(self, mock_get):
"""When cache returns None but client has flags, preserve existing flags."""
self.cache_provider.should_fetch_return_value = False
@@ -197,7 +197,7 @@ class TestCacheInitialization(TestFlagDefinitionCacheProvider):
class TestFetchCoordination(TestFlagDefinitionCacheProvider):
"""Tests for fetch coordination between workers."""
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_calls_should_fetch_before_each_poll(self, mock_get):
"""should_fetch_flag_definitions is called before each poll cycle."""
self.cache_provider.should_fetch_return_value = True
@@ -218,7 +218,7 @@ class TestFetchCoordination(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_does_not_call_on_received_when_fetch_skipped(self, mock_get):
"""on_flag_definitions_received is NOT called when fetch is skipped."""
self.cache_provider.should_fetch_return_value = False
@@ -232,7 +232,7 @@ class TestFetchCoordination(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_stores_data_in_cache_after_api_fetch(self, mock_get):
"""on_flag_definitions_received receives the fetched data."""
self.cache_provider.should_fetch_return_value = True
@@ -251,7 +251,7 @@ class TestFetchCoordination(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_304_not_modified_does_not_update_cache(self, mock_get):
"""When API returns 304 Not Modified, cache should not be updated."""
self.cache_provider.should_fetch_return_value = True
@@ -293,7 +293,7 @@ class TestFetchCoordination(TestFlagDefinitionCacheProvider):
class TestErrorHandling(TestFlagDefinitionCacheProvider):
"""Tests for error handling in cache provider operations."""
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_should_fetch_error_defaults_to_fetching(self, mock_get):
"""When should_fetch throws an error, default to fetching from API."""
self.cache_provider.should_fetch_error = Exception("Lock acquisition failed")
@@ -313,7 +313,7 @@ class TestErrorHandling(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_get_error_falls_back_to_api_fetch(self, mock_get):
"""When get_flag_definitions throws an error, fetch from API."""
self.cache_provider.should_fetch_return_value = False
@@ -331,7 +331,7 @@ class TestErrorHandling(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_on_received_error_keeps_flags_in_memory(self, mock_get):
"""When on_flag_definitions_received throws, flags are still in memory."""
self.cache_provider.should_fetch_return_value = True
@@ -350,7 +350,7 @@ class TestErrorHandling(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_shutdown_error_is_logged_but_continues(self, mock_get):
"""When shutdown throws an error, it's logged but shutdown continues."""
self.cache_provider.shutdown_error = Exception("Lock release failed")
@@ -372,7 +372,7 @@ class TestErrorHandling(TestFlagDefinitionCacheProvider):
class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
"""Tests for shutdown lifecycle."""
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_shutdown_calls_cache_provider_shutdown(self, mock_get):
"""Client shutdown calls cache provider shutdown."""
mock_get.return_value = GetResponse(
@@ -387,7 +387,7 @@ class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_shutdown_called_even_without_fetching(self, mock_get):
"""Shutdown is called even when cache was used instead of fetching."""
self.cache_provider.should_fetch_return_value = False
@@ -400,7 +400,7 @@ class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
# Shutdown should still be called
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_multiple_join_calls_only_shutdown_once(self, mock_get):
"""Calling join() multiple times should only call cache provider shutdown once."""
mock_get.return_value = GetResponse(
@@ -423,7 +423,7 @@ class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
class TestBackwardCompatibility(TestFlagDefinitionCacheProvider):
"""Tests for backward compatibility without cache provider."""
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_works_without_cache_provider(self, mock_get):
"""Client works normally without a cache provider configured."""
mock_get.return_value = GetResponse(
@@ -451,7 +451,7 @@ class TestBackwardCompatibility(TestFlagDefinitionCacheProvider):
class TestDataIntegrity(TestFlagDefinitionCacheProvider):
"""Tests for data integrity between cache and client state."""
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_cached_flags_available_for_evaluation(self, mock_get):
"""Flags loaded from cache are available for local evaluation."""
self.cache_provider.should_fetch_return_value = False
@@ -483,7 +483,7 @@ class TestDataIntegrity(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_group_type_mapping_loaded_from_cache(self, mock_get):
"""Group type mapping is correctly loaded from cache."""
self.cache_provider.should_fetch_return_value = False
@@ -497,7 +497,7 @@ class TestDataIntegrity(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_cohorts_loaded_from_cache(self, mock_get):
"""Cohorts are correctly loaded from cache."""
self.cache_provider.should_fetch_return_value = False
@@ -510,7 +510,7 @@ class TestDataIntegrity(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_cache_updated_when_api_returns_new_data(self, mock_get):
"""State transition: cache has old data -> API returns new -> cache updated."""
# Start with old cached data
@@ -556,7 +556,7 @@ class TestDataIntegrity(TestFlagDefinitionCacheProvider):
class TestConcurrency(TestFlagDefinitionCacheProvider):
"""Tests for thread safety and concurrent access."""
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_concurrent_load_feature_flags_is_thread_safe(self, mock_get):
"""Multiple threads calling _load_feature_flags should not cause errors."""
mock_get.return_value = GetResponse(
@@ -1,10 +1,10 @@
import unittest
from hanzo_insights import Insights
from posthog import Posthog
class TestModule(unittest.TestCase):
client = None
posthog = None
def _assert_enqueue_result(self, result):
self.assertEqual(type(result[0]), str)
@@ -14,19 +14,19 @@ class TestModule(unittest.TestCase):
def setUp(self):
self.failed = False
self.client = Insights(
self.posthog = Posthog(
"testsecret", host="http://localhost:8000", on_error=self.failed
)
def test_track(self):
res = self.client.capture("python module event", distinct_id="distinct_id")
res = self.posthog.capture("python module event", distinct_id="distinct_id")
self._assert_enqueue_result(res)
self.client.flush()
self.posthog.flush()
def test_alias(self):
res = self.client.alias("previousId", "distinct_id")
res = self.posthog.alias("previousId", "distinct_id")
self._assert_enqueue_result(res)
self.client.flush()
self.posthog.flush()
def test_flush(self):
self.client.flush()
self.posthog.flush()
@@ -6,8 +6,8 @@ import mock
import pytest
import requests
import hanzo_insights.request as request_module
from hanzo_insights.request import (
import posthog.request as request_module
from posthog.request import (
APIError,
DatetimeSerializer,
GetResponse,
@@ -23,7 +23,7 @@ from hanzo_insights.request import (
get,
set_socket_options,
)
from hanzo_insights.test.test_utils import TEST_API_KEY
from posthog.test.test_utils import TEST_API_KEY
@pytest.mark.parametrize(
@@ -128,7 +128,7 @@ class TestRequests(unittest.TestCase):
}
).encode("utf-8")
with mock.patch("hanzo_insights.request._session.post", return_value=mock_response):
with mock.patch("posthog.request._session.post", return_value=mock_response):
with self.assertRaises(QuotaLimitError) as cm:
decide("fake_key", "fake_host")
@@ -146,7 +146,7 @@ class TestRequests(unittest.TestCase):
}
).encode("utf-8")
with mock.patch("hanzo_insights.request._session.post", return_value=mock_response):
with mock.patch("posthog.request._session.post", return_value=mock_response):
response = decide("fake_key", "fake_host")
self.assertEqual(response["featureFlags"], {"flag1": True})
@@ -154,7 +154,7 @@ class TestRequests(unittest.TestCase):
class TestGet(unittest.TestCase):
"""Unit tests for the get() function HTTP-level behavior."""
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_returns_data_and_etag(self, mock_get):
"""Test that get() returns GetResponse with data and etag from headers."""
mock_response = requests.Response()
@@ -172,7 +172,7 @@ class TestGet(unittest.TestCase):
self.assertEqual(response.etag, '"abc123"')
self.assertFalse(response.not_modified)
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_sends_if_none_match_header_when_etag_provided(self, mock_get):
"""Test that If-None-Match header is sent when etag parameter is provided."""
mock_response = requests.Response()
@@ -186,7 +186,7 @@ class TestGet(unittest.TestCase):
call_kwargs = mock_get.call_args[1]
self.assertEqual(call_kwargs["headers"]["If-None-Match"], '"previous-etag"')
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_does_not_send_if_none_match_when_no_etag(self, mock_get):
"""Test that If-None-Match header is not sent when no etag provided."""
mock_response = requests.Response()
@@ -199,7 +199,7 @@ class TestGet(unittest.TestCase):
call_kwargs = mock_get.call_args[1]
self.assertNotIn("If-None-Match", call_kwargs["headers"])
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_handles_304_not_modified(self, mock_get):
"""Test that 304 Not Modified response returns not_modified=True with no data."""
mock_response = requests.Response()
@@ -216,7 +216,7 @@ class TestGet(unittest.TestCase):
self.assertEqual(response.etag, '"unchanged-etag"')
self.assertTrue(response.not_modified)
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_304_without_etag_header_uses_request_etag(self, mock_get):
"""Test that 304 response without ETag header falls back to request etag."""
mock_response = requests.Response()
@@ -231,7 +231,7 @@ class TestGet(unittest.TestCase):
self.assertTrue(response.not_modified)
self.assertEqual(response.etag, '"original-etag"')
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_200_without_etag_header(self, mock_get):
"""Test that 200 response without ETag header returns None for etag."""
mock_response = requests.Response()
@@ -246,7 +246,7 @@ class TestGet(unittest.TestCase):
self.assertIsNone(response.etag)
self.assertEqual(response.data, {"flags": []})
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_error_response_raises_api_error(self, mock_get):
"""Test that error responses raise APIError."""
mock_response = requests.Response()
@@ -260,7 +260,7 @@ class TestGet(unittest.TestCase):
self.assertEqual(ctx.exception.status, 401)
self.assertEqual(ctx.exception.message, "Unauthorized")
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_sends_authorization_header(self, mock_get):
"""Test that Authorization header is sent with Bearer token."""
mock_response = requests.Response()
@@ -273,7 +273,7 @@ class TestGet(unittest.TestCase):
call_kwargs = mock_get.call_args[1]
self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer my-api-key")
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_sends_user_agent_header(self, mock_get):
"""Test that User-Agent header is sent."""
mock_response = requests.Response()
@@ -286,10 +286,10 @@ class TestGet(unittest.TestCase):
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/")
call_kwargs["headers"]["User-Agent"].startswith("posthog-python/")
)
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_passes_timeout(self, mock_get):
"""Test that timeout parameter is passed to the request."""
mock_response = requests.Response()
@@ -302,7 +302,7 @@ class TestGet(unittest.TestCase):
call_kwargs = mock_get.call_args[1]
self.assertEqual(call_kwargs["timeout"], 30)
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_constructs_full_url(self, mock_get):
"""Test that host and url are combined correctly."""
mock_response = requests.Response()
@@ -315,7 +315,7 @@ class TestGet(unittest.TestCase):
call_args = mock_get.call_args[0]
self.assertEqual(call_args[0], "https://example.com/api/flags")
@mock.patch("hanzo_insights.request._session.get")
@mock.patch("posthog.request._session.get")
def test_get_removes_trailing_slash_from_host(self, mock_get):
"""Test that trailing slash is removed from host."""
mock_response = requests.Response()
@@ -339,13 +339,13 @@ class TestGet(unittest.TestCase):
("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"),
("https://app.posthog.com", "https://us.i.posthog.com"),
("https://eu.posthog.com", "https://eu.i.posthog.com"),
("https://us.posthog.com", "https://us.i.posthog.com"),
("https://app.posthog.com/", "https://us.i.posthog.com"),
("https://eu.posthog.com/", "https://eu.i.posthog.com"),
("https://us.posthog.com/", "https://us.i.posthog.com"),
(None, "https://us.i.posthog.com"),
],
)
def test_routing_to_custom_host(host, expected):
@@ -355,7 +355,7 @@ def test_routing_to_custom_host(host, expected):
def test_enable_keep_alive_sets_socket_options():
try:
enable_keep_alive()
from hanzo_insights.request import _session
from posthog.request import _session
adapter = _session.get_adapter("https://example.com")
assert adapter.socket_options == KEEP_ALIVE_SOCKET_OPTIONS
@@ -367,7 +367,7 @@ def test_set_socket_options_clears_with_none():
try:
enable_keep_alive()
set_socket_options(None)
from hanzo_insights.request import _session
from posthog.request import _session
adapter = _session.get_adapter("https://example.com")
assert adapter.socket_options is None
@@ -401,17 +401,17 @@ class TestFlagsSession(unittest.TestCase):
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
from posthog.request import RETRY_STATUS_FORCELIST
self.assertNotIn(429, RETRY_STATUS_FORCELIST)
def test_retry_status_forcelist_excludes_quota_errors(self):
"""Verify 402 (payment required/quota) is NOT retried - won't resolve."""
from hanzo_insights.request import RETRY_STATUS_FORCELIST
from posthog.request import RETRY_STATUS_FORCELIST
self.assertNotIn(402, RETRY_STATUS_FORCELIST)
@mock.patch("hanzo_insights.request._get_flags_session")
@mock.patch("posthog.request._get_flags_session")
def test_flags_uses_flags_session(self, mock_get_flags_session):
"""flags() uses the dedicated flags session, not the general session."""
mock_response = requests.Response()
@@ -434,7 +434,7 @@ class TestFlagsSession(unittest.TestCase):
mock_get_flags_session.assert_called_once()
mock_session.post.assert_called_once()
@mock.patch("hanzo_insights.request._get_flags_session")
@mock.patch("posthog.request._get_flags_session")
def test_flags_no_retry_on_quota_limit(self, mock_get_flags_session):
"""flags() raises QuotaLimitError without retrying (at application level)."""
mock_response = requests.Response()
@@ -470,7 +470,7 @@ class TestFlagsSessionNetworkRetries(unittest.TestCase):
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
from posthog.request import _build_flags_session
session = _build_flags_session()
@@ -491,7 +491,7 @@ class TestFlagsSessionNetworkRetries(unittest.TestCase):
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
from posthog.request import _build_flags_session, RETRY_STATUS_FORCELIST
session = _build_flags_session()
adapter = session.get_adapter("https://test.posthog.com")
@@ -518,7 +518,7 @@ class TestFlagsSessionNetworkRetries(unittest.TestCase):
"""
Verify that retries use exponential backoff to avoid thundering herd.
"""
from hanzo_insights.request import _build_flags_session
from posthog.request import _build_flags_session
session = _build_flags_session()
adapter = session.get_adapter("https://test.posthog.com")
@@ -545,7 +545,7 @@ class TestFlagsSessionRetryIntegration(unittest.TestCase):
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
from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
request_count = 0
@@ -631,7 +631,7 @@ class TestFlagsSessionRetryIntegration(unittest.TestCase):
import socket
import time
from urllib3.util.retry import Retry
from hanzo_insights.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
# Get an available port by binding then closing a socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -2,7 +2,7 @@ import unittest
from parameterized import parameterized
from hanzo_insights import utils
from posthog import utils
class TestSizeLimitedDict(unittest.TestCase):
@@ -2,7 +2,7 @@ import unittest
from parameterized import parameterized
from hanzo_insights.types import (
from posthog.types import (
FeatureFlag,
FlagMetadata,
FlagReason,
@@ -13,8 +13,8 @@ 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
from posthog import utils
from posthog.types import FeatureFlagResult
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
FAKE_TEST_API_KEY = "random_key"
@@ -96,8 +96,8 @@ class TestUtils(unittest.TestCase):
@parameterized.expand(
[
("http://hanzo_insights.io/", "http://hanzo_insights.io"),
("http://hanzo_insights.io", "http://hanzo_insights.io"),
("http://posthog.io/", "http://posthog.io"),
("http://posthog.io", "http://posthog.io"),
("https://example.com/path/", "https://example.com/path"),
("https://example.com/path", "https://example.com/path"),
]
+2 -2
View File
@@ -16,7 +16,7 @@ import distro # For Linux OS detection
import six
from dateutil.tz import tzlocal, tzutc
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
def is_naive(dt):
@@ -277,7 +277,7 @@ class FlagCache:
class RedisFlagCache:
def __init__(
self, redis_client, default_ttl=300, stale_ttl=3600, key_prefix="insights:flags:"
self, redis_client, default_ttl=300, stale_ttl=3600, key_prefix="posthog:flags:"
):
self.redis = redis_client
self.default_ttl = default_ttl
+4
View File
@@ -0,0 +1,4 @@
VERSION = "7.5.1"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201

Some files were not shown because too many files have changed in this diff Show More