Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52df246a3e | ||
|
|
f1f9ecf7a4 | ||
|
|
9db1b7e9f3 | ||
|
|
01751d1205 | ||
|
|
4426dd9d27 | ||
|
|
bf0d7efbfe | ||
|
|
f17ebfa12b | ||
|
|
800527da43 | ||
|
|
0d29fb7be3 |
@@ -0,0 +1,17 @@
|
||||
# This workflow is used to call the flags-project-board workflow when a pull request is opened, ready for review, review requested, synchronized, converted to draft, or reopened.
|
||||
# It is used to update the feature flags project board with the pull request information.
|
||||
|
||||
name: Call Feature Flags Project Workflow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, review_requested, synchronize, converted_to_draft, reopened]
|
||||
|
||||
jobs:
|
||||
call-flags-project:
|
||||
uses: PostHog/.github/.github/workflows/flags-project-board.yml@main
|
||||
with:
|
||||
pr_number: ${{ github.event.pull_request.number }}
|
||||
pr_node_id: ${{ github.event.pull_request.node_id }}
|
||||
is_draft: ${{ github.event.pull_request.draft }}
|
||||
secrets: inherit
|
||||
+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"})
|
||||
```
|
||||
+46
-13
@@ -1,6 +1,40 @@
|
||||
## 4.4.1 and 4.4.2- 2025-06-07
|
||||
## 4.9.0 - 2025-06-13
|
||||
|
||||
- empty point release to fix the posthog_analytics release
|
||||
- feat(ai): track reasoning and cache tokens in the LangChain callback
|
||||
|
||||
## 4.8.0 - 2025-06-10
|
||||
|
||||
- fix: export scoped, rather than tracked, decorator
|
||||
- feat: allow use of contexts without error tracking
|
||||
|
||||
## 4.7.0 - 2025-06-10
|
||||
|
||||
- feat: add support for parse endpoint in responses API (no longer beta)
|
||||
|
||||
## 4.6.2 - 2025-06-09
|
||||
|
||||
- fix: replace `import posthog` with direct method imports
|
||||
|
||||
## 4.6.1 - 2025-06-09
|
||||
|
||||
- fix: replace `import posthog` in `posthoganalytics` package
|
||||
|
||||
## 4.6.0 - 2025-06-09
|
||||
|
||||
- feat: add additional user and request context to captured exceptions via the Django integration
|
||||
- feat: Add `setup()` function to initialise default client
|
||||
|
||||
## 4.5.0 - 2025-06-09
|
||||
|
||||
- feat: add before_send callback (#249)
|
||||
|
||||
## 4.4.2- 2025-06-09
|
||||
|
||||
- empty point release to fix release automation
|
||||
|
||||
## 4.4.1 2025-06-09
|
||||
|
||||
- empty point release to fix release automation
|
||||
|
||||
## 4.4.0 - 2025-06-09
|
||||
|
||||
@@ -8,19 +42,18 @@
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+7
-3
@@ -15,7 +15,7 @@ new_context = new_context
|
||||
tag = tag
|
||||
get_tags = get_tags
|
||||
clear_tags = clear_tags
|
||||
tracked = scoped
|
||||
scoped = scoped
|
||||
|
||||
"""Settings."""
|
||||
api_key = None # type: Optional[str]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
@@ -569,9 +568,14 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
event_properties["$ai_is_error"] = True
|
||||
else:
|
||||
# Add usage
|
||||
input_tokens, output_tokens = _parse_usage(output)
|
||||
event_properties["$ai_input_tokens"] = input_tokens
|
||||
event_properties["$ai_output_tokens"] = output_tokens
|
||||
usage = _parse_usage(output)
|
||||
event_properties["$ai_input_tokens"] = usage.input_tokens
|
||||
event_properties["$ai_output_tokens"] = usage.output_tokens
|
||||
event_properties["$ai_cache_creation_input_tokens"] = (
|
||||
usage.cache_write_tokens
|
||||
)
|
||||
event_properties["$ai_cache_read_input_tokens"] = usage.cache_read_tokens
|
||||
event_properties["$ai_reasoning_tokens"] = usage.reasoning_tokens
|
||||
|
||||
# Generation results
|
||||
generation_result = output.generations[-1]
|
||||
@@ -647,9 +651,18 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
|
||||
return message_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelUsage:
|
||||
input_tokens: Optional[int]
|
||||
output_tokens: Optional[int]
|
||||
cache_write_tokens: Optional[int]
|
||||
cache_read_tokens: Optional[int]
|
||||
reasoning_tokens: Optional[int]
|
||||
|
||||
|
||||
def _parse_usage_model(
|
||||
usage: Union[BaseModel, Dict],
|
||||
) -> Tuple[Union[int, None], Union[int, None]]:
|
||||
usage: Union[BaseModel, dict],
|
||||
) -> ModelUsage:
|
||||
if isinstance(usage, BaseModel):
|
||||
usage = usage.__dict__
|
||||
|
||||
@@ -657,15 +670,23 @@ def _parse_usage_model(
|
||||
# https://pypi.org/project/langchain-anthropic/ (works also for Bedrock-Anthropic)
|
||||
("input_tokens", "input"),
|
||||
("output_tokens", "output"),
|
||||
("cache_creation_input_tokens", "cache_write"),
|
||||
("cache_read_input_tokens", "cache_read"),
|
||||
# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/get-token-count
|
||||
("prompt_token_count", "input"),
|
||||
("candidates_token_count", "output"),
|
||||
("cached_content_token_count", "cache_read"),
|
||||
("thoughts_token_count", "reasoning"),
|
||||
# Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/monitoring-cw.html#runtime-cloudwatch-metrics
|
||||
("inputTokenCount", "input"),
|
||||
("outputTokenCount", "output"),
|
||||
("cacheCreationInputTokenCount", "cache_write"),
|
||||
("cacheReadInputTokenCount", "cache_read"),
|
||||
# Bedrock Anthropic
|
||||
("prompt_tokens", "input"),
|
||||
("completion_tokens", "output"),
|
||||
("cache_creation_input_tokens", "cache_write"),
|
||||
("cache_read_input_tokens", "cache_read"),
|
||||
# langchain-ibm https://pypi.org/project/langchain-ibm/
|
||||
("input_token_count", "input"),
|
||||
("generated_token_count", "output"),
|
||||
@@ -683,13 +704,45 @@ def _parse_usage_model(
|
||||
|
||||
parsed_usage[type_key] = final_count
|
||||
|
||||
return parsed_usage.get("input"), parsed_usage.get("output")
|
||||
# Caching (OpenAI & langchain 0.3.9+)
|
||||
if "input_token_details" in usage and isinstance(
|
||||
usage["input_token_details"], dict
|
||||
):
|
||||
parsed_usage["cache_write"] = usage["input_token_details"].get("cache_creation")
|
||||
parsed_usage["cache_read"] = usage["input_token_details"].get("cache_read")
|
||||
|
||||
# Reasoning (OpenAI & langchain 0.3.9+)
|
||||
if "output_token_details" in usage and isinstance(
|
||||
usage["output_token_details"], dict
|
||||
):
|
||||
parsed_usage["reasoning"] = usage["output_token_details"].get("reasoning")
|
||||
|
||||
field_mapping = {
|
||||
"input": "input_tokens",
|
||||
"output": "output_tokens",
|
||||
"cache_write": "cache_write_tokens",
|
||||
"cache_read": "cache_read_tokens",
|
||||
"reasoning": "reasoning_tokens",
|
||||
}
|
||||
return ModelUsage(
|
||||
**{
|
||||
dataclass_key: parsed_usage.get(mapped_key) or 0
|
||||
for mapped_key, dataclass_key in field_mapping.items()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parse_usage(response: LLMResult):
|
||||
def _parse_usage(response: LLMResult) -> ModelUsage:
|
||||
# langchain-anthropic uses the usage field
|
||||
llm_usage_keys = ["token_usage", "usage"]
|
||||
llm_usage: Tuple[Union[int, None], Union[int, None]] = (None, None)
|
||||
llm_usage: ModelUsage = ModelUsage(
|
||||
input_tokens=None,
|
||||
output_tokens=None,
|
||||
cache_write_tokens=None,
|
||||
cache_read_tokens=None,
|
||||
reasoning_tokens=None,
|
||||
)
|
||||
|
||||
if response.llm_output is not None:
|
||||
for key in llm_usage_keys:
|
||||
if response.llm_output.get(key):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -144,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)
|
||||
|
||||
@@ -199,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
|
||||
@@ -744,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)
|
||||
|
||||
+31
-26
@@ -12,34 +12,37 @@ def _get_current_context() -> Dict[str, Any]:
|
||||
|
||||
|
||||
@contextmanager
|
||||
def new_context(fresh=False):
|
||||
def new_context(fresh=False, capture_exceptions=True):
|
||||
"""
|
||||
Create a new context scope that will be active for the duration of the with block.
|
||||
Any tags set within this scope will be isolated to this context. Any exceptions raised
|
||||
Create a new context scope that will be active for the duration of the with block.
|
||||
Any tags set within this scope will be isolated to this context. Any exceptions raised
|
||||
or events captured within the context will be tagged with the context tags.
|
||||
|
||||
Args:
|
||||
fresh: Whether to start with a fresh context (default: False).
|
||||
If False, inherits tags from parent context.
|
||||
If True, starts with no tags.
|
||||
|
||||
Examples:
|
||||
# Inherit parent context tags
|
||||
with posthog.new_context():
|
||||
posthog.tag("request_id", "123")
|
||||
# Both this event and the exception will be tagged with the context tags
|
||||
posthog.capture("event_name", {"property": "value"})
|
||||
raise ValueError("Something went wrong")
|
||||
|
||||
# Start with fresh context (no inherited tags)
|
||||
with posthog.new_context(fresh=True):
|
||||
posthog.tag("request_id", "123")
|
||||
# Both this event and the exception will be tagged with the context tags
|
||||
posthog.capture("event_name", {"property": "value"})
|
||||
raise ValueError("Something went wrong")
|
||||
Args:
|
||||
fresh: Whether to start with a fresh context (default: False).
|
||||
If False, inherits tags from parent context.
|
||||
If True, starts with no tags.
|
||||
capture_exceptions: Whether to capture exceptions raised within the context (default: True).
|
||||
If True, captures exceptions and tags them with the context tags before propagating them.
|
||||
If False, exceptions will propagate without being tagged or captured.
|
||||
|
||||
Examples:
|
||||
# Inherit parent context tags
|
||||
with posthog.new_context():
|
||||
posthog.tag("request_id", "123")
|
||||
# Both this event and the exception will be tagged with the context tags
|
||||
posthog.capture("event_name", {"property": "value"})
|
||||
raise ValueError("Something went wrong")
|
||||
|
||||
# Start with fresh context (no inherited tags)
|
||||
with posthog.new_context(fresh=True):
|
||||
posthog.tag("request_id", "123")
|
||||
# Both this event and the exception will be tagged with the context tags
|
||||
posthog.capture("event_name", {"property": "value"})
|
||||
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 +52,8 @@ def new_context(fresh=False):
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
posthog.capture_exception(e)
|
||||
if capture_exceptions:
|
||||
capture_exception(e)
|
||||
raise
|
||||
finally:
|
||||
_context_stack.reset(token)
|
||||
@@ -88,13 +92,14 @@ def clear_tags() -> None:
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def scoped(fresh=False):
|
||||
def scoped(fresh=False, capture_exceptions=True):
|
||||
"""
|
||||
Decorator that creates a new context for the function. Simply wraps
|
||||
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 posthog error tracking (default: True)
|
||||
|
||||
Example:
|
||||
@posthog.scoped()
|
||||
@@ -114,7 +119,7 @@ def scoped(fresh=False):
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
with new_context(fresh=fresh):
|
||||
with new_context(fresh=fresh, capture_exceptions=capture_exceptions):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return cast(F, wrapper)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1378,11 +1378,11 @@ def test_langgraph_agent(mock_client):
|
||||
)
|
||||
graph.invoke(inputs, config={"callbacks": [cb]})
|
||||
calls = [call[1] for call in mock_client.capture.call_args_list]
|
||||
assert len(calls) == 21
|
||||
assert len(calls) == 15
|
||||
for call in calls:
|
||||
assert call["properties"]["$ai_trace_id"] == "test-trace-id"
|
||||
assert len([call for call in calls if call["event"] == "$ai_generation"]) == 2
|
||||
assert len([call for call in calls if call["event"] == "$ai_span"]) == 18
|
||||
assert len([call for call in calls if call["event"] == "$ai_span"]) == 12
|
||||
assert len([call for call in calls if call["event"] == "$ai_trace"]) == 1
|
||||
|
||||
|
||||
@@ -1435,11 +1435,13 @@ def test_span_set_parent_ids_for_third_level_run(mock_client, trace_id):
|
||||
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
span2, span1, trace = [
|
||||
call[1]["properties"] for call in mock_client.capture.call_args_list
|
||||
]
|
||||
assert span2["$ai_parent_id"] == span1["$ai_span_id"]
|
||||
assert span1["$ai_parent_id"] == trace["$ai_trace_id"]
|
||||
calls = mock_client.capture.call_args_list
|
||||
span_props_2 = calls[0][1]["properties"]
|
||||
span_props_1 = calls[1][1]["properties"]
|
||||
trace_props = calls[2][1]["properties"]
|
||||
|
||||
assert span_props_2["$ai_parent_id"] == span_props_1["$ai_span_id"]
|
||||
assert span_props_1["$ai_parent_id"] == trace_props["$ai_trace_id"]
|
||||
|
||||
|
||||
def test_captures_error_with_details_in_span(mock_client):
|
||||
@@ -1478,3 +1480,250 @@ def test_captures_error_without_details_in_span(mock_client):
|
||||
== "ValueError"
|
||||
)
|
||||
assert mock_client.capture.call_args_list[1][1]["properties"]["$ai_is_error"]
|
||||
|
||||
|
||||
def test_openai_reasoning_tokens(mock_client):
|
||||
"""Test that OpenAI reasoning tokens are captured correctly."""
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[("user", "Think step by step about this problem")]
|
||||
)
|
||||
|
||||
# Mock response with reasoning tokens in output_token_details
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="Let me think through this step by step...",
|
||||
usage_metadata={
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 25,
|
||||
"total_tokens": 35,
|
||||
"output_token_details": {"reasoning": 15}, # 15 reasoning tokens
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Let me think through this step by step..."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
assert generation_props["$ai_input_tokens"] == 10
|
||||
assert generation_props["$ai_output_tokens"] == 25
|
||||
assert generation_props["$ai_reasoning_tokens"] == 15
|
||||
|
||||
|
||||
def test_anthropic_cache_write_and_read_tokens(mock_client):
|
||||
"""Test that Anthropic cache creation and read tokens are captured correctly."""
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "Analyze this large document")])
|
||||
|
||||
# First call with cache creation
|
||||
model_write = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="I've analyzed the document and cached the context.",
|
||||
usage_metadata={
|
||||
"total_tokens": 1050,
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 50,
|
||||
"cache_creation_input_tokens": 800, # Anthropic cache write
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model_write
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "I've analyzed the document and cached the context."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
assert generation_props["$ai_input_tokens"] == 1000
|
||||
assert generation_props["$ai_output_tokens"] == 50
|
||||
assert generation_props["$ai_cache_creation_input_tokens"] == 800
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 0
|
||||
assert generation_props["$ai_reasoning_tokens"] == 0
|
||||
|
||||
# Reset mock for second call
|
||||
mock_client.reset_mock()
|
||||
|
||||
# Second call with cache read
|
||||
model_read = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="Using cached analysis to provide quick response.",
|
||||
usage_metadata={
|
||||
"input_tokens": 200,
|
||||
"output_tokens": 30,
|
||||
"total_tokens": 1030,
|
||||
"cache_read_input_tokens": 800, # Anthropic cache read
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
chain = prompt | model_read
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Using cached analysis to provide quick response."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
assert generation_props["$ai_input_tokens"] == 200
|
||||
assert generation_props["$ai_output_tokens"] == 30
|
||||
assert generation_props["$ai_cache_creation_input_tokens"] == 0
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 800
|
||||
assert generation_props["$ai_reasoning_tokens"] == 0
|
||||
|
||||
|
||||
def test_openai_cache_read_tokens(mock_client):
|
||||
"""Test that OpenAI cache read tokens are captured correctly."""
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[("user", "Use the cached prompt for this request")]
|
||||
)
|
||||
|
||||
# Mock response with cache read tokens in input_token_details
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="Response using cached prompt context.",
|
||||
usage_metadata={
|
||||
"input_tokens": 150,
|
||||
"output_tokens": 40,
|
||||
"total_tokens": 190,
|
||||
"input_token_details": {
|
||||
"cache_read": 100, # 100 tokens read from cache
|
||||
"cache_creation": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Response using cached prompt context."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
assert generation_props["$ai_input_tokens"] == 150
|
||||
assert generation_props["$ai_output_tokens"] == 40
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 100
|
||||
assert generation_props["$ai_cache_creation_input_tokens"] == 0
|
||||
assert generation_props["$ai_reasoning_tokens"] == 0
|
||||
|
||||
|
||||
def test_openai_cache_creation_tokens(mock_client):
|
||||
"""Test that OpenAI cache creation tokens are captured correctly."""
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[("user", "Create a cache for this large prompt context")]
|
||||
)
|
||||
|
||||
# Mock response with cache creation tokens in input_token_details
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="Created cache for the prompt context.",
|
||||
usage_metadata={
|
||||
"input_tokens": 2000,
|
||||
"output_tokens": 25,
|
||||
"total_tokens": 2025,
|
||||
"input_token_details": {
|
||||
"cache_creation": 1500, # 1500 tokens written to cache
|
||||
"cache_read": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Created cache for the prompt context."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
assert generation_props["$ai_input_tokens"] == 2000
|
||||
assert generation_props["$ai_output_tokens"] == 25
|
||||
assert generation_props["$ai_cache_creation_input_tokens"] == 1500
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 0
|
||||
assert generation_props["$ai_reasoning_tokens"] == 0
|
||||
|
||||
|
||||
def test_combined_reasoning_and_cache_tokens(mock_client):
|
||||
"""Test that both reasoning tokens and cache tokens can be captured together."""
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[("user", "Think through this cached problem")]
|
||||
)
|
||||
|
||||
# Mock response with both reasoning and cache tokens
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="Let me reason through this using cached context...",
|
||||
usage_metadata={
|
||||
"input_tokens": 500,
|
||||
"output_tokens": 100,
|
||||
"total_tokens": 600,
|
||||
"input_token_details": {"cache_read": 300, "cache_creation": 0},
|
||||
"output_token_details": {"reasoning": 60}, # 60 reasoning tokens
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Let me reason through this using cached context..."
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
generation_args = mock_client.capture.call_args_list[1][1]
|
||||
generation_props = generation_args["properties"]
|
||||
|
||||
assert generation_args["event"] == "$ai_generation"
|
||||
assert generation_props["$ai_input_tokens"] == 500
|
||||
assert generation_props["$ai_output_tokens"] == 100
|
||||
assert generation_props["$ai_cache_read_input_tokens"] == 300
|
||||
assert generation_props["$ai_cache_creation_input_tokens"] == 0
|
||||
assert generation_props["$ai_reasoning_tokens"] == 60
|
||||
|
||||
|
||||
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OPENAI_API_KEY is not set")
|
||||
def test_openai_reasoning_tokens(mock_client):
|
||||
model = ChatOpenAI(
|
||||
api_key=OPENAI_API_KEY, model="o4-mini", max_completion_tokens=10
|
||||
)
|
||||
cb = CallbackHandler(
|
||||
mock_client, trace_id="test-trace-id", distinct_id="test-distinct-id"
|
||||
)
|
||||
model.invoke("what is the weather in sf", config={"callbacks": [cb]})
|
||||
call = mock_client.capture.call_args_list[0][1]
|
||||
assert call["properties"]["$ai_reasoning_tokens"] is not None
|
||||
assert call["properties"]["$ai_input_tokens"] is not None
|
||||
assert call["properties"]["$ai_output_tokens"] is not None
|
||||
|
||||
@@ -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
|
||||
|
||||
+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.4.2"
|
||||
VERSION = "4.9.0"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
+5
-4
@@ -68,10 +68,11 @@ test = [
|
||||
"django",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"langgraph",
|
||||
"langchain-community>=0.2.0",
|
||||
"langchain-openai>=0.2.0",
|
||||
"langchain-anthropic>=0.2.0",
|
||||
"langgraph>=0.4.8",
|
||||
"langchain-core>=0.3.65",
|
||||
"langchain-community>=0.3.25",
|
||||
"langchain-openai>=0.3.22",
|
||||
"langchain-anthropic>=0.3.15",
|
||||
"google-genai",
|
||||
"pydantic",
|
||||
"parameterized>=0.8.1",
|
||||
|
||||
Reference in New Issue
Block a user