11use std:: sync:: { Arc , Mutex } ;
22
33use :: sentry as Sentry ;
4- use mixpanel_rs:: Mixpanel ;
54use quilt_uri:: Host ;
65use semver:: Version ;
76
@@ -14,6 +13,7 @@ pub mod sentry;
1413pub mod tracing;
1514
1615pub use event:: MixpanelEvent ;
16+ pub use mixpanel:: Analytics ;
1717pub use tracing:: LogsDir ;
1818
1919pub mod prelude {
@@ -32,25 +32,63 @@ pub mod prelude {
3232/// instead, on whatever thread the event leaves from.
3333pub type AmbientHost = Arc < Mutex < Option < Host > > > ;
3434
35+ /// Where this build's telemetry goes — decided by the build profile alone.
36+ ///
37+ /// **Nothing leaves a local build.** Analytics dry-runs to the terminal (see
38+ /// [`mixpanel::Analytics`]) and the crash reporter is not constructed at all, so
39+ /// no configuration, environment variable, or stray `.env` credential can make a
40+ /// developer's machine emit. That is why this needs no opt-in: an opt-in guards a
41+ /// risk, and there is none left to guard.
42+ ///
43+ /// The crash reporter is the asymmetric half. It has no dry run — the SDK either
44+ /// holds a client or it does not — so rather than gate it on a flag, a local build
45+ /// simply never builds one. The cost is that crash-side behaviour (breadcrumbs,
46+ /// stack traces, release health) stays release-verified; the alternative was a
47+ /// `.env` DSN quietly shipping a developer's crashes, which is the thing a
48+ /// deliberate act was supposed to prevent.
49+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
50+ pub enum Sinks {
51+ /// Release build: the sinks configured at build time.
52+ Production ,
53+ /// Local build: analytics dry-runs, the crash reporter is absent.
54+ Development ,
55+ }
56+
57+ impl Sinks {
58+ pub fn resolve ( ) -> Self {
59+ if cfg ! ( debug_assertions) {
60+ Self :: Development
61+ } else {
62+ Self :: Production
63+ }
64+ }
65+
66+ /// Whether a crash client is built. True only for a release build — see the
67+ /// type's note on why a local build never reports crashes.
68+ pub fn reports_crashes ( self ) -> bool {
69+ matches ! ( self , Self :: Production )
70+ }
71+ }
72+
3573pub struct Telemetry {
3674 _sentry : Option < Sentry :: ClientInitGuard > ,
37- mixpanel : Option < Arc < Mixpanel > > ,
75+ analytics : Analytics ,
3876 /// The host every outgoing crash report is tagged with; see [`AmbientHost`].
3977 host : AmbientHost ,
4078}
4179
4280impl Telemetry {
43- pub fn new ( version : & Version , enable : Option < ( ) > ) -> Self {
81+ pub fn new ( version : & Version , sinks : Sinks ) -> Self {
4482 // The cell outlives the client and is read by its event hook, so it has
4583 // to exist before the client is built.
4684 let host: AmbientHost = Arc :: new ( Mutex :: new ( None ) ) ;
4785
4886 Self {
49- mixpanel : enable
50- . and ( mixpanel :: mixpanel_config ( ) )
51- . map ( | ( token , config ) | Arc :: new ( Mixpanel :: init ( & token , Some ( config ) ) ) ) ,
52- _sentry : enable
53- . and ( sentry :: sentry_config ( version , Arc :: clone ( & host ) ) )
87+ analytics : Analytics :: resolve ( sinks ) ,
88+ _sentry : sinks
89+ . reports_crashes ( )
90+ . then ( || sentry :: sentry_config ( version , Arc :: clone ( & host ) ) )
91+ . flatten ( )
5492 . map ( :: sentry:: init) ,
5593 host,
5694 }
@@ -81,7 +119,7 @@ impl Telemetry {
81119 if let Some ( host) = event. host ( ) {
82120 self . add_host ( host) ;
83121 }
84- if let Err ( err) = mixpanel:: track_event ( self . mixpanel . as_ref ( ) , & event) . await {
122+ if let Err ( err) = mixpanel:: track_event ( & self . analytics , & event) . await {
85123 Sentry :: capture_error ( & err) ;
86124 }
87125 }
@@ -107,7 +145,7 @@ impl Telemetry {
107145 }
108146
109147 pub fn init ( & self ) {
110- mixpanel:: init ( self . mixpanel . as_ref ( ) ) ;
148+ mixpanel:: init ( & self . analytics ) ;
111149 }
112150
113151 /// Returns the current global maximum log level as a human-readable string.
@@ -118,9 +156,82 @@ impl Telemetry {
118156
119157#[ cfg( test) ]
120158impl Default for Telemetry {
159+ /// A test double, built as one rather than resolved from a build mode: a test
160+ /// wants a `Telemetry` that neither sends nor prints, which is not something
161+ /// any shipped build is. Constructing the fields directly keeps [`Sinks`]
162+ /// describing only the two builds that exist.
121163 fn default ( ) -> Self {
122- // In tests, use non-production mode (no telemetry)
123- let version = semver:: Version :: new ( 0 , 0 , 0 ) ;
124- Self :: new ( & version, None )
164+ Self {
165+ _sentry : None ,
166+ analytics : Analytics :: Off ,
167+ host : Arc :: new ( Mutex :: new ( None ) ) ,
168+ }
169+ }
170+ }
171+
172+ #[ cfg( test) ]
173+ mod tests {
174+ use serial_test:: serial;
175+
176+ use super :: * ;
177+
178+ /// Tests build with `debug_assertions` on, so this pins the branch a
179+ /// developer actually runs.
180+ #[ test]
181+ fn a_local_build_resolves_to_development ( ) {
182+ assert_eq ! ( Sinks :: resolve( ) , Sinks :: Development ) ;
183+ }
184+
185+ /// The invariant, and the reason no opt-in is needed: a local build cannot
186+ /// emit **even holding real credentials**. Analytics dry-runs and no crash
187+ /// client is constructed, so there is nothing for a stray `.env` to switch on.
188+ ///
189+ /// `#[serial]` because this mutates the process environment, which races any
190+ /// concurrent reader — the same reason [`crate::env`]'s tests are serial.
191+ #[ test]
192+ #[ serial]
193+ #[ allow(
194+ clippy:: used_underscore_binding,
195+ reason = "`_sentry` is underscore-prefixed because it is held only for its Drop; \
196+ whether it was constructed at all is exactly what this test asserts"
197+ ) ]
198+ fn a_local_build_cannot_emit_even_holding_credentials ( ) {
199+ unsafe {
200+ std:: env:: set_var ( "MIXPANEL_PROJECT_TOKEN" , "a-token-that-must-not-be-used" ) ;
201+ std:: env:: set_var ( "SENTRY_DSN" , "https://public@example.invalid/1" ) ;
202+ }
203+
204+ let telemetry = Telemetry :: new ( & Version :: new ( 0 , 0 , 0 ) , Sinks :: resolve ( ) ) ;
205+ assert ! (
206+ matches!( telemetry. analytics, Analytics :: DryRun ) ,
207+ "a local build must never construct a live analytics client"
208+ ) ;
209+ assert ! (
210+ telemetry. _sentry. is_none( ) ,
211+ "a local build must never construct a crash client, DSN or not"
212+ ) ;
213+
214+ unsafe {
215+ std:: env:: remove_var ( "MIXPANEL_PROJECT_TOKEN" ) ;
216+ std:: env:: remove_var ( "SENTRY_DSN" ) ;
217+ }
218+ }
219+
220+ #[ test]
221+ #[ allow(
222+ clippy:: used_underscore_binding,
223+ reason = "see `a_local_build_cannot_emit_even_holding_credentials`"
224+ ) ]
225+ fn the_test_double_builds_nothing ( ) {
226+ let telemetry = Telemetry :: default ( ) ;
227+ assert ! ( matches!( telemetry. analytics, Analytics :: Off ) ) ;
228+ assert ! ( telemetry. _sentry. is_none( ) ) ;
229+ }
230+
231+ /// Only a release build reports crashes — the asymmetry the type documents.
232+ #[ test]
233+ fn only_a_release_build_reports_crashes ( ) {
234+ assert ! ( Sinks :: Production . reports_crashes( ) ) ;
235+ assert ! ( !Sinks :: Development . reports_crashes( ) ) ;
125236 }
126237}
0 commit comments