Skip to content

Commit a8f2a57

Browse files
committed
fix: enforce privacy filter precedence for sentry init overrides
1 parent 7d64d77 commit a8f2a57

3 files changed

Lines changed: 97 additions & 10 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,16 +160,17 @@ export OPENADAPT_TELEMETRY_ENABLED=false
160160
- File paths have usernames replaced with `<user>`
161161
- Sensitive fields (password, token, api_key, etc.) are redacted
162162
- Email addresses and phone numbers are scrubbed from messages
163+
- User IDs are hashed before upload (`anon:<hash>`)
164+
- `send_default_pii` is enforced to `false` by the client
163165

164166
## Internal Usage Tagging
165167

166168
Internal/developer usage is automatically detected via:
167169

168170
1. `OPENADAPT_INTERNAL=true` environment variable
169171
2. `OPENADAPT_DEV=true` environment variable
170-
3. Running from source (not frozen executable)
171-
4. Git repository present in working directory
172-
5. CI environment detected (GitHub Actions, GitLab CI, etc.)
172+
3. Git repository present in working directory
173+
4. CI environment detected (GitHub Actions, GitLab CI, etc.)
173174

174175
Filter in GlitchTip:
175176
```

src/openadapt_telemetry/client.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@
99
import os
1010
import platform
1111
import sys
12+
import warnings
1213
from pathlib import Path
13-
from typing import Any, Dict, Optional
14+
from typing import Any, Callable, Dict, Optional
1415

1516
import sentry_sdk
17+
from sentry_sdk.types import Event, Hint
1618

1719
from .config import TelemetryConfig, load_config
1820
from .privacy import anonymize_identifier, create_before_send_filter
1921

22+
BeforeSendFn = Callable[[Event, Hint], Optional[Event]]
23+
2024

2125
def is_running_from_executable() -> bool:
2226
"""Check if running from a frozen/bundled executable.
@@ -84,6 +88,18 @@ def is_internal_user() -> bool:
8488
return False
8589

8690

91+
def _compose_before_send(base: BeforeSendFn, extra: BeforeSendFn) -> BeforeSendFn:
92+
"""Compose custom before_send after privacy filtering."""
93+
94+
def composed(event: Event, hint: Hint) -> Optional[Event]:
95+
sanitized = base(event, hint)
96+
if sanitized is None:
97+
return None
98+
return extra(sanitized, hint)
99+
100+
return composed
101+
102+
87103
class TelemetryClient:
88104
"""Unified telemetry client for all OpenAdapt packages.
89105
@@ -175,7 +191,8 @@ def initialize(
175191
Returns:
176192
True if initialization succeeded, False if disabled or already initialized.
177193
"""
178-
if self._initialized and not kwargs.get("force", False):
194+
force = bool(kwargs.pop("force", False))
195+
if self._initialized and not force:
179196
return True
180197

181198
# Load configuration
@@ -195,23 +212,40 @@ def initialize(
195212
if not self._config.dsn:
196213
return False
197214

198-
# Create privacy filter
199-
before_send = create_before_send_filter()
215+
# Always enforce privacy scrubber first; optional custom filter can run afterward.
216+
base_before_send = create_before_send_filter()
217+
custom_before_send = kwargs.pop("before_send", None)
218+
if custom_before_send is not None:
219+
if not callable(custom_before_send):
220+
raise TypeError("before_send must be callable")
221+
warnings.warn(
222+
"Custom before_send is composed after OpenAdapt privacy filtering and cannot bypass scrubbing.",
223+
stacklevel=2,
224+
)
225+
before_send = _compose_before_send(base_before_send, custom_before_send)
226+
else:
227+
before_send = base_before_send
228+
229+
if "send_default_pii" in kwargs:
230+
kwargs.pop("send_default_pii")
231+
warnings.warn(
232+
"Ignoring sentry init override for send_default_pii; OpenAdapt telemetry enforces send_default_pii=False.",
233+
stacklevel=2,
234+
)
200235

201236
# Initialize Sentry SDK
202237
sentry_kwargs = {
203238
"dsn": self._config.dsn,
204239
"environment": self._config.environment,
205240
"sample_rate": self._config.sample_rate,
206241
"traces_sample_rate": self._config.traces_sample_rate,
207-
"send_default_pii": self._config.send_default_pii,
242+
# Enforced for privacy safety across all callers/configs.
243+
"send_default_pii": False,
208244
"before_send": before_send,
209245
}
210246

211247
# Merge in any additional kwargs
212248
sentry_kwargs.update(kwargs)
213-
# Remove our internal kwargs
214-
sentry_kwargs.pop("force", None)
215249

216250
sentry_sdk.init(**sentry_kwargs)
217251

tests/test_client.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import os
44
from unittest.mock import patch
55

6+
import pytest
7+
68
from openadapt_telemetry.client import (
79
TelemetryClient,
810
get_telemetry,
@@ -188,6 +190,8 @@ def test_initialize_with_dsn(self, mock_sentry):
188190
assert result is True
189191
assert client.initialized is True
190192
mock_sentry.init.assert_called_once()
193+
init_kwargs = mock_sentry.init.call_args.kwargs
194+
assert init_kwargs["send_default_pii"] is False
191195

192196
@patch("openadapt_telemetry.client.sentry_sdk")
193197
def test_capture_exception_when_enabled(self, mock_sentry):
@@ -254,6 +258,54 @@ def test_set_user_hashes_identifier_and_drops_extra_fields(self, mock_sentry):
254258
assert payload["id"].startswith("anon:")
255259
assert payload["id"] != "user@example.com"
256260

261+
@patch("openadapt_telemetry.client.sentry_sdk")
262+
def test_custom_before_send_is_composed_after_privacy_filter(self, mock_sentry):
263+
"""Custom before_send should run after built-in scrubbing."""
264+
265+
def custom_before_send(event, hint):
266+
event.setdefault("tags", {})["custom"] = "true"
267+
return event
268+
269+
with patch.dict(os.environ, {"DO_NOT_TRACK": ""}, clear=False):
270+
TelemetryClient.reset_instance()
271+
client = TelemetryClient.get_instance()
272+
with pytest.warns(UserWarning, match="Custom before_send is composed after OpenAdapt privacy filtering"):
273+
client.initialize(
274+
dsn="https://test@example.com/1",
275+
before_send=custom_before_send,
276+
)
277+
278+
before_send = mock_sentry.init.call_args.kwargs["before_send"]
279+
event = {"user": {"id": "user@example.com", "email": "user@example.com"}}
280+
output = before_send(event, hint={})
281+
assert output is not None
282+
assert output["user"]["id"].startswith("anon:")
283+
assert "email" not in output["user"]
284+
assert output["tags"]["custom"] == "true"
285+
286+
@patch("openadapt_telemetry.client.sentry_sdk")
287+
def test_send_default_pii_override_is_ignored(self, mock_sentry):
288+
"""send_default_pii should always be enforced to False."""
289+
with patch.dict(os.environ, {"DO_NOT_TRACK": ""}, clear=False):
290+
TelemetryClient.reset_instance()
291+
client = TelemetryClient.get_instance()
292+
with pytest.warns(UserWarning, match="Ignoring sentry init override for send_default_pii"):
293+
client.initialize(
294+
dsn="https://test@example.com/1",
295+
send_default_pii=True,
296+
)
297+
298+
assert mock_sentry.init.call_args.kwargs["send_default_pii"] is False
299+
300+
def test_initialize_rejects_non_callable_before_send(self):
301+
"""Non-callable before_send should raise TypeError."""
302+
with patch.dict(os.environ, {"DO_NOT_TRACK": ""}, clear=False):
303+
TelemetryClient.reset_instance()
304+
client = TelemetryClient.get_instance()
305+
with patch("openadapt_telemetry.client.sentry_sdk"):
306+
with pytest.raises(TypeError, match="before_send must be callable"):
307+
client.initialize(dsn="https://test@example.com/1", before_send="invalid")
308+
257309
@patch("openadapt_telemetry.client.sentry_sdk")
258310
def test_add_breadcrumb(self, mock_sentry):
259311
"""add_breadcrumb should call sentry when enabled."""

0 commit comments

Comments
 (0)