Skip to content

Commit 3433e48

Browse files
fiskusclaude
andcommitted
Give fault reporting a seam a test can observe
`report_anomaly` and `report_error` were associated functions calling the crash SDK directly. They worked only because that SDK keeps a process-global client — which meant they bypassed `Telemetry`'s state entirely and, more importantly, **nothing could observe that a path reported a fault**. That matters now rather than in principle. Instrumenting the background engine is mostly about failures, and d-outcome-not-intent settled that failure counting rides the crash sink rather than the analytics vocabulary — so the mechanism the telemetry work leans on for failure visibility was the one with no way to assert against it. Both are methods over a `Faults` field now: `Live` captures via the SDK, `DryRun` writes to the developer's console, and a cfg(test) `Recorded` variant keeps them in memory so a test can read them back. The dry run is a real gain of its own — until now a fault in a local build went nowhere at all, since a dev build has no crash client. Deliberately not a trait, per d-no-provider-traits: this is a seam for observation, not an abstraction over vendors. What it immediately bought — the spec's claim about logout is now checked rather than merely written. A logout that finds nothing stored is still a success (a second click must not error) but emits no event and reports an anomaly, because the settings page lists hosts from those very directories, so a missing one means the list and the erase disagree. Two tests: that the anomaly is reported, and that a logout which does erase something reports nothing — without the second, the first would pass on an implementation that reported every time. The failed-send path inside `track` also routes through the field now, so the whole fault path is observable rather than most of it. just lint 0, cargo fmt --check 0, 307 quilt-sync tests, workspace green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 906acad commit 3433e48

3 files changed

Lines changed: 226 additions & 9 deletions

File tree

quilt-sync/src-tauri/src/commands/auth.rs

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,9 @@ async fn erase_auth_command(
146146
// stays constant so the anomaly groups as one issue.
147147
Ok(ErasedAuth::NothingStored(host)) => {
148148
tracing.add_host(host);
149-
Telemetry::report_anomaly("Logout found no stored auth for the host");
149+
tracing.report_anomaly("Logout found no stored auth for the host");
150150
}
151-
Err(err) => Telemetry::report_error(err),
151+
Err(err) => tracing.report_error(err),
152152
}
153153

