Skip to content

Commit 461b029

Browse files
committed
feat: add before_send callback
1 parent 1db6e45 commit 461b029

File tree

4 files changed

+379
-2
lines changed

4 files changed

+379
-2
lines changed

BEFORE_SEND.md

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# Before Send Hook
2+
3+
The `before_send` parameter allows you to modify or filter events before they are sent to PostHog. This is useful for:
4+
5+
- **Privacy**: Removing or masking sensitive data (PII)
6+
- **Filtering**: Dropping unwanted events (test events, internal users, etc.)
7+
- **Enhancement**: Adding custom properties to all events
8+
- **Transformation**: Modifying event names or property formats
9+
10+
## Basic Usage
11+
12+
```python
13+
import posthog
14+
from typing import Optional, Dict, Any
15+
16+
def my_before_send(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
17+
"""
18+
Process event before sending to PostHog.
19+
20+
Args:
21+
event: The event dictionary containing 'event', 'distinct_id', 'properties', etc.
22+
23+
Returns:
24+
Modified event dictionary to send, or None to drop the event
25+
"""
26+
# Your processing logic here
27+
return event
28+
29+
# Initialize client with before_send hook
30+
client = posthog.Client(
31+
api_key="your-project-api-key",
32+
before_send=my_before_send
33+
)
34+
```
35+
36+
## Common Use Cases
37+
38+
### 1. Filter Out Events
39+
40+
```python
41+
from typing import Optional, Any
42+
43+
def filter_events_by_property_or_event_name(event: dict[str, Any]) -> Optional[dict[str, Any]]:
44+
"""Drop events from internal users or test environments."""
45+
properties = event.get("properties", {})
46+
47+
# Choose some property from your events
48+
event_source = properties.get("event_source", "")
49+
if event_source.endswith("internal"):
50+
return None # Drop the event
51+
52+
# Filter out test events
53+
if event.get("event") == "test_event":
54+
return None
55+
56+
return event
57+
```
58+
59+
### 2. Remove/Mask PII Data
60+
61+
```python
62+
from typing import Optional, Any
63+
64+
def scrub_pii(event: dict[str, Any]) -> Optional[dict[str, Any]]:
65+
"""Remove or mask personally identifiable information."""
66+
properties = event.get("properties", {})
67+
68+
# Mask email but keep domain for analytics
69+
if "email" in properties:
70+
email = properties["email"]
71+
if "@" in email:
72+
domain = email.split("@")[1]
73+
properties["email"] = f"***@{domain}"
74+
else:
75+
properties["email"] = "***"
76+
77+
# Remove sensitive fields entirely
78+
sensitive_fields = ["my_business_info", "secret_things"]
79+
for field in sensitive_fields:
80+
properties.pop(field, None)
81+
82+
return event
83+
```
84+
85+
### 3. Add Custom Properties
86+
87+
```python
88+
from typing import Optional, Any
89+
90+
def add_context(event: dict[str, Any]) -> Optional[dict[str, Any]]:
91+
"""Add custom properties to all events."""
92+
if "properties" not in event:
93+
event["properties"] = {}
94+
95+
event["properties"].update({
96+
"app_version": "2.1.0",
97+
"environment": "production",
98+
"processed_at": datetime.now().isoformat()
99+
})
100+
101+
return event
102+
```
103+
104+
### 4. Transform Event Names
105+
106+
```python
107+
from typing import Optional, Any
108+
109+
def normalize_event_names(event: dict[str, Any]) -> Optional[dict[str, Any]]:
110+
"""Convert event names to a consistent format."""
111+
original_event = event.get("event")
112+
if original_event:
113+
# Convert to snake_case
114+
normalized = original_event.lower().replace(" ", "_").replace("-", "_")
115+
event["event"] = f"app_{normalized}"
116+
117+
return event
118+
```
119+
120+
### 5. Combined Processing
121+
122+
```python
123+
from typing import Optional, Any
124+
125+
def comprehensive_processor(event: dict[str, Any]) -> Optional[dict[str, Any]]:
126+
"""Apply multiple transformations in sequence."""
127+
128+
# Step 1: Filter unwanted events
129+
if should_drop_event(event):
130+
return None
131+
132+
# Step 2: Scrub PII
133+
event = scrub_pii(event)
134+
135+
# Step 3: Add context
136+
event = add_context(event)
137+
138+
# Step 4: Normalize names
139+
event = normalize_event_names(event)
140+
141+
return event
142+
143+
def should_drop_event(event: dict[str, Any]) -> bool:
144+
"""Determine if event should be dropped."""
145+
# Your filtering logic
146+
return False
147+
```
148+
149+
## Error Handling
150+
151+
If your `before_send` function raises an exception, PostHog will:
152+
153+
1. Log the error
154+
2. Continue with the original, unmodified event
155+
3. Not crash your application
156+
157+
```python
158+
from typing import Optional, Any
159+
160+
def risky_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
161+
# If this raises an exception, the original event will be sent
162+
risky_operation()
163+
return event
164+
```
165+
## Complete Example
166+
167+
```python
168+
import posthog
169+
from typing import Optional, Any
170+
import re
171+
172+
def production_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
173+
try:
174+
properties = event.get("properties", {})
175+
176+
# 1. Filter out bot traffic
177+
user_agent = properties.get("$user_agent", "")
178+
if re.search(r'bot|crawler|spider', user_agent, re.I):
179+
return None
180+
181+
# 2. Filter out internal traffic
182+
ip = properties.get("$ip", "")
183+
if ip.startswith("192.168.") or ip.startswith("10."):
184+
return None
185+
186+
# 3. Scrub email PII but keep domain
187+
if "email" in properties:
188+
email = properties["email"]
189+
if "@" in email:
190+
domain = email.split("@")[1]
191+
properties["email"] = f"***@{domain}"
192+
193+
# 4. Add custom context
194+
properties.update({
195+
"app_version": "1.0.0",
196+
"build_number": "123"
197+
})
198+
199+
# 5. Normalize event name
200+
if event.get("event"):
201+
event["event"] = event["event"].lower().replace(" ", "_")
202+
203+
return event
204+
205+
except Exception as e:
206+
# Log error but don't crash
207+
print(f"Error in before_send: {e}")
208+
return event # Return original event on error
209+
210+
# Usage
211+
client = posthog.Client(
212+
api_key="your-api-key",
213+
before_send=production_before_send
214+
)
215+
216+
# All events will now be processed by your before_send function
217+
client.capture("user_123", "Page View", {"url": "/home"})
218+
```

