Skip to content

Commit 937fb7f

Browse files
fiskusclaude
andcommitted
Give each install an anonymous identity
Events could not become people. Nothing identified an install — no distinct_id, no device id, nothing persisted — so 400 pushes could have been 4 users or 40, and retention and funnels were unanswerable. This is the question quiltsync-telemetry-host closed on as "blocked on whether one may be attached at all"; the answer is that counting installs needs nothing about a user. A random UUID, minted on first run and persisted beside the app's own data, derived from nothing. Not an email hash: that is reversible for a customer set already known, so it would carry the obligations of personal data while feeling as though it did not — and it counts accounts, not installs, being absent before the first login, which is exactly where the onboarding funnel starts. Reaches all three sinks so they can be read together: the analytics distinct_id, the crash reporter's user.id, and a field in the diagnostic export. A crash now leads to that install's event stream leads to the archive its user emailed in. Two design points worth keeping: - The identity rides the crash client's *event hook*, beside the host tag, not a scope. It is fixed for the process, so this is not about staleness — a scope carrying it would still reach only threads that snapshotted after it was set. One mechanism, both facts. - `wire_payload` is separate from `event_payload` so the two stay honest about whose fact each property is: the payload belongs to the *event* and is pinned by its own tests, while distinct_id belongs to the *install* and to nothing the vocabulary describes. A payloadless event still gets a properties object, because it still has an identity — and app launch, the head of every funnel, is exactly that case. Failure semantics, which are the whole reason this is not three lines: - Cannot persist -> report no identity, never an unpersisted one. An id that is not on disk is a *new* id next launch, inflating the install count precisely when disks are unhappy. - Read fails for any reason other than absence -> report none, and do not mint a replacement, which would discard a real install's history to satisfy one run. - Written atomically (temp + rename), so an interrupted write cannot leave a truncated value that reads as a different install forever. - No identity means an *unattributed* event, never a dropped one — anything else loses the events of the machines having trouble. Locally verifiable via the dry run, which shows the identity — the first unit the rig from #820 actually pays for. just lint 0, cargo fmt --check 0, 300 quilt-sync tests, workspace green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fa2d2e5 commit 937fb7f

9 files changed

Lines changed: 418 additions & 40 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

