Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01751d1205 | ||
|
|
4426dd9d27 | ||
|
|
bf0d7efbfe | ||
|
|
f17ebfa12b | ||
|
|
800527da43 | ||
|
|
0d29fb7be3 | ||
|
|
24d89806cb | ||
|
|
a2105f6e95 | ||
|
|
3171193d75 | ||
|
|
1db6e45258 | ||
|
|
1daa8a8053 |
@@ -18,17 +18,16 @@ jobs:
|
||||
with:
|
||||
python-version: 3.11.11
|
||||
|
||||
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Install dev dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install -e .[dev]
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra dev
|
||||
|
||||
- name: Check formatting with ruff
|
||||
run: |
|
||||
@@ -55,9 +54,16 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install requirements.txt dependencies with pip
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Install test dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install -e .[test]
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test
|
||||
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
|
||||
@@ -24,15 +24,23 @@ jobs:
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.11.11
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Detect version
|
||||
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare for building release
|
||||
run: pip install -U pip setuptools packaging wheel twine
|
||||
run: uv sync --extra dev
|
||||
|
||||
- name: Push release to PyPI
|
||||
run: make release && make release_analytics
|
||||
- name: Push releases to PyPI
|
||||
run: uv run make release && uv run make release_analytics
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
|
||||
|
||||
@@ -17,3 +17,4 @@ posthog-analytics
|
||||
.coverage
|
||||
pyrightconfig.json
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# Before Send Hook
|
||||
|
||||
The `before_send` parameter allows you to modify or filter events before they are sent to PostHog. This is useful for:
|
||||
|
||||
- **Privacy**: Removing or masking sensitive data (PII)
|
||||
- **Filtering**: Dropping unwanted events (test events, internal users, etc.)
|
||||
- **Enhancement**: Adding custom properties to all events
|
||||
- **Transformation**: Modifying event names or property formats
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```python
|
||||
import posthog
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
def my_before_send(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Process event before sending to PostHog.
|
||||
|
||||
Args:
|
||||
event: The event dictionary containing 'event', 'distinct_id', 'properties', etc.
|
||||
|
||||
Returns:
|
||||
Modified event dictionary to send, or None to drop the event
|
||||
"""
|
||||
# Your processing logic here
|
||||
return event
|
||||
|
||||
# Initialize client with before_send hook
|
||||
client = posthog.Client(
|
||||
api_key="your-project-api-key",
|
||||
before_send=my_before_send
|
||||
)
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### 1. Filter Out Events
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def filter_events_by_property_or_event_name(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Drop events from internal users or test environments."""
|
||||
properties = event.get("properties", {})
|
||||
|
||||
# Choose some property from your events
|
||||
event_source = properties.get("event_source", "")
|
||||
if event_source.endswith("internal"):
|
||||
return None # Drop the event
|
||||
|
||||
# Filter out test events
|
||||
if event.get("event") == "test_event":
|
||||
return None
|
||||
|
||||
return event
|
||||
```
|
||||
|
||||
### 2. Remove/Mask PII Data
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def scrub_pii(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Remove or mask personally identifiable information."""
|
||||
properties = event.get("properties", {})
|
||||
|
||||
# Mask email but keep domain for analytics
|
||||
if "email" in properties:
|
||||
email = properties["email"]
|
||||
if "@" in email:
|
||||
domain = email.split("@")[1]
|
||||
properties["email"] = f"***@{domain}"
|
||||
else:
|
||||
properties["email"] = "***"
|
||||
|
||||
# Remove sensitive fields entirely
|
||||
sensitive_fields = ["my_business_info", "secret_things"]
|
||||
for field in sensitive_fields:
|
||||
properties.pop(field, None)
|
||||
|
||||
return event
|
||||
```
|
||||
|
||||
### 3. Add Custom Properties
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
def add_context(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Add custom properties to all events."""
|
||||
if "properties" not in event:
|
||||
event["properties"] = {}
|
||||
|
||||
event["properties"].update({
|
||||
"app_version": "2.1.0",
|
||||
"environment": "production",
|
||||
"processed_at": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
return event
|
||||
```
|
||||
|
||||
### 4. Transform Event Names
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def normalize_event_names(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Convert event names to a consistent format."""
|
||||
original_event = event.get("event")
|
||||
if original_event:
|
||||
# Convert to snake_case
|
||||
normalized = original_event.lower().replace(" ", "_").replace("-", "_")
|
||||
event["event"] = f"app_{normalized}"
|
||||
|
||||
return event
|
||||
```
|
||||
|
||||
### 5. Log and drop in "dev" mode
|
||||
|
||||
When running in local dev often, you want to log but drop all events
|
||||
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def log_and_drop_all(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Convert event names to a consistent format."""
|
||||
print(event)
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 6. Combined Processing
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def comprehensive_processor(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Apply multiple transformations in sequence."""
|
||||
|
||||
# Step 1: Filter unwanted events
|
||||
if should_drop_event(event):
|
||||
return None
|
||||
|
||||
# Step 2: Scrub PII
|
||||
event = scrub_pii(event)
|
||||
|
||||
# Step 3: Add context
|
||||
event = add_context(event)
|
||||
|
||||
# Step 4: Normalize names
|
||||
event = normalize_event_names(event)
|
||||
|
||||
return event
|
||||
|
||||
def should_drop_event(event: dict[str, Any]) -> bool:
|
||||
"""Determine if event should be dropped."""
|
||||
# Your filtering logic
|
||||
return False
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
If your `before_send` function raises an exception, PostHog will:
|
||||
|
||||
1. Log the error
|
||||
2. Continue with the original, unmodified event
|
||||
3. Not crash your application
|
||||
|
||||
```python
|
||||
from typing import Optional, Any
|
||||
|
||||
def risky_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
# If this raises an exception, the original event will be sent
|
||||
risky_operation()
|
||||
return event
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```python
|
||||
import posthog
|
||||
from typing import Optional, Any
|
||||
import re
|
||||
|
||||
def production_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
try:
|
||||
properties = event.get("properties", {})
|
||||
|
||||
# 1. Filter out bot traffic
|
||||
user_agent = properties.get("$user_agent", "")
|
||||
if re.search(r'bot|crawler|spider', user_agent, re.I):
|
||||
return None
|
||||
|
||||
# 2. Filter out internal traffic
|
||||
ip = properties.get("$ip", "")
|
||||
if ip.startswith("192.168.") or ip.startswith("10."):
|
||||
return None
|
||||
|
||||
# 3. Scrub email PII but keep domain
|
||||
if "email" in properties:
|
||||
email = properties["email"]
|
||||
if "@" in email:
|
||||
domain = email.split("@")[1]
|
||||
properties["email"] = f"***@{domain}"
|
||||
|
||||
# 4. Add custom context
|
||||
properties.update({
|
||||
"app_version": "1.0.0",
|
||||
"build_number": "123"
|
||||
})
|
||||
|
||||
# 5. Normalize event name
|
||||
if event.get("event"):
|
||||
event["event"] = event["event"].lower().replace(" ", "_")
|
||||
|
||||
return event
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't crash
|
||||
print(f"Error in before_send: {e}")
|
||||
return event # Return original event on error
|
||||
|
||||
# Usage
|
||||
client = posthog.Client(
|
||||
api_key="your-api-key",
|
||||
before_send=production_before_send
|
||||
)
|
||||
|
||||
# All events will now be processed by your before_send function
|
||||
client.capture("user_123", "Page View", {"url": "/home"})
|
||||
```
|
||||
+43
-11
@@ -1,18 +1,50 @@
|
||||
## 4.7.0 - 2025-06-10
|
||||
|
||||
- feat: add support for parse endpoint in responses API (no longer beta)
|
||||
|
||||
## 4.6.2 - 2025-06-09
|
||||
|
||||
- fix: replace `import posthog` with direct method imports
|
||||
|
||||
## 4.6.1 - 2025-06-09
|
||||
|
||||
- fix: replace `import posthog` in `posthoganalytics` package
|
||||
|
||||
## 4.6.0 - 2025-06-09
|
||||
|
||||
- feat: add additional user and request context to captured exceptions via the Django integration
|
||||
- feat: Add `setup()` function to initialise default client
|
||||
|
||||
## 4.5.0 - 2025-06-09
|
||||
|
||||
- feat: add before_send callback (#249)
|
||||
|
||||
## 4.4.2- 2025-06-09
|
||||
|
||||
- empty point release to fix release automation
|
||||
|
||||
## 4.4.1 2025-06-09
|
||||
|
||||
- empty point release to fix release automation
|
||||
|
||||
## 4.4.0 - 2025-06-09
|
||||
|
||||
- Use the new `/flags` endpoint for all feature flag evaluations (don't fall back to `/decide` at all)
|
||||
|
||||
## 4.3.2 - 2025-06-06
|
||||
|
||||
Add context management:
|
||||
- New context manager with `posthog.new_context()`
|
||||
- Tag functions: `posthog.tag()`, `posthog.get_tags()`, `posthog.clear_tags()`
|
||||
- Function decorator:
|
||||
- `@posthog.scoped` - Creates context and captures exceptions thrown within the function
|
||||
- Automatic deduplication of exceptions to ensure each exception is only captured once
|
||||
1. Add context management:
|
||||
|
||||
## 4.2.1 - 2025-6-05
|
||||
- New context manager with `posthog.new_context()`
|
||||
- Tag functions: `posthog.tag()`, `posthog.get_tags()`, `posthog.clear_tags()`
|
||||
- Function decorator:
|
||||
- `@posthog.scoped` - Creates context and captures exceptions thrown within the function
|
||||
- Automatic deduplication of exceptions to ensure each exception is only captured once
|
||||
|
||||
1. fix: feature flag request use geoip_disable (#235)
|
||||
2. chore: pin actions versions (#210)
|
||||
3. fix: opinionated setup and clean fn fix (#240)
|
||||
4. fix: release action failed (#241)
|
||||
2. fix: feature flag request use geoip_disable (#235)
|
||||
3. chore: pin actions versions (#210)
|
||||
4. fix: opinionated setup and clean fn fix (#240)
|
||||
5. fix: release action failed (#241)
|
||||
|
||||
## 4.2.0 - 2025-05-22
|
||||
|
||||
|
||||
@@ -16,16 +16,21 @@ release_analytics:
|
||||
rm -rf posthoganalytics
|
||||
mkdir posthoganalytics
|
||||
cp -r posthog/* posthoganalytics/
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog /from posthoganalytics /g' {} \;
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog\./from posthoganalytics\./g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
|
||||
find ./posthoganalytics -name "*.bak" -delete
|
||||
rm -rf posthog
|
||||
python setup_analytics.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
mkdir posthog
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics /from posthog /g' {} \;
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics\./from posthog\./g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from posthog /g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from posthog\./g' {} \;
|
||||
find ./posthoganalytics -name "*.bak" -delete
|
||||
cp -r posthoganalytics/* posthog/
|
||||
rm -rf posthoganalytics
|
||||
rm -f pyproject.toml
|
||||
cp pyproject.toml.backup pyproject.toml
|
||||
rm -f pyproject.toml.backup
|
||||
|
||||
e2e_test:
|
||||
.buildscripts/e2e.sh
|
||||
|
||||
@@ -16,11 +16,13 @@ Please see the [Python integration docs](https://posthog.com/docs/integrations/p
|
||||
|
||||
### Testing Locally
|
||||
|
||||
1. Run `python3 -m venv env` (creates virtual environment called "env")
|
||||
* or `uv venv env`
|
||||
We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
|
||||
|
||||
1. Run `uv venv env` (creates virtual environment called "env")
|
||||
* or `python3 -m venv env`
|
||||
2. Run `source env/bin/activate` (activates the virtual environment)
|
||||
3. Run `python3 -m pip install -e ".[test]"` (installs the package in develop mode, along with test dependencies)
|
||||
* or `uv pip install -e ".[test]"`
|
||||
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`
|
||||
@@ -32,7 +34,7 @@ uv python install 3.9.19
|
||||
uv python pin 3.9.19
|
||||
uv venv env
|
||||
source env/bin/activate
|
||||
uv pip install --editable ".[dev,test]"
|
||||
uv sync --extra dev --extra test
|
||||
pre-commit install
|
||||
make test
|
||||
```
|
||||
|
||||
+6
-2
@@ -580,8 +580,7 @@ def shutdown():
|
||||
_proxy("join")
|
||||
|
||||
|
||||
def _proxy(method, *args, **kwargs):
|
||||
"""Create an analytics client if one doesn't exist and send to it."""
|
||||
def setup():
|
||||
global default_client
|
||||
if not default_client:
|
||||
default_client = Client(
|
||||
@@ -610,6 +609,11 @@ def _proxy(method, *args, **kwargs):
|
||||
default_client.disabled = disabled
|
||||
default_client.debug = debug
|
||||
|
||||
|
||||
def _proxy(method, *args, **kwargs):
|
||||
"""Create an analytics client if one doesn't exist and send to it."""
|
||||
setup()
|
||||
|
||||
fn = getattr(default_client, method)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -230,6 +230,42 @@ class WrappedResponses:
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
def parse(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
|
||||
|
||||
Args:
|
||||
posthog_distinct_id: Optional ID to associate with the usage event.
|
||||
posthog_trace_id: Optional trace UUID for linking events.
|
||||
posthog_properties: Optional dictionary of extra properties to include in the event.
|
||||
posthog_privacy_mode: Whether to anonymize the input and output.
|
||||
posthog_groups: Optional dictionary of groups to associate with the event.
|
||||
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's responses.parse call.
|
||||
"""
|
||||
return call_llm_and_track_usage(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
self._original.parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class WrappedChat:
|
||||
"""Wrapper for OpenAI chat that tracks usage in PostHog."""
|
||||
|
||||
@@ -230,6 +230,42 @@ class WrappedResponses:
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
async def parse(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
|
||||
|
||||
Args:
|
||||
posthog_distinct_id: Optional ID to associate with the usage event.
|
||||
posthog_trace_id: Optional trace UUID for linking events.
|
||||
posthog_properties: Optional dictionary of extra properties to include in the event.
|
||||
posthog_privacy_mode: Whether to anonymize the input and output.
|
||||
posthog_groups: Optional dictionary of groups to associate with the event.
|
||||
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's responses.parse call.
|
||||
"""
|
||||
return await call_llm_and_track_usage_async(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
self._original.parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class WrappedChat:
|
||||
"""Async wrapper for OpenAI chat that tracks usage in PostHog."""
|
||||
|
||||
+26
-130
@@ -1,5 +1,4 @@
|
||||
import atexit
|
||||
import hashlib
|
||||
import logging
|
||||
import numbers
|
||||
import os
|
||||
@@ -27,7 +26,6 @@ from posthog.request import (
|
||||
DEFAULT_HOST,
|
||||
APIError,
|
||||
batch_post,
|
||||
decide,
|
||||
determine_server_host,
|
||||
flags,
|
||||
get,
|
||||
@@ -58,78 +56,6 @@ except ImportError:
|
||||
ID_TYPES = (numbers.Number, string_types, UUID)
|
||||
MAX_DICT_SIZE = 50_000
|
||||
|
||||
# TODO: Get rid of these when you're done rolling out `/flags` to all customers
|
||||
ROLLOUT_PERCENTAGE = 1
|
||||
INCLUDED_HASHES = set(
|
||||
{"bc94e67150c97dbcbf52549d50a7b80814841dbf"}
|
||||
) # this is PostHog's API key
|
||||
# Explicitly excluding all the API tokens associated with the top 10 customers; we'll get to them soon, but don't want to rollout to them just yet
|
||||
EXCLUDED_HASHES = set(
|
||||
{
|
||||
"03005596796f9ee626e9596b8062972cb6a556a0",
|
||||
"05620a20b287e0d5cb1d4a0dd492797f36b952c5",
|
||||
"0f95b5ca12878693c01c6420e727904f1737caa7",
|
||||
"1212b6287a6e7e5ff6be5cb30ec563f35c2139d6",
|
||||
"171ec1bb2caf762e06b1fde2e36a38c4638691a8",
|
||||
"171faa9fc754b1aa42252a4eedb948b7c805d5cb",
|
||||
"178ddde3f628fb0030321387acf939e4e6946d35",
|
||||
"1790085d7e9aa136e8b73c180dd6a6060e2ef949",
|
||||
"1895a3349c2371559c886f19ef1bf60617a934e0",
|
||||
"1f01267d4f0295f88e8943bc963d816ee4abc84b",
|
||||
"213df54990a34e62e3570b430f7ee36ec0928743",
|
||||
"23d235537d988ab98ad259853eab02b07d828c2b",
|
||||
"27135f7ae8f936222a5fcfcdc75c139b27dd3254",
|
||||
"2817396d80fafc86c0816af8e73880f8b3e54320",
|
||||
"29d3235e63db42056858ef04c6a5488c2a459eaa",
|
||||
"2a76d9b5eb9307e540de9d516aa80f6cb5a0292f",
|
||||
"2a92965a1344ab8a1f7dac2507e858f579a88ac2",
|
||||
"2d5823818261512d616161de2bb8a161d48f1e35",
|
||||
"32942f6a879dbfa8011cc68288c098e4a76e6cc0",
|
||||
"3db6c17ab65827ceadf77d9a8462fabd94170ca6",
|
||||
"4975b24f9ced9b2c06b604ddc9612f663f9452d5",
|
||||
"497c7b017b13cd6cdbfe641c71f0dfb660a4c518",
|
||||
"49c79e1dbce4a7b9394d6c14bf0421e04cecb445",
|
||||
"4d63e1c5cd3a80972eac4e7526f03357ac538043",
|
||||
"4da0f42a6f8f116822411152e5cda3c65ed2561f",
|
||||
"4e494675ecd2b841784d6f29b658b38a0877a62e",
|
||||
"4e852d8422130cec991eca2d6416dbe321d0a689",
|
||||
"5120bfd92c9c6731074a89e4a82f49e947d34369",
|
||||
"512cd72f9aa7ab11dfd012cc2e19394a020bd9a8",
|
||||
"5b175d4064cc62f01118a2c6818c2c02fc8f27e1",
|
||||
"5ba4bba3979e97d2c84df2aba394ca29c6c43187",
|
||||
"639014946463614353ca640b268dc6592f62b652",
|
||||
"643b9be9d50104e2b4ba94bc56688adba69c80fe",
|
||||
"658f92992af9fc6a360143d72d93a36f63bbccb0",
|
||||
"673a59c99739dfcee35202e428dd020b94866d52",
|
||||
"67a9829b4997f5c6f3ab8173ad299f634adcfa53",
|
||||
"6d686043e914ae8275df65e1ad890bd32a3b6fdd",
|
||||
"6e4b5e1d649ad006d78f1f1617a9a0f35fc73078",
|
||||
"6f1fc3a8fa9df54d00cbc1ef9ad5f24640589fd0",
|
||||
"764e5fec2c7899cfee620fae8450fcc62cd72bf0",
|
||||
"80ea6d6ed9a5895633c7bee7aba4323eeacdc90e",
|
||||
"872e420156f583bc97351f3d83c02dae734a85df",
|
||||
"8a24844cbeae31e74b4372964cdea74e99d9c0e2",
|
||||
"975ae7330506d4583b000f96ad87abb41a0141ce",
|
||||
"9e3d71378b340def3080e0a3a785a1b964cf43ef",
|
||||
"9ede7b21365661331d024d92915de6e69749892b",
|
||||
"a1ed1b4216ef4cec542c6b3b676507770be24ddc",
|
||||
"a4f66a70a9647b3b89fc59f7642af8ffab073ba1",
|
||||
"a7adb80be9e90948ab6bb726cc6e8e52694aec74",
|
||||
"bca4b14ac8de49cccc02306c7bb6e5ae2acc0f72",
|
||||
"bde5fe49f61e13629c5498d7428a7f6215e482a6",
|
||||
"c54a7074c323aa7c5cb7b24bf826751b2a58f5d8",
|
||||
"c552d20da0c87fb4ebe2da97c7f95c05eef2bca1",
|
||||
"d7682f2d268f3064d433309af34f2935810989d2",
|
||||
"d794ac43d8be26bf99f369ea79501eb774fe1b16",
|
||||
"e0963e2552af77d46bb24d5b5806b5b456c64c5f",
|
||||
"e6f14b2100cb0598925958b097ace82486037a25",
|
||||
"e79ec399ad45f44a4295a5bb1322e2f14600ae39",
|
||||
"eecf29f73f9c31009e5737a6c5ec3f87ec5b8ea6",
|
||||
"f2c01f3cc770c7788257ee60910e2530f92eefc3",
|
||||
"f7bbc58f4122b1e2812c0f1962c584cb404a1ac3",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_os_info():
|
||||
"""
|
||||
@@ -185,43 +111,6 @@ def system_context() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def is_token_in_rollout(
|
||||
token: str,
|
||||
percentage: float = 0,
|
||||
included_hashes: Optional[set[str]] = None,
|
||||
excluded_hashes: Optional[set[str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Determines if a token should be included in a rollout based on:
|
||||
1. If its hash matches any included_hashes provided
|
||||
2. If its hash falls within the percentage rollout
|
||||
|
||||
Args:
|
||||
token: String to hash (usually API key)
|
||||
percentage: Float between 0 and 1 representing rollout percentage
|
||||
included_hashes: Optional set of specific SHA1 hashes to match against
|
||||
excluded_hashes: Optional set of specific SHA1 hashes to exclude from rollout
|
||||
Returns:
|
||||
bool: True if token should be included in rollout
|
||||
"""
|
||||
# First generate SHA1 hash of token
|
||||
token_hash = hashlib.sha1(token.encode("utf-8")).hexdigest()
|
||||
|
||||
# Check if hash matches any included hashes
|
||||
if included_hashes and token_hash in included_hashes:
|
||||
return True
|
||||
|
||||
# Check if hash matches any excluded hashes
|
||||
if excluded_hashes and token_hash in excluded_hashes:
|
||||
return False
|
||||
|
||||
# Convert first 8 chars of hash to int and divide by max value to get number between 0-1
|
||||
hash_int = int(token_hash[:8], 16)
|
||||
hash_float = hash_int / 0xFFFFFFFF
|
||||
|
||||
return hash_float < percentage
|
||||
|
||||
|
||||
class Client(object):
|
||||
"""Create a new PostHog client."""
|
||||
|
||||
@@ -255,6 +144,7 @@ class Client(object):
|
||||
exception_autocapture_integrations=None,
|
||||
project_root=None,
|
||||
privacy_mode=False,
|
||||
before_send=None,
|
||||
):
|
||||
self.queue = queue.Queue(max_queue_size)
|
||||
|
||||
@@ -310,6 +200,15 @@ class Client(object):
|
||||
else:
|
||||
self.log.setLevel(logging.WARNING)
|
||||
|
||||
if before_send is not None:
|
||||
if callable(before_send):
|
||||
self.before_send = before_send
|
||||
else:
|
||||
self.log.warning("before_send is not callable, it will be ignored")
|
||||
self.before_send = None
|
||||
else:
|
||||
self.before_send = None
|
||||
|
||||
if self.enable_exception_autocapture:
|
||||
self.exception_capture = ExceptionCapture(
|
||||
self, integrations=self.exception_autocapture_integrations
|
||||
@@ -475,28 +374,13 @@ class Client(object):
|
||||
"geoip_disable": disable_geoip,
|
||||
}
|
||||
|
||||
use_flags = is_token_in_rollout(
|
||||
resp_data = flags(
|
||||
self.api_key,
|
||||
ROLLOUT_PERCENTAGE,
|
||||
included_hashes=INCLUDED_HASHES,
|
||||
excluded_hashes=EXCLUDED_HASHES,
|
||||
self.host,
|
||||
timeout=self.feature_flags_request_timeout_seconds,
|
||||
**request_data,
|
||||
)
|
||||
|
||||
if use_flags:
|
||||
resp_data = flags(
|
||||
self.api_key,
|
||||
self.host,
|
||||
timeout=self.feature_flags_request_timeout_seconds,
|
||||
**request_data,
|
||||
)
|
||||
else:
|
||||
resp_data = decide(
|
||||
self.api_key,
|
||||
self.host,
|
||||
timeout=self.feature_flags_request_timeout_seconds,
|
||||
**request_data,
|
||||
)
|
||||
|
||||
return normalize_flags_response(resp_data)
|
||||
|
||||
def capture(
|
||||
@@ -870,6 +754,18 @@ class Client(object):
|
||||
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
|
||||
|
||||
msg = clean(msg)
|
||||
|
||||
if self.before_send:
|
||||
try:
|
||||
modified_msg = self.before_send(msg)
|
||||
if modified_msg is None:
|
||||
self.log.debug("Event dropped by before_send callback")
|
||||
return True, None
|
||||
msg = modified_msg
|
||||
except Exception as e:
|
||||
self.log.exception(f"Error in before_send callback: {e}")
|
||||
# Continue with the original message if callback fails
|
||||
|
||||
self.log.debug("queueing: %s", msg)
|
||||
|
||||
# if send is False, return msg as if it was successfully queued
|
||||
|
||||
@@ -67,8 +67,8 @@ class DjangoRequestExtractor:
|
||||
headers = self.headers()
|
||||
|
||||
# Extract traceparent and tracestate headers
|
||||
traceparent = headers.get("traceparent")
|
||||
tracestate = headers.get("tracestate")
|
||||
traceparent = headers.get("Traceparent")
|
||||
tracestate = headers.get("Tracestate")
|
||||
|
||||
# Extract the distinct_id from tracestate
|
||||
distinct_id = None
|
||||
@@ -80,12 +80,38 @@ class DjangoRequestExtractor:
|
||||
distinct_id = match.group(1)
|
||||
|
||||
return {
|
||||
**self.user(),
|
||||
"distinct_id": distinct_id,
|
||||
"ip": headers.get("X-Forwarded-For"),
|
||||
"user_agent": headers.get("User-Agent"),
|
||||
"traceparent": traceparent,
|
||||
"$request_path": self.request.path,
|
||||
}
|
||||
|
||||
def user(self):
|
||||
user_data: dict[str, str] = {}
|
||||
|
||||
user = getattr(self.request, "user", None)
|
||||
|
||||
if user is None or not user.is_authenticated:
|
||||
return user_data
|
||||
|
||||
try:
|
||||
user_id = str(user.pk)
|
||||
if user_id:
|
||||
user_data.setdefault("$user_id", user_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
email = str(user.email)
|
||||
if email:
|
||||
user_data.setdefault("email", email)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return user_data
|
||||
|
||||
def headers(self):
|
||||
# type: () -> Dict[str, str]
|
||||
return dict(self.request.headers)
|
||||
|
||||
+2
-2
@@ -39,7 +39,7 @@ def new_context(fresh=False):
|
||||
raise ValueError("Something went wrong")
|
||||
|
||||
"""
|
||||
import posthog
|
||||
from posthog import capture_exception
|
||||
|
||||
current_tags = _get_current_context().copy()
|
||||
current_stack = _context_stack.get()
|
||||
@@ -49,7 +49,7 @@ def new_context(fresh=False):
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
posthog.capture_exception(e)
|
||||
capture_exception(e)
|
||||
raise
|
||||
finally:
|
||||
_context_stack.reset(token)
|
||||
|
||||
@@ -4,7 +4,7 @@ from sentry_sdk.integrations import Integration
|
||||
from sentry_sdk.scope import add_global_event_processor
|
||||
from sentry_sdk.utils import Dsn
|
||||
|
||||
import posthog
|
||||
from posthog import capture, host
|
||||
from posthog.request import DEFAULT_HOST
|
||||
from posthog.sentry import POSTHOG_ID_TAG
|
||||
|
||||
@@ -34,7 +34,7 @@ class PostHogIntegration(Integration):
|
||||
if event.get("tags", {}).get(POSTHOG_ID_TAG):
|
||||
posthog_distinct_id = event["tags"][POSTHOG_ID_TAG]
|
||||
event["tags"]["PostHog URL"] = (
|
||||
f"{posthog.host or DEFAULT_HOST}/person/{posthog_distinct_id}"
|
||||
f"{host or DEFAULT_HOST}/person/{posthog_distinct_id}"
|
||||
)
|
||||
|
||||
properties = {
|
||||
@@ -52,6 +52,6 @@ class PostHogIntegration(Integration):
|
||||
f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={project_id}&query={event['event_id']}"
|
||||
)
|
||||
|
||||
posthog.capture(posthog_distinct_id, "$exception", properties)
|
||||
capture(posthog_distinct_id, "$exception", properties)
|
||||
|
||||
return event
|
||||
|
||||
@@ -26,6 +26,11 @@ try:
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
ResponseUsage,
|
||||
ParsedResponse,
|
||||
)
|
||||
from openai.types.responses.parsed_response import (
|
||||
ParsedResponseOutputMessage,
|
||||
ParsedResponseOutputText,
|
||||
)
|
||||
|
||||
from posthog.ai.openai import OpenAI
|
||||
@@ -115,6 +120,59 @@ def mock_openai_response_with_responses_api():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_parsed_response():
|
||||
return ParsedResponse(
|
||||
id="test",
|
||||
model="gpt-4o-2024-08-06",
|
||||
object="response",
|
||||
created_at=1741476542,
|
||||
status="completed",
|
||||
error=None,
|
||||
incomplete_details=None,
|
||||
instructions=None,
|
||||
max_output_tokens=None,
|
||||
tools=[],
|
||||
tool_choice="auto",
|
||||
output=[
|
||||
ParsedResponseOutputMessage(
|
||||
id="msg_123",
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[
|
||||
ParsedResponseOutputText(
|
||||
type="output_text",
|
||||
text='{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}',
|
||||
annotations=[],
|
||||
parsed={
|
||||
"name": "Science Fair",
|
||||
"date": "Friday",
|
||||
"participants": ["Alice", "Bob"],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
output_parsed={
|
||||
"name": "Science Fair",
|
||||
"date": "Friday",
|
||||
"participants": ["Alice", "Bob"],
|
||||
},
|
||||
parallel_tool_calls=True,
|
||||
previous_response_id=None,
|
||||
usage=ResponseUsage(
|
||||
input_tokens=15,
|
||||
output_tokens=20,
|
||||
input_tokens_details={"prompt_tokens": 15, "cached_tokens": 0},
|
||||
output_tokens_details={"reasoning_tokens": 5},
|
||||
total_tokens=35,
|
||||
),
|
||||
user=None,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedding_response():
|
||||
return CreateEmbeddingResponse(
|
||||
@@ -646,3 +704,73 @@ def test_responses_api(mock_client, mock_openai_response_with_responses_api):
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_responses_parse(mock_client, mock_parsed_response):
|
||||
with patch(
|
||||
"openai.resources.responses.Responses.parse",
|
||||
return_value=mock_parsed_response,
|
||||
):
|
||||
client = OpenAI(api_key="test-key", posthog_client=mock_client)
|
||||
response = client.responses.parse(
|
||||
model="gpt-4o-2024-08-06",
|
||||
input=[
|
||||
{"role": "system", "content": "Extract the event information."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Alice and Bob are going to a science fair on Friday.",
|
||||
},
|
||||
],
|
||||
text={
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "event",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"date": {"type": "string"},
|
||||
"participants": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": ["name", "date", "participants"],
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_parsed_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "openai"
|
||||
assert props["$ai_model"] == "gpt-4o-2024-08-06"
|
||||
assert props["$ai_input"] == [
|
||||
{"role": "system", "content": "Extract the event information."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Alice and Bob are going to a science fair on Friday.",
|
||||
},
|
||||
]
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": '{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}',
|
||||
}
|
||||
]
|
||||
assert props["$ai_input_tokens"] == 15
|
||||
assert props["$ai_output_tokens"] == 20
|
||||
assert props["$ai_reasoning_tokens"] == 5
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
from posthog.exception_integrations.django import DjangoRequestExtractor
|
||||
from django.test import RequestFactory
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
import django
|
||||
|
||||
DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
|
||||
|
||||
# setup a test app
|
||||
if not settings.configured:
|
||||
settings.configure(
|
||||
SECRET_KEY="test",
|
||||
DEFAULT_CHARSET="utf-8",
|
||||
INSTALLED_APPS=[
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
],
|
||||
DATABASES={
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": ":memory:",
|
||||
}
|
||||
},
|
||||
)
|
||||
django.setup()
|
||||
|
||||
call_command("migrate", verbosity=0, interactive=False)
|
||||
|
||||
|
||||
def mock_request_factory(override_headers):
|
||||
class Request:
|
||||
META = {}
|
||||
# TRICKY: Actual django request dict object has case insensitive matching, and strips http from the names
|
||||
headers = {
|
||||
factory = RequestFactory(
|
||||
headers={
|
||||
"User-Agent": DEFAULT_USER_AGENT,
|
||||
"Referrer": "http://example.com",
|
||||
"X-Forwarded-For": "193.4.5.12",
|
||||
**(override_headers or {}),
|
||||
}
|
||||
)
|
||||
|
||||
return Request()
|
||||
request = factory.get("/api/endpoint")
|
||||
return request
|
||||
|
||||
|
||||
def test_request_extractor_with_no_trace():
|
||||
@@ -25,6 +49,7 @@ def test_request_extractor_with_no_trace():
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"traceparent": None,
|
||||
"distinct_id": None,
|
||||
"$request_path": "/api/endpoint",
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +57,14 @@ def test_request_extractor_with_trace():
|
||||
request = mock_request_factory(
|
||||
{"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"}
|
||||
)
|
||||
|
||||
extractor = DjangoRequestExtractor(request)
|
||||
assert extractor.extract_person_data() == {
|
||||
"ip": "193.4.5.12",
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
|
||||
"distinct_id": None,
|
||||
"$request_path": "/api/endpoint",
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +81,7 @@ def test_request_extractor_with_tracestate():
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
|
||||
"distinct_id": "1234",
|
||||
"$request_path": "/api/endpoint",
|
||||
}
|
||||
|
||||
|
||||
@@ -67,4 +95,27 @@ def test_request_extractor_with_complicated_tracestate():
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"traceparent": None,
|
||||
"distinct_id": "alohaMountainsXUYZ",
|
||||
"$request_path": "/api/endpoint",
|
||||
}
|
||||
|
||||
|
||||
def test_request_extractor_with_request_user():
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
user = User.objects.create_user(
|
||||
username="test", email="test@posthog.com", password="top_secret"
|
||||
)
|
||||
|
||||
request = mock_request_factory(None)
|
||||
request.user = user
|
||||
|
||||
extractor = DjangoRequestExtractor(request)
|
||||
assert extractor.extract_person_data() == {
|
||||
"ip": "193.4.5.12",
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"traceparent": None,
|
||||
"distinct_id": None,
|
||||
"$request_path": "/api/endpoint",
|
||||
"email": "test@posthog.com",
|
||||
"$user_id": "1",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
|
||||
|
||||
class TestClient(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# This ensures no real HTTP POST requests are made
|
||||
cls.client_post_patcher = mock.patch("posthog.client.batch_post")
|
||||
cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post")
|
||||
cls.client_post_patcher.start()
|
||||
cls.consumer_post_patcher.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.client_post_patcher.stop()
|
||||
cls.consumer_post_patcher.stop()
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
print("FAIL", e, batch) # noqa: T201
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
def test_before_send_callback_modifies_event(self):
|
||||
"""Test that before_send callback can modify events."""
|
||||
processed_events = []
|
||||
|
||||
def my_before_send(event):
|
||||
processed_events.append(event.copy())
|
||||
if "properties" not in event:
|
||||
event["properties"] = {}
|
||||
event["properties"]["processed_by_before_send"] = True
|
||||
return event
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=my_before_send
|
||||
)
|
||||
success, msg = client.capture("user1", "test_event", {"original": "value"})
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(msg["properties"]["processed_by_before_send"], True)
|
||||
self.assertEqual(msg["properties"]["original"], "value")
|
||||
self.assertEqual(len(processed_events), 1)
|
||||
self.assertEqual(processed_events[0]["event"], "test_event")
|
||||
|
||||
def test_before_send_callback_drops_event(self):
|
||||
"""Test that before_send callback can drop events by returning None."""
|
||||
|
||||
def drop_test_events(event):
|
||||
if event.get("event") == "test_drop_me":
|
||||
return None
|
||||
return event
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=drop_test_events
|
||||
)
|
||||
|
||||
# Event should be dropped
|
||||
success, msg = client.capture("user1", "test_drop_me")
|
||||
self.assertTrue(success)
|
||||
self.assertIsNone(msg)
|
||||
|
||||
# Event should go through
|
||||
success, msg = client.capture("user1", "keep_me")
|
||||
self.assertTrue(success)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertEqual(msg["event"], "keep_me")
|
||||
|
||||
def test_before_send_callback_handles_exceptions(self):
|
||||
"""Test that exceptions in before_send don't crash the client."""
|
||||
|
||||
def buggy_before_send(event):
|
||||
raise ValueError("Oops!")
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=buggy_before_send
|
||||
)
|
||||
success, msg = client.capture("user1", "robust_event")
|
||||
|
||||
# Event should still be sent despite the exception
|
||||
self.assertTrue(success)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertEqual(msg["event"], "robust_event")
|
||||
|
||||
def test_before_send_callback_works_with_all_event_types(self):
|
||||
"""Test that before_send works with capture, identify, set, etc."""
|
||||
|
||||
def add_marker(event):
|
||||
if "properties" not in event:
|
||||
event["properties"] = {}
|
||||
event["properties"]["marked"] = True
|
||||
return event
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=add_marker
|
||||
)
|
||||
|
||||
# Test capture
|
||||
success, msg = client.capture("user1", "event")
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(msg["properties"]["marked"])
|
||||
|
||||
# Test identify
|
||||
success, msg = client.identify("user1", {"trait": "value"})
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(msg["properties"]["marked"])
|
||||
|
||||
# Test set
|
||||
success, msg = client.set("user1", {"prop": "value"})
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(msg["properties"]["marked"])
|
||||
|
||||
# Test page
|
||||
success, msg = client.page("user1", "https://example.com")
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(msg["properties"]["marked"])
|
||||
|
||||
def test_before_send_callback_disabled_when_none(self):
|
||||
"""Test that client works normally when before_send is None."""
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=None)
|
||||
success, msg = client.capture("user1", "normal_event")
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertEqual(msg["event"], "normal_event")
|
||||
|
||||
def test_before_send_callback_pii_scrubbing_example(self):
|
||||
"""Test a realistic PII scrubbing use case."""
|
||||
|
||||
def scrub_pii(event):
|
||||
properties = event.get("properties", {})
|
||||
|
||||
# Mask email but keep domain
|
||||
if "email" in properties:
|
||||
email = properties["email"]
|
||||
if "@" in email:
|
||||
domain = email.split("@")[1]
|
||||
properties["email"] = f"***@{domain}"
|
||||
else:
|
||||
properties["email"] = "***"
|
||||
|
||||
# Remove credit card
|
||||
properties.pop("credit_card", None)
|
||||
|
||||
return event
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=scrub_pii
|
||||
)
|
||||
success, msg = client.capture(
|
||||
"user1",
|
||||
"form_submit",
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"credit_card": "1234-5678-9012-3456",
|
||||
"form_name": "contact",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(msg["properties"]["email"], "***@example.com")
|
||||
self.assertNotIn("credit_card", msg["properties"])
|
||||
self.assertEqual(msg["properties"]["form_name"], "contact")
|
||||
@@ -1,4 +1,3 @@
|
||||
import hashlib
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
@@ -8,7 +7,7 @@ import mock
|
||||
import six
|
||||
from parameterized import parameterized
|
||||
|
||||
from posthog.client import EXCLUDED_HASHES, INCLUDED_HASHES, Client, is_token_in_rollout
|
||||
from posthog.client import Client
|
||||
from posthog.request import APIError
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.types import FeatureFlag, LegacyFlagMetadata
|
||||
@@ -1462,80 +1461,3 @@ class TestClient(unittest.TestCase):
|
||||
"errorsWhileComputingFlags": False,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_flags_decision_rollout(self, patch_flags, patch_decide):
|
||||
# Set up mock responses
|
||||
decide_response = {
|
||||
"featureFlags": {"flag1": True},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
flags_response = {
|
||||
"featureFlags": {"flag2": True},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
patch_decide.return_value = decide_response
|
||||
patch_flags.return_value = flags_response
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY)
|
||||
|
||||
# Test 100% rollout - should use flags
|
||||
with mock.patch(
|
||||
"posthog.client.is_token_in_rollout", return_value=True
|
||||
) as mock_rollout:
|
||||
client.get_flags_decision("distinct_id")
|
||||
mock_rollout.assert_called_with(
|
||||
FAKE_TEST_API_KEY,
|
||||
1,
|
||||
included_hashes=INCLUDED_HASHES,
|
||||
excluded_hashes=EXCLUDED_HASHES,
|
||||
)
|
||||
patch_flags.assert_called_once()
|
||||
patch_decide.assert_not_called()
|
||||
|
||||
def test_token_rollout_calculation(self):
|
||||
# Test specific hash inclusion
|
||||
token = "test_token"
|
||||
token_hash = hashlib.sha1(token.encode("utf-8")).hexdigest()
|
||||
included_hashes = {token_hash}
|
||||
|
||||
# Should be included due to specific hash, even with 0% rollout
|
||||
self.assertTrue(
|
||||
expr=is_token_in_rollout(
|
||||
token, percentage=0.0, included_hashes=included_hashes
|
||||
)
|
||||
)
|
||||
|
||||
# Should not be included with 0% rollout and no specific hash
|
||||
self.assertFalse(is_token_in_rollout(token, percentage=0.0))
|
||||
|
||||
# Should be included with 100% rollout regardless of specific hash
|
||||
self.assertTrue(is_token_in_rollout(token, percentage=1.0))
|
||||
self.assertTrue(
|
||||
is_token_in_rollout(token, percentage=1.0, included_hashes=included_hashes)
|
||||
)
|
||||
|
||||
# Test deterministic behavior - same token should always give same result
|
||||
hash_float = int(token_hash[:8], 16) / 0xFFFFFFFF
|
||||
percentage = hash_float + 0.1 # Just above the hash value
|
||||
|
||||
self.assertTrue(is_token_in_rollout(token, percentage))
|
||||
self.assertFalse(
|
||||
is_token_in_rollout(token, percentage - 0.2)
|
||||
) # Just below the hash value
|
||||
|
||||
# Test that the token exclusion works correctly
|
||||
self.assertFalse(
|
||||
is_token_in_rollout(token, percentage=1.0, excluded_hashes={token_hash})
|
||||
)
|
||||
|
||||
# Should work for other specific token hashes
|
||||
# Include our API key
|
||||
self.assertTrue(
|
||||
is_token_in_rollout(
|
||||
"sTMFPsFhdP1Ssg", percentage=0.1, included_hashes=INCLUDED_HASHES
|
||||
)
|
||||
)
|
||||
|
||||
+5
-1
@@ -1,9 +1,13 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, TypedDict, Union, cast
|
||||
from typing import Any, Callable, List, Optional, TypedDict, Union, cast
|
||||
|
||||
FlagValue = Union[bool, str]
|
||||
|
||||
# Type alias for the before_send callback function
|
||||
# Takes an event dictionary and returns the modified event or None to drop it
|
||||
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlagReason:
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
VERSION = "4.3.2"
|
||||
VERSION = "4.7.0"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
+10
-5
@@ -8,13 +8,14 @@ dynamic = ["version"]
|
||||
description = "Integrate PostHog into any python application."
|
||||
authors = [{ name = "PostHog", email = "hey@posthog.com" }]
|
||||
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
|
||||
license = { text = "MIT License" }
|
||||
license = {text = "MIT"}
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Operating System :: OS Independent",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
@@ -35,6 +36,8 @@ Homepage = "https://github.com/posthog/posthog-python"
|
||||
Repository = "https://github.com/posthog/posthog-python"
|
||||
|
||||
[project.optional-dependencies]
|
||||
sentry = ["sentry-sdk", "django"]
|
||||
langchain = ["langchain>=0.2.0"]
|
||||
dev = [
|
||||
"django-stubs",
|
||||
"lxml",
|
||||
@@ -48,6 +51,12 @@ dev = [
|
||||
"pre-commit",
|
||||
"pydantic",
|
||||
"ruff",
|
||||
"setuptools",
|
||||
"packaging",
|
||||
"wheel",
|
||||
"twine",
|
||||
"tomli",
|
||||
"tomli_w",
|
||||
]
|
||||
test = [
|
||||
"mock>=2.0.0",
|
||||
@@ -67,8 +76,6 @@ test = [
|
||||
"pydantic",
|
||||
"parameterized>=0.8.1",
|
||||
]
|
||||
sentry = ["sentry-sdk", "django"]
|
||||
langchain = ["langchain>=0.2.0"]
|
||||
|
||||
[tool.setuptools]
|
||||
packages = [
|
||||
@@ -83,8 +90,6 @@ packages = [
|
||||
"posthog.exception_integrations",
|
||||
]
|
||||
|
||||
license-files = []
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "posthog.version.VERSION" }
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[bdist_wheel]
|
||||
universal = 1
|
||||
|
||||
[tool:pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
|
||||
[flake8]
|
||||
# ignore E501 for line length
|
||||
# ignore W503 for line break before binary operator
|
||||
ignore = E501,W503
|
||||
max-line-length = 120
|
||||
@@ -28,7 +28,6 @@ setup(
|
||||
author_email="hey@posthog.com",
|
||||
maintainer="PostHog",
|
||||
maintainer_email="hey@posthog.com",
|
||||
test_suite="posthog.test.all",
|
||||
license="MIT License",
|
||||
description="Integrate PostHog into any python application.",
|
||||
long_description=long_description,
|
||||
|
||||
+34
-2
@@ -1,5 +1,8 @@
|
||||
import os
|
||||
import sys
|
||||
import tomli
|
||||
import tomli_w
|
||||
import shutil
|
||||
|
||||
try:
|
||||
from setuptools import setup
|
||||
@@ -7,9 +10,39 @@ except ImportError:
|
||||
from distutils.core import setup
|
||||
|
||||
# Don't import analytics-python module here, since deps may not be installed
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthog"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthoganalytics"))
|
||||
from version import VERSION # noqa: E402
|
||||
|
||||
|
||||
# Copy the original pyproject.toml as backup
|
||||
shutil.copy("pyproject.toml", "pyproject.toml.backup")
|
||||
|
||||
# Read the original pyproject.toml
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
config = tomli.load(f)
|
||||
|
||||
# Override specific values
|
||||
config["project"]["name"] = "posthoganalytics"
|
||||
config["tool"]["setuptools"]["dynamic"]["version"] = {
|
||||
"attr": "posthoganalytics.version.VERSION"
|
||||
}
|
||||
|
||||
# Rename packages from posthog.* to posthoganalytics.*
|
||||
if "packages" in config["tool"]["setuptools"]:
|
||||
new_packages = []
|
||||
for package in config["tool"]["setuptools"]["packages"]:
|
||||
if package == "posthog":
|
||||
new_packages.append("posthoganalytics")
|
||||
elif package.startswith("posthog."):
|
||||
new_packages.append(package.replace("posthog.", "posthoganalytics.", 1))
|
||||
else:
|
||||
new_packages.append(package)
|
||||
config["tool"]["setuptools"]["packages"] = new_packages
|
||||
|
||||
# Overwrite the original pyproject.toml
|
||||
with open("pyproject.toml", "wb") as f:
|
||||
tomli_w.dump(config, f)
|
||||
|
||||
long_description = """
|
||||
PostHog is developer-friendly, self-hosted product analytics.
|
||||
posthog-python is the python package.
|
||||
@@ -28,7 +61,6 @@ setup(
|
||||
author_email="hey@posthog.com",
|
||||
maintainer="PostHog",
|
||||
maintainer_email="hey@posthog.com",
|
||||
test_suite="posthog.test.all",
|
||||
license="MIT License",
|
||||
description="Integrate PostHog into any python application.",
|
||||
long_description=long_description,
|
||||
|
||||
Reference in New Issue
Block a user