Skip to content

Commit 800527d

Browse files
authored
feat: add before_send callback (#249)
1 parent 0d29fb7 commit 800527d

File tree

6 files changed

+440
-2
lines changed

6 files changed

+440
-2
lines changed

BEFORE_SEND.md

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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+
from datetime import datetime
91+
from typing import Optional, Any
92+
93+
def add_context(event: dict[str, Any]) -> Optional[dict[str, Any]]:
94+
"""Add custom properties to all events."""
95+
if "properties" not in event:
96+
event["properties"] = {}
97+
98+
event["properties"].update({
99+
"app_version": "2.1.0",
100+
"environment": "production",
101+
"processed_at": datetime.now().isoformat()
102+
})
103+
104+
return event
105+
```
106+
107+
### 4. Transform Event Names
108+
109+
```python
110+
from typing import Optional, Any
111+
112+
def normalize_event_names(event: dict[str, Any]) -> Optional[dict[str, Any]]:
113+
"""Convert event names to a consistent format."""
114+
original_event = event.get("event")
115+
if original_event:
116+
# Convert to snake_case
117+
normalized = original_event.lower().replace(" ", "_").replace("-", "_")
118+
event["event"] = f"app_{normalized}"
119+
120+
return event
121+
```
122+
123+
### 5. Log and drop in "dev" mode
124+
125+
When running in local dev often, you want to log but drop all events
126+
127+
128+
```python
129+
from typing import Optional, Any
130+
131+
def log_and_drop_all(event: dict[str, Any]) -> Optional[dict[str, Any]]:
132+
"""Convert event names to a consistent format."""
133+
print(event)
134+
135+
return None
136+
```
137+
138+
### 6. Combined Processing
139+
140+
```python
141+
from typing import Optional, Any
142+
143+
def comprehensive_processor(event: dict[str, Any]) -> Optional[dict[str, Any]]:
144+
"""Apply multiple transformations in sequence."""
145+
146+
# Step 1: Filter unwanted events
147+
if should_drop_event(event):
148+
return None
149+
150+
# Step 2: Scrub PII
151+
event = scrub_pii(event)
152+
153+
# Step 3: Add context
154+
event = add_context(event)
155+
156+
# Step 4: Normalize names
157+
event = normalize_event_names(event)
158+
159+
return event
160+
161+
def should_drop_event(event: dict[str, Any]) -> bool:
162+
"""Determine if event should be dropped."""
163+
# Your filtering logic
164+
return False
165+
```
166+
167+
## Error Handling
168+
169+
If your `before_send` function raises an exception, PostHog will:
170+
171+
1. Log the error
172+
2. Continue with the original, unmodified event
173+
3. Not crash your application
174+
175+
```python
176+
from typing import Optional, Any
177+
178+
def risky_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
179+
# If this raises an exception, the original event will be sent
180+
risky_operation()
181+
return event
182+
```
183+
184+
## Complete Example
185+
186+
```python
187+
import posthog
188+
from typing import Optional, Any
189+
import re
190+
191+
def production_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
192+
try:
193+
properties = event.get("properties", {})
194+
195+
# 1. Filter out bot traffic
196+
user_agent = properties.get("$user_agent", "")
197+
if re.search(r'bot|crawler|spider', user_agent, re.I):
198+
return None
199+
200+
# 2. Filter out internal traffic
201+
ip = properties.get("$ip", "")
202+
if ip.startswith("192.168.") or ip.startswith("10."):
203+
return None
204+
205+
# 3. Scrub email PII but keep domain
206+
if "email" in properties:
207+
email = properties["email"]
208+
if "@" in email:
209+
domain = email.split("@")[1]
210+
properties["email"] = f"***@{domain}"
211+
212+
# 4. Add custom context
213+
properties.update({
214+
"app_version": "1.0.0",
215+
"build_number": "123"
216+
})
217+
218+
# 5. Normalize event name
219+
if event.get("event"):
220+
event["event"] = event["event"].lower().replace(" ", "_")
221+
222+
return event
223+
224+
except Exception as e:
225+
# Log error but don't crash
226+
print(f"Error in before_send: {e}")
227+
return event # Return original event on error
228+
229+
# Usage
230+
client = posthog.Client(
231+
api_key="your-api-key",
232+
before_send=production_before_send
233+
)
234+
235+
# All events will now be processed by your before_send function
236+
client.capture("user_123", "Page View", {"url": "/home"})
237+
```

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 4.5.0- 2025-06-09
2+
3+
- feat: add before_send callback (#249)
4+
15
## 4.4.2- 2025-06-09
26

37
- empty point release to fix release automation

posthog/client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def __init__(
144144
exception_autocapture_integrations=None,
145145
project_root=None,
146146
privacy_mode=False,
147+
before_send=None,
147148
):
148149
self.queue = queue.Queue(max_queue_size)
149150

@@ -199,6 +200,15 @@ def __init__(
199200
else:
200201
self.log.setLevel(logging.WARNING)
201202

203+
if before_send is not None:
204+
if callable(before_send):
205+
self.before_send = before_send
206+
else:
207+
self.log.warning("before_send is not callable, it will be ignored")
208+
self.before_send = None
209+
else:
210+
self.before_send = None
211+
202212
if self.enable_exception_autocapture:
203213
self.exception_capture = ExceptionCapture(
204214
self, integrations=self.exception_autocapture_integrations
@@ -744,6 +754,18 @@ def _enqueue(self, msg, disable_geoip):
744754
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
745755

746756
msg = clean(msg)
757+
758+
if self.before_send:
759+
try:
760+
modified_msg = self.before_send(msg)
761+
if modified_msg is None:
762+
self.log.debug("Event dropped by before_send callback")
763+
return True, None
764+
msg = modified_msg
765+
except Exception as e:
766+
self.log.exception(f"Error in before_send callback: {e}")
767+
# Continue with the original message if callback fails
768+
747769
self.log.debug("queueing: %s", msg)
748770

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

0 commit comments

Comments
 (0)