posthog/client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
)
3434
from posthog.scopes import get_tags
3535
from posthog.types import (
36+
BeforeSendCallback,
3637
FeatureFlag,
3738
FeatureFlagResult,
3839
FlagMetadata,
@@ -144,6 +145,7 @@ def __init__(
144145
exception_autocapture_integrations=None,
145146
project_root=None,
146147
privacy_mode=False,
148+
before_send: Optional[BeforeSendCallback] = None,
147149
):
148150
self.queue = queue.Queue(max_queue_size)
149151

@@ -180,6 +182,7 @@ def __init__(
180182
self.exception_autocapture_integrations = exception_autocapture_integrations
181183
self.exception_capture = None
182184
self.privacy_mode = privacy_mode
185+
self.before_send = before_send
183186

184187
if project_root is None:
185188
try:
@@ -744,6 +747,18 @@ def _enqueue(self, msg, disable_geoip):
744747
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
745748

746749
msg = clean(msg)
750+
751+
if self.before_send:
752+
try:
753+
modified_msg = self.before_send(msg)
754+
if modified_msg is None:
755+
self.log.debug("Event dropped by before_send callback")
756+
return True, None
757+
msg = modified_msg
758+
except Exception as e:
759+
self.log.exception(f"Error in before_send callback: {e}")
760+
# Continue with the original message if callback fails
761+
747762
self.log.debug("queueing: %s", msg)
748763

749764
# if send is False, return msg as if it was successfully queued

0 commit comments

Comments
 (0)