Skip to content

Commit c3bb290

Browse files
committed
update name, incorporate other PR
2 parents 5f74391 + 800527d commit c3bb290

File tree

9 files changed

+457
-14
lines changed

9 files changed

+457
-14
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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 4.6.0 - 2025-06-09
2+
3+
- feat: add additional user and request context to captured exceptions via the Django integration
4+
- feat: Add `setup()` function to initialise default client
5+
16
## 4.5.0 - 2025-06-09
27

38
- add additional user and request context to captured exceptions via the Django integration

posthog/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -580,8 +580,7 @@ def shutdown():
580580
_proxy("join")
581581

582582

583-
def _proxy(method, *args, **kwargs):
584-
"""Create an analytics client if one doesn't exist and send to it."""
583+
def setup():
585584
global default_client
586585
if not default_client:
587586
default_client = Client(
@@ -610,6 +609,11 @@ def _proxy(method, *args, **kwargs):
610609
default_client.disabled = disabled
611610
default_client.debug = debug
612611

612+
613+
def _proxy(method, *args, **kwargs):
614+
"""Create an analytics client if one doesn't exist and send to it."""
615+
setup()
616+
613617
fn = getattr(default_client, method)
614618
return fn(*args, **kwargs)
615619

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

posthog/exception_integrations/django.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def extract_person_data(self):
8585
"ip": headers.get("X-Forwarded-For"),
8686
"user_agent": headers.get("User-Agent"),
8787
"traceparent": traceparent,
88-
"$request.path": self.request.path,
88+
"$request_path": self.request.path,
8989
}
9090

9191
def user(self):
@@ -99,14 +99,14 @@ def user(self):
9999
try:
100100
user_id = str(user.pk)
101101
if user_id:
102-
user_data.setdefault("$user.id", user_id)
102+
user_data.setdefault("$user_id", user_id)
103103
except Exception:
104104
pass
105105

106106
try:
107107
email = str(user.email)
108108
if email:
109-
user_data.setdefault("$user.email", email)
109+
user_data.setdefault("email", email)
110110
except Exception:
111111
pass
112112

posthog/test/exception_integrations/test_django.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def test_request_extractor_with_no_trace():
4949
"user_agent": DEFAULT_USER_AGENT,
5050
"traceparent": None,
5151
"distinct_id": None,
52-
"$request.path": "/api/endpoint",
52+
"$request_path": "/api/endpoint",
5353
}
5454

5555

@@ -64,7 +64,7 @@ def test_request_extractor_with_trace():
6464
"user_agent": DEFAULT_USER_AGENT,
6565
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
6666
"distinct_id": None,
67-
"$request.path": "/api/endpoint",
67+
"$request_path": "/api/endpoint",
6868
}
6969

7070

@@ -81,7 +81,7 @@ def test_request_extractor_with_tracestate():
8181
"user_agent": DEFAULT_USER_AGENT,
8282
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
8383
"distinct_id": "1234",
84-
"$request.path": "/api/endpoint",
84+
"$request_path": "/api/endpoint",
8585
}
8686

8787

@@ -95,7 +95,7 @@ def test_request_extractor_with_complicated_tracestate():
9595
"user_agent": DEFAULT_USER_AGENT,
9696
"traceparent": None,
9797
"distinct_id": "alohaMountainsXUYZ",
98-
"$request.path": "/api/endpoint",
98+
"$request_path": "/api/endpoint",
9999
}
100100

101101

@@ -115,7 +115,7 @@ def test_request_extractor_with_request_user():
115115
"user_agent": DEFAULT_USER_AGENT,
116116
"traceparent": None,
117117
"distinct_id": None,
118-
"$request.path": "/api/endpoint",
119-
"$user.email": "[email protected]",
120-
"$user.id": "1",
118+
"$request_path": "/api/endpoint",
119+
"email": "[email protected]",
120+
"$user_id": "1",
121121
}

0 commit comments

Comments
 (0)