Skip to content

Commit fa2d2e5

Browse files
authored
Make telemetry observable from a local build (#820)
1 parent d607f28 commit fa2d2e5

5 files changed

Lines changed: 276 additions & 46 deletions

File tree

quilt-sync/.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
# Telemetry credentials, used by RELEASE builds only.
2+
#
3+
# The release workflow bakes these in at build time. Nothing leaves a local
4+
# build: `just start` dry-runs analytics to the terminal and builds no crash
5+
# reporter at all, so these values cannot cause a dev build to emit — which is
6+
# what makes it safe to keep real ones here.
7+
#
8+
# Consequence worth knowing: crash-side behaviour (breadcrumbs, stack traces,
9+
# release health) is only observable from a release build.
110
SENTRY_DSN=https://your-dsn-here@sentry.io/project-id
211
MIXPANEL_PROJECT_TOKEN=your-mixpanel-project-token-here
312
MIXPANEL_API_SECRET=your-mixpanel-api-secret-here
13+
14+
# Caveat: option_env! reads the *compiler's* environment, so credentials
15+
# exported in the shell you build from are baked into your binary. Keep real
16+
# values in this file, not in your shell profile.

quilt-sync/src-tauri/src/main.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,8 @@ fn main() {
6464
.plugin(tauri_plugin_updater::Builder::new().build())
6565
.setup(|app| {
6666
let package_info = app.package_info();
67-
let enable = if cfg!(debug_assertions) {
68-
None
69-
} else {
70-
Some(())
71-
};
72-
let telemetry = telemetry::Telemetry::new(&package_info.version, enable);
67+
let sinks = telemetry::Sinks::resolve();
68+
let telemetry = telemetry::Telemetry::new(&package_info.version, sinks);
7369

7470
// This is for runtime registering
7571
#[cfg(desktop)]

quilt-sync/src-tauri/src/telemetry.rs

Lines changed: 124 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use std::sync::{Arc, Mutex};
22

33
use ::sentry as Sentry;
4-
use mixpanel_rs::Mixpanel;
54
use quilt_uri::Host;
65
use semver::Version;
76

@@ -14,6 +13,7 @@ pub mod sentry;
1413
pub mod tracing;
1514

1615
pub use event::MixpanelEvent;
16+
pub use mixpanel::Analytics;
1717
pub use tracing::LogsDir;
1818

1919
pub mod prelude {
@@ -32,25 +32,63 @@ pub mod prelude {
3232
/// instead, on whatever thread the event leaves from.
3333
pub 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+
3573
pub 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

4280
impl 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)]
120158
impl 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

Comments
 (0)