154154
// On either success: the role cache can still hold an entry for a host whose
@@ -774,6 +774,74 @@ mod tests {
774774
/// A logout takes one host's stored auth and nothing else. The erase joins a
775775
/// parsed host onto the auth dir, so a bug in that addressing would show up
776776
/// here as the other host's credentials disappearing with it.
777+
/// The claim the spec makes about logout, finally assertable: one that finds
778+
/// nothing stored is still a **success** — a second click must not raise an
779+
/// error — but it emits no event and *reports an anomaly*, because the settings
780+
/// page offers logout only for hosts it listed from these very directories, so
781+
/// a missing one means the list and the erase disagree about the name.
782+
///
783+
/// Before fault reporting had a seam this could be read but not checked: it
784+
/// went straight to a process-global crash client that no test could observe.
785+
#[tokio::test]
786+
async fn logging_out_a_host_with_nothing_stored_reports_an_anomaly() {
787+
let data_dir = TempDir::new().expect("temp data dir");
788+
seed_auth_dirs(data_dir.path(), &["a.quilt.dev"]);
789+
790+
let mut m = MockQuiltModel::new();
791+
m.expect_clear_remote_client_cache().returning(|_| ());
792+
793+
let telemetry = Telemetry::default();
794+
erase_auth_command(
795+
data_dir.path(),
796+
&m,
797+
&RoleCache::default(),
798+
&telemetry,
799+
"never-logged-in.quilt.dev",
800+
)
801+
.await
802+
.expect("a logout that finds nothing stored is still a success");
803+
804+
let reported = telemetry.reported_faults();
805+
assert_eq!(
806+
reported.len(),
807+
1,
808+
"exactly one signal per attempt: {reported:?}"
809+
);
810+
assert!(
811+
reported[0].contains("Logout found no stored auth"),
812+
"and it is the anomaly, not something else: {reported:?}"
813+
);
814+
}
815+
816+
/// The other side of the same rule: a logout that *does* erase something
817+
/// reports no fault at all. Without this, the assertion above would pass on an
818+
/// implementation that reported an anomaly every time.
819+
#[tokio::test]
820+
async fn a_successful_logout_reports_no_fault() {
821+
let data_dir = TempDir::new().expect("temp data dir");
822+
seed_auth_dirs(data_dir.path(), &["a.quilt.dev"]);
823+
824+
let mut m = MockQuiltModel::new();
825+
m.expect_clear_remote_client_cache().returning(|_| ());
826+
827+
let telemetry = Telemetry::default();
828+
erase_auth_command(
829+
data_dir.path(),
830+
&m,
831+
&RoleCache::default(),
832+
&telemetry,
833+
"a.quilt.dev",
834+
)
835+
.await
836+
.expect("logout");
837+
838+
assert!(
839+
telemetry.reported_faults().is_empty(),
840+
"erasing what was there is not an anomaly: {:?}",
841+
telemetry.reported_faults()
842+
);
843+
}
844+
777845
#[tokio::test]
778846
async fn logging_out_erases_only_its_own_host() {
779847
let data_dir = TempDir::new().expect("temp data dir");

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub mod tracing;
1616
pub use event::MixpanelEvent;
1717
pub use install_id::InstallId;
1818
pub use mixpanel::Analytics;
19+
pub use sentry::Faults;
1920
pub use tracing::LogsDir;
2021

2122
pub mod prelude {
@@ -79,8 +80,11 @@ pub struct Telemetry {
7980
host: AmbientHost,
8081
/// This install's identity, on every analytics event and every crash report
8182
/// so the two can be read together. `None` when it could not be persisted —
82-
/// see [`install_id::load`].
83+
/// see [`InstallId::load`].
8384
install_id: Option<InstallId>,
85+
/// Where a fault goes. A field rather than a direct call to the crash SDK, so
86+
/// that "this path reported a fault" is observable — see [`Faults`].
87+
faults: Faults,
8488
}
8589

8690
impl Telemetry {
@@ -98,6 +102,7 @@ impl Telemetry {
98102
.map(::sentry::init),
99103
host,
100104
install_id,
105+
faults: Faults::resolve(sinks),
101106
}
102107
}
103108

@@ -135,7 +140,7 @@ impl Telemetry {
135140
if let Err(err) =
136141
mixpanel::track_event(&self.analytics, &event, self.install_id.as_ref()).await
137142
{
138-
Sentry::capture_error(&err);
143+
self.report_error(&err);
139144
}
140145
}
141146

@@ -145,8 +150,8 @@ impl Telemetry {
145150
/// Keep `message` a constant: the crash reporter groups by it, so the
146151
/// variable part belongs in the host tag ([`Self::add_host`]) rather than in
147152
/// the text, or one anomaly becomes one issue per host.
148-
pub fn report_anomaly(message: &str) {
149-
Sentry::capture_message(message, Sentry::Level::Warning);
153+
pub fn report_anomaly(&self, message: &str) {
154+
self.faults.anomaly(message);
150155
}
151156

152157
/// Report a fault to the crash reporter without failing the caller.
@@ -155,8 +160,16 @@ impl Telemetry {
155160
/// name its deployment and cannot. Reporting it as a fault keeps the
156161
/// analytics vocabulary free of error events, which would need their own
157162
/// design for what an error is allowed to say.
158-
pub fn report_error(err: &(dyn std::error::Error + Send + Sync + 'static)) {
159-
Sentry::capture_error(err);
163+
pub fn report_error(&self, err: &(dyn std::error::Error + Send + Sync + 'static)) {
164+
self.faults.error(err);
165+
}
166+
167+
/// What was reported through [`Self::report_anomaly`] and
168+
/// [`Self::report_error`] — the seam that made this unit worth doing. Without
169+
/// it a path can claim to report a fault and no test can tell.
170+
#[cfg(test)]
171+
pub fn reported_faults(&self) -> Vec<String> {
172+
self.faults.reported()
160173
}
161174

162175
pub fn init(&self) {
@@ -181,6 +194,9 @@ impl Default for Telemetry {
181194
analytics: Analytics::Off,
182195
host: Arc::new(Mutex::new(None)),
183196
install_id: None,
197+
// Recording rather than printing, so a test can assert what a path
198+
// reported instead of a developer reading it go by.
199+
faults: Faults::Recorded(Arc::new(Mutex::new(Vec::new()))),
184200
}
185201
}
186202
}

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

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,92 @@
11
use semver::Version;
22

33
use crate::env;
4-
use crate::telemetry::{AmbientHost, InstallId};
4+
use crate::telemetry::{AmbientHost, InstallId, Sinks};
5+
6+
/// Where a fault goes — a signal that something should not have happened, which
7+
/// no event describes.
8+
///
9+
/// A field rather than a direct call to the SDK, and that is the whole point: the
10+
/// crash client is a process global, so reporting through it left **nothing a test
11+
/// could observe**. Instrumenting the background engine turns mostly on failures,
12+
/// and failure counting rides this sink rather than the analytics vocabulary, so
13+
/// the mechanism the telemetry work leans on for failure visibility was the one
14+
/// nothing could assert against.
15+
///
16+
/// Deliberately not a trait: this is a seam for *observation*, not an abstraction
17+
/// over vendors — the sinks are three different shapes and the crash SDK actively
18+
/// resists wrapping.
19+
#[derive(Clone)]
20+
pub enum Faults {
21+
/// A release build: captured by the crash client, if one was configured.
22+
Live,
23+
/// A local build: written to the developer's console, because there is no
24+
/// crash client to hold it. Today these vanish entirely in a dev build.
25+
DryRun,
26+
/// Tests: recorded in memory, so a test can assert that a path reported.
27+
#[cfg(test)]
28+
Recorded(std::sync::Arc<std::sync::Mutex<Vec<String>>>),
29+
}
30+
31+
impl Faults {
32+
pub fn resolve(sinks: Sinks) -> Self {
33+
if sinks.reports_crashes() {
34+
Self::Live
35+
} else {
36+
Self::DryRun
37+
}
38+
}
39+
40+
/// Report an anomaly: something that should not have happened, with no `Err`
41+
/// to carry it.
42+
///
43+
/// `message` must stay constant — the crash reporter groups by it, so a
44+
/// variable part belongs in the host tag rather than the text, or one anomaly
45+
/// becomes one issue per host.
46+
pub fn anomaly(&self, message: &str) {
47+
match self {
48+
// The returned event id is discarded: nothing here correlates a fault
49+
// back to its report, and returning it would invite a caller to think
50+
// something does.
51+
Self::Live => {
52+
sentry::capture_message(message, sentry::Level::Warning);
53+
}
54+
Self::DryRun => eprintln!("telemetry(dry-run) anomaly: {message}"),
55+
#[cfg(test)]
56+
Self::Recorded(recorded) => Self::record(recorded, format!("anomaly: {message}")),
57+
}
58+
}
59+
60+
/// Report a fault the caller is not failing on.
61+
pub fn error(&self, err: &(dyn std::error::Error + Send + Sync + 'static)) {
62+
match self {
63+
Self::Live => {
64+
sentry::capture_error(err);
65+
}
66+
Self::DryRun => eprintln!("telemetry(dry-run) fault: {err}"),
67+
#[cfg(test)]
68+
Self::Recorded(recorded) => Self::record(recorded, format!("fault: {err}")),
69+
}
70+
}
71+
72+
#[cfg(test)]
73+
fn record(recorded: &std::sync::Mutex<Vec<String>>, entry: String) {
74+
// A poisoned lock would mean another test thread panicked mid-record.
75+
// Dropping the entry is right: the panic is the failure worth reporting,
76+
// and a second panic here would bury it.
77+
if let Ok(mut recorded) = recorded.lock() {
78+
recorded.push(entry);
79+
}
80+
}
81+
82+
#[cfg(test)]
83+
pub fn reported(&self) -> Vec<String> {
84+
match self {
85+
Self::Recorded(recorded) => recorded.lock().map(|r| r.clone()).unwrap_or_default(),
86+
_ => Vec::new(),
87+
}
88+
}
89+
}
590

691
fn get_sentry_dsn() -> Option<sentry::types::Dsn> {
792
env::sentry_dsn().and_then(|dsn_str| {
@@ -72,3 +157,51 @@ pub fn sentry_config(
72157
options
73158
})
74159
}
160+
161+
#[cfg(test)]
162+
mod tests {
163+
use super::*;
164+
use crate::telemetry::Sinks;
165+
166+
fn recorder() -> Faults {
167+
Faults::Recorded(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())))
168+
}
169+
170+
/// Both kinds of fault are recorded, distinguishably and in order — a test
171+
/// asserting "an anomaly was reported" must not be satisfied by an error.
172+
#[test]
173+
fn records_anomalies_and_errors_apart() {
174+
let faults = recorder();
175+
176+
faults.anomaly("a constant message");
177+
faults.error(&std::io::Error::other("something broke"));
178+
179+
assert_eq!(
180+
faults.reported(),
181+
vec![
182+
"anomaly: a constant message".to_owned(),
183+
"fault: something broke".to_owned()
184+
]
185+
);
186+
}
187+
188+
/// A build that reports nowhere observable reports *nothing* to a reader —
189+
/// so a test holding a live or dry-run sink cannot accidentally pass by
190+
/// reading someone else's recording.
191+
#[test]
192+
fn only_the_recorder_reports() {
193+
assert!(Faults::Live.reported().is_empty());
194+
assert!(Faults::DryRun.reported().is_empty());
195+
}
196+
197+
/// A local build has no crash client, so its faults go to the console rather
198+
/// than nowhere — which is what they did before.
199+
#[test]
200+
fn a_local_build_dry_runs_its_faults() {
201+
assert!(matches!(
202+
Faults::resolve(Sinks::Development),
203+
Faults::DryRun
204+
));
205+
assert!(matches!(Faults::resolve(Sinks::Production), Faults::Live));
206+
}
207+
}

0 commit comments

Comments
 (0)