Skip to content

Commit 7385d64

Browse files
authored
Make autosync visible (#824)
1 parent 323e951 commit 7385d64

7 files changed

Lines changed: 430 additions & 31 deletions

File tree

quilt-sync/src-tauri/src/autopull/reporter.rs

Lines changed: 144 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,35 @@
11
use std::fmt::Write;
2+
use std::sync::Arc;
23

34
use serde::Serialize;
45

56
use quilt_uri::Host;
67
use quilt_uri::Namespace;
7-
use tauri::Emitter;
8+
use tauri::{Emitter, Manager};
89

910
use crate::autopull::PausedReason;
1011
use crate::quilt;
12+
use crate::telemetry::Telemetry;
13+
use crate::telemetry::event::{
14+
AutosyncAuthEvent, AutosyncEvent, AutosyncPausedEvent, MixpanelEvent, PausedKind,
15+
};
1116
use crate::telemetry::prelude::*;
1217

18+
/// Whether a deployment's session has *just* become unusable, or was already.
19+
///
20+
/// The distinction exists for telemetry: the loop rediscovers an expired session
21+
/// on every backoff-due tick, for every package on that deployment, so reporting
22+
/// each discovery would count one expiry many times over. The UI wants the
23+
/// opposite — it re-renders the same affordance idempotently and does not care —
24+
/// so this informs telemetry without changing when anyone is told.
25+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26+
pub enum LoginBlock {
27+
/// No package on this deployment was blocked before. The episode starts here.
28+
Began,
29+
/// Another package on the same deployment is already blocked, or this one was.
30+
Continues,
31+
}
32+
1333
/// Event names. Kept in lockstep with the UI's `listen(...)` calls.
1434
pub const STATUS_EVENT: &str = "package-status-changed";
1535
pub const LOGIN_REQUIRED_EVENT: &str = "autosync-login-required";
@@ -192,8 +212,14 @@ pub struct SubscriberErrorEvent {
192212
/// tests and a hypothetical headless daemon wire a logger.
193213
pub trait StatusReporter: Send + Sync + 'static {
194214
fn report_status(&self, namespace: &Namespace, event: PackageStatusEvent);
195-
fn report_paused(&self, namespace: &Namespace, reason: PausedReason);
196-
fn report_login_required(&self, host: Option<&Host>);
215+
/// `host` is required rather than optional: the loop skips any package whose
216+
/// lineage has no origin before doing work, so an outcome it reports always
217+
/// concerns a known deployment.
218+
fn report_paused(&self, namespace: &Namespace, host: &Host, reason: PausedReason);
219+
/// `block` distinguishes the deployment's session *becoming* unusable from
220+
/// the loop retrying while it stays that way. Only the transition is worth
221+
/// counting; the retries are a log line.
222+
fn report_login_required(&self, host: Option<&Host>, block: LoginBlock);
197223
fn report_subscriber_error(&self, event: SubscriberErrorEvent) {
198224
warn!(
199225
"fswatcher: kind={} namespace={:?} message={}",
@@ -202,8 +228,8 @@ pub trait StatusReporter: Send + Sync + 'static {
202228
}
203229
/// Surface a successful autosync publish. Default implementation
204230
/// logs only; `TauriEventReporter` also emits `PUBLISHED_EVENT`.
205-
fn report_published(&self, namespace: &Namespace, message: &str) {
206-
info!("autosync: published namespace={namespace} message={message}");
231+
fn report_published(&self, namespace: &Namespace, host: &Host, message: &str) {
232+
info!("autosync: published namespace={namespace} host={host} message={message}");
207233
}
208234
}
209235

@@ -221,21 +247,109 @@ impl StatusReporter for LogReporter {
221247
);
222248
}
223249

224-
fn report_paused(&self, namespace: &Namespace, reason: PausedReason) {
225-
info!("autosync: paused namespace={namespace} reason={reason:?}");
250+
fn report_paused(&self, namespace: &Namespace, host: &Host, reason: PausedReason) {
251+
info!("autosync: paused namespace={namespace} host={host} reason={reason:?}");
226252
}
227253

228-
fn report_login_required(&self, host: Option<&Host>) {
254+
fn report_login_required(&self, host: Option<&Host>, block: LoginBlock) {
229255
if let Some(h) = host {
230-
warn!("autosync: login required for {h}");
256+
warn!("autosync: login required for {h} ({block:?})");
231257
} else {
232-
warn!("autosync: login required");
258+
warn!("autosync: login required ({block:?})");
233259
}
234260
}
235261
}
236262

237263
/// Production reporter: emits typed events on the Tauri event bus and
238264
/// also logs so file-tail-style debugging still works.
265+
/// Wraps another reporter and, in passing, tells telemetry what the engine just
266+
/// did.
267+
///
268+
/// A decorator rather than calls threaded into the tick: the engine already
269+
/// funnels every outcome through [`StatusReporter`], so that trait *is* the seam,
270+
/// and adding a second one inside the loop would mean two places to keep in step.
271+
/// The inner reporter still does its job — this only observes.
272+
///
273+
/// **Not every method reports.** `report_status` fires per package per tick and
274+
/// carries a fingerprint precisely so consumers can discard repeats; it is a
275+
/// progress signal, not a countable act, and sending it would swamp the
276+
/// vocabulary. A subscriber error is reported as a *fault* rather than an event,
277+
/// because the analytics vocabulary carries no error events by design.
278+
///
279+
/// Emission is spawned rather than awaited, because the trait is synchronous and
280+
/// telemetry is not. That is the right shape regardless: the engine must not wait
281+
/// on a network call to finish its tick.
282+
pub struct TelemetryReporter {
283+
handle: tauri::AppHandle,
284+
inner: Arc<dyn StatusReporter>,
285+
}
286+
287+
impl TelemetryReporter {
288+
pub fn wrapping(inner: Arc<dyn StatusReporter>, handle: tauri::AppHandle) -> Self {
289+
Self { handle, inner }
290+
}
291+
292+
/// Hand `event` to telemetry without blocking the caller.
293+
///
294+
/// The `Telemetry` lives in Tauri state rather than being held here, so a
295+
/// reporter constructed before it is managed still works — the same reason
296+
/// [`TauriEventReporter`] holds a handle instead of a window.
297+
fn emit(&self, event: MixpanelEvent) {
298+
let handle = self.handle.clone();
299+
tauri::async_runtime::spawn(async move {
300+
handle.state::<Telemetry>().track(event).await;
301+
});
302+
}
303+
}
304+
305+
impl StatusReporter for TelemetryReporter {
306+
fn report_status(&self, namespace: &Namespace, event: PackageStatusEvent) {
307+
// Deliberately not reported — see the type's note on volume.
308+
self.inner.report_status(namespace, event);
309+
}
310+
311+
fn report_paused(&self, namespace: &Namespace, host: &Host, reason: PausedReason) {
312+
self.emit(MixpanelEvent::AutosyncPaused(AutosyncPausedEvent {
313+
host: host.clone(),
314+
reason: PausedKind::from(&reason),
315+
}));
316+
self.inner.report_paused(namespace, host, reason);
317+
}
318+
319+
fn report_login_required(&self, host: Option<&Host>, block: LoginBlock) {
320+
// Once per deployment per episode. The loop rediscovers the same expired
321+
// session on every backoff-due tick and for every package on that host, so
322+
// counting discoveries would report one expiry as many.
323+
if block == LoginBlock::Began {
324+
self.emit(MixpanelEvent::AutosyncLoginRequired(AutosyncAuthEvent {
325+
host: host.cloned(),
326+
}));
327+
}
328+
self.inner.report_login_required(host, block);
329+
}
330+
331+
fn report_subscriber_error(&self, event: SubscriberErrorEvent) {
332+
// A fault, not an event: the vocabulary has no error events, and the
333+
// filesystem watcher concerns no deployment, so there would be nothing to
334+
// attribute one to. The message is a constant so the reporter groups it as
335+
// one issue rather than one per path.
336+
let handle = self.handle.clone();
337+
tauri::async_runtime::spawn(async move {
338+
handle
339+
.state::<Telemetry>()
340+
.report_anomaly("Autosync filesystem watcher reported an error");
341+
});
342+
self.inner.report_subscriber_error(event);
343+
}
344+
345+
fn report_published(&self, namespace: &Namespace, host: &Host, message: &str) {
346+
self.emit(MixpanelEvent::AutosyncPublished(AutosyncEvent {
347+
host: host.clone(),
348+
}));
349+
self.inner.report_published(namespace, host, message);
350+
}
351+
}
352+
239353
pub struct TauriEventReporter {
240354
handle: tauri::AppHandle,
241355
}
@@ -257,19 +371,19 @@ impl StatusReporter for TauriEventReporter {
257371
}
258372
}
259373

260-
fn report_paused(&self, namespace: &Namespace, reason: PausedReason) {
261-
info!("autosync: paused namespace={namespace} reason={reason:?}");
374+
fn report_paused(&self, namespace: &Namespace, host: &Host, reason: PausedReason) {
375+
info!("autosync: paused namespace={namespace} host={host} reason={reason:?}");
262376
let payload = PausedEvent::from_reason(namespace, &reason);
263377
if let Err(err) = self.handle.emit(PAUSED_EVENT, &payload) {
264378
warn!("autosync: failed to emit {PAUSED_EVENT}: {err}");
265379
}
266380
}
267381

268-
fn report_login_required(&self, host: Option<&Host>) {
382+
fn report_login_required(&self, host: Option<&Host>, block: LoginBlock) {
269383
if let Some(h) = host {
270-
warn!("autosync: login required for {h}");
384+
warn!("autosync: login required for {h} ({block:?})");
271385
} else {
272-
warn!("autosync: login required");
386+
warn!("autosync: login required ({block:?})");
273387
}
274388
// TODO(autosync/03-merge-conflicts.md): no UI listener yet.
275389
let payload = LoginRequiredEvent {
@@ -290,8 +404,8 @@ impl StatusReporter for TauriEventReporter {
290404
}
291405
}
292406

293-
fn report_published(&self, namespace: &Namespace, message: &str) {
294-
info!("autosync: published namespace={namespace} message={message}");
407+
fn report_published(&self, namespace: &Namespace, host: &Host, message: &str) {
408+
info!("autosync: published namespace={namespace} host={host} message={message}");
295409
let payload = PublishedEvent {
296410
namespace: namespace.to_string(),
297411
message: message.to_string(),
@@ -520,8 +634,15 @@ pub(crate) mod test_support {
520634
pub statuses: Mutex<Vec<(Namespace, PackageStatusEvent)>>,
521635
pub paused: Mutex<Vec<(Namespace, PausedReason)>>,
522636
pub logins: Mutex<Vec<Option<Host>>>,
637+
/// Whether each login report was the start of an episode or a repeat, so a
638+
/// test can assert one expiry is counted once.
639+
pub login_blocks: Mutex<Vec<LoginBlock>>,
523640
pub subscriber_errors: Mutex<Vec<SubscriberErrorEvent>>,
524641
pub published: Mutex<Vec<(Namespace, String)>>,
642+
/// Hosts seen on the outcomes that carry one, so a test can assert the
643+
/// engine attributed a report to the package's own deployment rather than
644+
/// merely compiling against a `&Host`.
645+
pub hosts: Mutex<Vec<Host>>,
525646
}
526647

527648
impl StatusReporter for RecordingReporter {
@@ -532,22 +653,25 @@ pub(crate) mod test_support {
532653
.push((namespace.clone(), event));
533654
}
534655

535-
fn report_paused(&self, namespace: &Namespace, reason: PausedReason) {
656+
fn report_paused(&self, namespace: &Namespace, host: &Host, reason: PausedReason) {
657+
self.hosts.lock().unwrap().push(host.clone());
536658
self.paused
537659
.lock()
538660
.unwrap()
539661
.push((namespace.clone(), reason));
540662
}
541663

542-
fn report_login_required(&self, host: Option<&Host>) {
664+
fn report_login_required(&self, host: Option<&Host>, block: LoginBlock) {
665+
self.login_blocks.lock().unwrap().push(block);
543666
self.logins.lock().unwrap().push(host.cloned());
544667
}
545668

546669
fn report_subscriber_error(&self, event: SubscriberErrorEvent) {
547670
self.subscriber_errors.lock().unwrap().push(event);
548671
}
549672

550-
fn report_published(&self, namespace: &Namespace, message: &str) {
673+
fn report_published(&self, namespace: &Namespace, host: &Host, message: &str) {
674+
self.hosts.lock().unwrap().push(host.clone());
551675
self.published
552676
.lock()
553677
.unwrap()

quilt-sync/src-tauri/src/autopull/tick.rs

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use quilt_uri::Namespace;
1010
use crate::Error;
1111
use crate::autopull::PausedReason;
1212
use crate::autopull::WatcherInner;
13+
use crate::autopull::reporter::LoginBlock;
1314
use crate::autopull::reporter::PackageStatusEvent;
1415
use crate::autopull::reporter::clean_uptodate_fingerprint;
1516
use crate::autopull::reporter::status_fingerprint;
@@ -53,6 +54,28 @@ impl RefreshOutcome {
5354
}
5455
}
5556

57+
/// Whether a login failure *starts* an episode for its deployment, or joins one.
58+
///
59+
/// Per deployment rather than per package, and that is the whole point: one expired
60+
/// session blocks every package on the host, and they reach this code one per loop
61+
/// iteration. A per-package answer would report one expiry as many.
62+
///
63+
/// Asked *before* the failing namespace is recorded, so its own entry cannot make
64+
/// it look like a continuation of itself.
65+
fn login_episode(
66+
blocked: &std::collections::BTreeMap<Namespace, Option<Host>>,
67+
host: Option<&Host>,
68+
) -> LoginBlock {
69+
if blocked
70+
.values()
71+
.any(|blocked_host| blocked_host.as_ref() == host)
72+
{
73+
LoginBlock::Continues
74+
} else {
75+
LoginBlock::Began
76+
}
77+
}
78+
5679
#[derive(Debug)]
5780
pub(crate) enum WatchError {
5881
Conflict(PausedReason),
@@ -464,7 +487,13 @@ pub(crate) async fn run_once(
464487
let Some(remote) = lineage.remote_uri.as_ref() else {
465488
continue;
466489
};
467-
if remote.origin.is_none() || remote.bucket.is_empty() {
490+
// The origin is what makes every outcome below attributable: the loop
491+
// declines to work on a package without one, so `report_*` can require a
492+
// host rather than accept an absent one.
493+
let Some(origin) = remote.origin.as_ref() else {
494+
continue;
495+
};
496+
if remote.bucket.is_empty() {
468497
continue;
469498
}
470499

@@ -490,7 +519,7 @@ pub(crate) async fn run_once(
490519
inner.backoff.write().await.remove(&namespace);
491520
inner.login_blocked.write().await.remove(&namespace);
492521
if let Some(message) = outcome.published.as_deref() {
493-
inner.reporter.report_published(&namespace, message);
522+
inner.reporter.report_published(&namespace, origin, message);
494523
}
495524
inner.reporter.report_status(
496525
&namespace,
@@ -509,26 +538,34 @@ pub(crate) async fn run_once(
509538
Err(WatchError::LoginRequired(host)) => {
510539
// Backoff until the user re-auths; the Ok arm clears it.
511540
bump_backoff(&mut *inner.backoff.write().await, &namespace, now);
512-
inner
513-
.login_blocked
514-
.write()
515-
.await
516-
.insert(namespace.clone(), host.clone());
517-
inner.reporter.report_login_required(host.as_ref());
541+
// The episode is per *deployment*, not per package: one expired
542+
// session blocks every package on that host, and they arrive one
543+
// per loop iteration. Asking whether any other namespace is
544+
// already blocked on this host — before inserting this one — is
545+
// what makes it countable once.
546+
let block = {
547+
let mut blocked = inner.login_blocked.write().await;
548+
let block = login_episode(&blocked, host.as_ref());
549+
blocked.insert(namespace.clone(), host.clone());
550+
block
551+
};
552+
inner.reporter.report_login_required(host.as_ref(), block);
518553
inner.aggregator.note_login_required(&namespace, host);
519554
}
520555
Err(WatchError::Conflict(reason)) => {
521556
// Only a `RoleDenied` needs this, and only it pays for it:
522557
// the lookup is cached per host and the namespace is about
523558
// to be paused, so a denied host costs at most one `/me`
524559
// per role switch — not one per tick.
525-
let reason = name_denied_role(model, roles, remote.origin.as_ref(), reason).await;
560+
let reason = name_denied_role(model, roles, Some(origin), reason).await;
526561
inner
527562
.paused
528563
.write()
529564
.await
530565
.insert(namespace.clone(), reason.clone());
531-
inner.reporter.report_paused(&namespace, reason.clone());
566+
inner
567+
.reporter
568+
.report_paused(&namespace, origin, reason.clone());
532569
// Heuristic status from the refusal reason — flow::pull /
533570
// flow::publish don't expose the post-attempt state
534571
// directly. The string `"error"` is **reserved** for "we

0 commit comments

Comments
 (0)