99import os
1010import platform
1111import sys
12+ import warnings
1213from pathlib import Path
13- from typing import Any , Dict , Optional
14+ from typing import Any , Callable , Dict , Optional
1415
1516import sentry_sdk
17+ from sentry_sdk .types import Event , Hint
1618
1719from .config import TelemetryConfig , load_config
18- from .privacy import create_before_send_filter
20+ from .privacy import anonymize_identifier , create_before_send_filter
21+
22+ BeforeSendFn = Callable [[Event , Hint ], Optional [Event ]]
1923
2024
2125def is_running_from_executable () -> bool :
@@ -56,12 +60,11 @@ def is_ci_environment() -> bool:
5660def is_internal_user () -> bool :
5761 """Determine if current usage is from internal team.
5862
59- Uses multiple heuristics to detect internal/developer usage:
63+ Uses multiple signals to detect internal/developer usage:
6064 1. Explicit OPENADAPT_INTERNAL environment variable
6165 2. OPENADAPT_DEV environment variable
62- 3. Not running from frozen executable
63- 4. Git repository present in current directory
64- 5. CI environment detected
66+ 3. CI environment detected
67+ 4. Optional git repository heuristic when OPENADAPT_INTERNAL_FROM_GIT=true
6568
6669 Returns:
6770 True if this appears to be internal usage.
@@ -74,21 +77,30 @@ def is_internal_user() -> bool:
7477 if os .getenv ("OPENADAPT_DEV" , "" ).lower () in ("true" , "1" , "yes" ):
7578 return True
7679
77- # Method 3: Not running from executable (indicates dev mode)
78- if not is_running_from_executable ():
79- return True
80-
81- # Method 4: Git repository present (development checkout)
82- if Path (".git" ).exists () or Path ("../.git" ).exists ():
83- return True
84-
85- # Method 5: CI/CD environment
80+ # Method 3: CI/CD environment
8681 if is_ci_environment ():
8782 return True
8883
84+ # Method 4: optional git heuristic
85+ if os .getenv ("OPENADAPT_INTERNAL_FROM_GIT" , "" ).lower () in ("true" , "1" , "yes" ):
86+ if Path (".git" ).exists () or Path ("../.git" ).exists ():
87+ return True
88+
8989 return False
9090
9191
92+ def _compose_before_send (base : BeforeSendFn , extra : BeforeSendFn ) -> BeforeSendFn :
93+ """Compose custom before_send before final privacy filtering."""
94+
95+ def composed (event : Event , hint : Hint ) -> Optional [Event ]:
96+ modified = extra (event , hint )
97+ if modified is None :
98+ return None
99+ return base (modified , hint )
100+
101+ return composed
102+
103+
92104class TelemetryClient :
93105 """Unified telemetry client for all OpenAdapt packages.
94106
@@ -128,20 +140,13 @@ def reset_instance(cls) -> None:
128140 def _check_enabled (self ) -> bool :
129141 """Check if telemetry should be enabled.
130142
131- Checks environment variables for opt-out signals .
143+ Uses merged config with defaults/env/file precedence .
132144
133145 Returns:
134146 True if telemetry should be enabled.
135147 """
136- # Universal opt-out (DO_NOT_TRACK standard)
137- if os .getenv ("DO_NOT_TRACK" , "" ).lower () in ("1" , "true" ):
138- return False
139-
140- # Package-specific opt-out
141- if os .getenv ("OPENADAPT_TELEMETRY_ENABLED" , "" ).lower () in ("false" , "0" , "no" ):
142- return False
143-
144- return True
148+ self ._config = load_config ()
149+ return bool (self ._config .enabled )
145150
146151 @property
147152 def enabled (self ) -> bool :
@@ -187,10 +192,8 @@ def initialize(
187192 Returns:
188193 True if initialization succeeded, False if disabled or already initialized.
189194 """
190- if not self ._enabled :
191- return False
192-
193- if self ._initialized and not kwargs .get ("force" , False ):
195+ force = bool (kwargs .pop ("force" , False ))
196+ if self ._initialized and not force :
194197 return True
195198
196199 # Load configuration
@@ -201,28 +204,49 @@ def initialize(
201204 self ._config .dsn = dsn
202205 if environment :
203206 self ._config .environment = environment
207+ self ._enabled = bool (self ._config .enabled )
208+
209+ if not self ._enabled :
210+ return False
204211
205212 # Skip if no DSN configured
206213 if not self ._config .dsn :
207214 return False
208215
209- # Create privacy filter
210- before_send = create_before_send_filter ()
216+ # Always enforce privacy scrubber first; optional custom filter can run afterward.
217+ base_before_send = create_before_send_filter ()
218+ custom_before_send = kwargs .pop ("before_send" , None )
219+ if custom_before_send is not None :
220+ if not callable (custom_before_send ):
221+ raise TypeError ("before_send must be callable" )
222+ warnings .warn (
223+ "Custom before_send runs before OpenAdapt privacy filtering; final payload is always scrubbed." ,
224+ stacklevel = 2 ,
225+ )
226+ before_send = _compose_before_send (base_before_send , custom_before_send )
227+ else :
228+ before_send = base_before_send
229+
230+ if "send_default_pii" in kwargs :
231+ kwargs .pop ("send_default_pii" )
232+ warnings .warn (
233+ "Ignoring sentry init override for send_default_pii; OpenAdapt telemetry enforces send_default_pii=False." ,
234+ stacklevel = 2 ,
235+ )
211236
212237 # Initialize Sentry SDK
213238 sentry_kwargs = {
214239 "dsn" : self ._config .dsn ,
215240 "environment" : self ._config .environment ,
216241 "sample_rate" : self ._config .sample_rate ,
217242 "traces_sample_rate" : self ._config .traces_sample_rate ,
218- "send_default_pii" : self ._config .send_default_pii ,
243+ # Enforced for privacy safety across all callers/configs.
244+ "send_default_pii" : False ,
219245 "before_send" : before_send ,
220246 }
221247
222248 # Merge in any additional kwargs
223249 sentry_kwargs .update (kwargs )
224- # Remove our internal kwargs
225- sentry_kwargs .pop ("force" , None )
226250
227251 sentry_sdk .init (** sentry_kwargs )
228252
@@ -314,12 +338,13 @@ def set_user(
314338 Note: Only sets anonymous user ID. Never set email, name, or other PII.
315339
316340 Args:
317- user_id: Anonymous user identifier.
318- **kwargs: Additional user properties (id only recommended) .
341+ user_id: User identifier to hash before sending .
342+ **kwargs: Ignored. Additional user fields are dropped .
319343 """
320344 if not self ._enabled or not self ._initialized :
321345 return
322- sentry_sdk .set_user ({"id" : user_id , ** kwargs })
346+ _ = kwargs
347+ sentry_sdk .set_user ({"id" : anonymize_identifier (user_id )})
323348
324349 def set_tag (self , key : str , value : str ) -> None :
325350 """Set a custom tag for all subsequent events.
0 commit comments