quilt-sync/src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ thiserror.workspace = true
4343
tokio.workspace = true
4444
tokio-stream.workspace = true
4545
tracing.workspace = true
46+
uuid.workspace = true
4647
tracing-appender = "0.2.5"
4748
tracing-subscriber = { workspace = true, features = ["env-filter"] }
4849
url.workspace = true

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,9 @@ async fn collect_diagnostic_logs_command(
198198
app_handle: &tauri::AppHandle,
199199
m: &model::Model,
200200
app: &app::App,
201+
tracing: &crate::telemetry::Telemetry,
201202
) -> Result<PathBuf, Error> {
202-
let info = diagnostics::collect(app_handle, m, app).await?;
203+
let info = diagnostics::collect(app_handle, m, app, tracing.install_id()).await?;
203204
tokio::task::spawn_blocking(move || diagnostics::save_diagnostic_zip(&info))
204205
.await
205206
.map_err(|e| e.to_string())?
@@ -216,7 +217,7 @@ pub async fn collect_diagnostic_logs(
216217
let app_handle = app_handle.lock().await;
217218
let app: &app::App = &app;
218219

219-
match collect_diagnostic_logs_command(&app_handle, &m, app).await {
220+
match collect_diagnostic_logs_command(&app_handle, &m, app, &tracing).await {
220221
Ok(zip_path) => Ok(zip_path.display().to_string()),
221222
Err(err) => Err(err.to_string()),
222223
}

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,21 @@ fn main() {
6464
.plugin(tauri_plugin_updater::Builder::new().build())
6565
.setup(|app| {
6666
let package_info = app.package_info();
67+
68+
// Resolved before the sinks are built: the crash client's event hook
69+
// captures the identity when the client is constructed, so it has to
70+
// be known first or crashes would go out unattributed.
71+
let data_dir = app
72+
.path()
73+
.app_local_data_dir()
74+
.expect("Failed to resolve data dir");
75+
6776
let sinks = telemetry::Sinks::resolve();
68-
let telemetry = telemetry::Telemetry::new(&package_info.version, sinks);
77+
let telemetry = telemetry::Telemetry::new(
78+
&package_info.version,
79+
sinks,
80+
telemetry::install_id::load(&data_dir),
81+
);
6982

7083
// This is for runtime registering
7184
#[cfg(desktop)]
@@ -75,11 +88,6 @@ fn main() {
7588
}
7689
}
7790

78-
let data_dir = app
79-
.path()
80-
.app_local_data_dir()
81-
.expect("Failed to resolve data dir");
82-
8391
let logs_dir = telemetry::Telemetry::init_file_logging(&data_dir)?;
8492

8593
telemetry.init();

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ use crate::Result;
88

99
pub mod diagnostics;
1010
pub mod event;
11+
pub mod install_id;
1112
pub mod mixpanel;
1213
pub mod sentry;
1314
pub mod tracing;
1415

1516
pub use event::MixpanelEvent;
17+
pub use install_id::InstallId;
1618
pub use mixpanel::Analytics;
1719
pub use tracing::LogsDir;
1820

@@ -75,10 +77,14 @@ pub struct Telemetry {
7577
analytics: Analytics,
7678
/// The host every outgoing crash report is tagged with; see [`AmbientHost`].
7779
host: AmbientHost,
80+
/// This install's identity, on every analytics event and every crash report
81+
/// so the two can be read together. `None` when it could not be persisted —
82+
/// see [`install_id::load`].
83+
install_id: Option<InstallId>,
7884
}
7985

8086
impl Telemetry {
81-
pub fn new(version: &Version, sinks: Sinks) -> Self {
87+
pub fn new(version: &Version, sinks: Sinks, install_id: Option<InstallId>) -> Self {
8288
// The cell outlives the client and is read by its event hook, so it has
8389
// to exist before the client is built.
8490
let host: AmbientHost = Arc::new(Mutex::new(None));
@@ -87,13 +93,20 @@ impl Telemetry {
8793
analytics: Analytics::resolve(sinks),
8894
_sentry: sinks
8995
.reports_crashes()
90-
.then(|| sentry::sentry_config(version, Arc::clone(&host)))
96+
.then(|| sentry::sentry_config(version, install_id.clone(), Arc::clone(&host)))
9197
.flatten()
9298
.map(::sentry::init),
9399
host,
100+
install_id,
94101
}
95102
}
96103

104+
/// This install's identity, for the diagnostic export — so a report a user
105+
/// emails in can be lined up against that install's event stream.
106+
pub fn install_id(&self) -> Option<&InstallId> {
107+
self.install_id.as_ref()
108+
}
109+
97110
pub fn init_file_logging(base_path: &std::path::Path) -> Result<LogsDir> {
98111
tracing::init_file_logging(base_path)
99112
}
@@ -119,7 +132,9 @@ impl Telemetry {
119132
if let Some(host) = event.host() {
120133
self.add_host(host);
121134
}
122-
if let Err(err) = mixpanel::track_event(&self.analytics, &event).await {
135+
if let Err(err) =
136+
mixpanel::track_event(&self.analytics, &event, self.install_id.as_ref()).await
137+
{
123138
Sentry::capture_error(&err);
124139
}
125140
}
@@ -145,7 +160,7 @@ impl Telemetry {
145160
}
146161

147162
pub fn init(&self) {
148-
mixpanel::init(&self.analytics);
163+
mixpanel::init(&self.analytics, self.install_id.clone());
149164
}
150165

151166
/// Returns the current global maximum log level as a human-readable string.
@@ -165,6 +180,7 @@ impl Default for Telemetry {
165180
_sentry: None,
166181
analytics: Analytics::Off,
167182
host: Arc::new(Mutex::new(None)),
183+
install_id: None,
168184
}
169185
}
170186
}
@@ -201,7 +217,7 @@ mod tests {
201217
std::env::set_var("SENTRY_DSN", "https://public@example.invalid/1");
202218
}
203219

204-
let telemetry = Telemetry::new(&Version::new(0, 0, 0), Sinks::resolve());
220+
let telemetry = Telemetry::new(&Version::new(0, 0, 0), Sinks::resolve(), None);
205221
assert!(
206222
matches!(telemetry.analytics, Analytics::DryRun),
207223
"a local build must never construct a live analytics client"

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ pub struct DiagnosticInfo {
2020
pub home_dir: String,
2121
pub logs_dir: PathBuf,
2222
pub auth_hosts: Vec<String>,
23+
/// This install's identity, so a report a user emails in can be lined up
24+
/// against that install's event stream and its crash reports.
25+
pub install_id: Option<String>,
2326
}
2427

2528
/// Typed shape of `metadata.json` stored inside a diagnostic zip.
@@ -30,6 +33,10 @@ struct DiagnosticMetadata {
3033
data_dir: String,
3134
home_dir: String,
3235
authenticated_hosts: Vec<String>,
36+
/// Absent on an install whose identity could not be persisted, so a missing
37+
/// value here means "no identity", never "an older export".
38+
#[serde(default, skip_serializing_if = "Option::is_none")]
39+
install_id: Option<String>,
3340
}
3441

3542
impl DiagnosticMetadata {
@@ -40,6 +47,7 @@ impl DiagnosticMetadata {
4047
data_dir: info.data_dir.display().to_string(),
4148
home_dir: info.home_dir.clone(),
4249
authenticated_hosts: info.auth_hosts.clone(),
50+
install_id: info.install_id.clone(),
4351
}
4452
}
4553
}
@@ -49,6 +57,7 @@ pub async fn collect(
4957
app_handle: &tauri::AppHandle,
5058
m: &Model,
5159
app: &App,
60+
install_id: Option<&crate::telemetry::InstallId>,
5261
) -> Result<DiagnosticInfo, Error> {
5362
let local_data_dir = app_handle.path().app_local_data_dir()?;
5463
let auth_hosts = quilt::paths::list_auth_hosts(&local_data_dir);
@@ -70,6 +79,7 @@ pub async fn collect(
7079
home_dir,
7180
logs_dir: app.logs_dir.path().to_path_buf(),
7281
auth_hosts,
82+
install_id: install_id.map(|id| id.as_str().to_string()),
7383
})
7484
}
7585

@@ -98,6 +108,9 @@ pub fn send_crash_report(zip_path: &Path) -> Result<(), Error> {
98108
scope.set_extra("os", m.os.clone().into());
99109
scope.set_extra("data_dir", m.data_dir.clone().into());
100110
scope.set_extra("home_dir", m.home_dir.clone().into());
111+
if let Some(ref install_id) = m.install_id {
112+
scope.set_extra("install_id", install_id.clone().into());
113+
}
101114
scope.set_extra(
102115
"authenticated_hosts",
103116
serde_json::json!(m.authenticated_hosts),
@@ -223,6 +236,7 @@ mod tests {
223236
home_dir: "/home/tester".to_string(),
224237
logs_dir,
225238
auth_hosts,
239+
install_id: Some("test-install-id".to_string()),
226240
}
227241
}
228242

0 commit comments

Comments
 (0)