11use std:: fmt:: Write ;
2+ use std:: sync:: Arc ;
23
34use serde:: Serialize ;
45
56use quilt_uri:: Host ;
67use quilt_uri:: Namespace ;
7- use tauri:: Emitter ;
8+ use tauri:: { Emitter , Manager } ;
89
910use crate :: autopull:: PausedReason ;
1011use crate :: quilt;
12+ use crate :: telemetry:: Telemetry ;
13+ use crate :: telemetry:: event:: {
14+ AutosyncAuthEvent , AutosyncEvent , AutosyncPausedEvent , MixpanelEvent , PausedKind ,
15+ } ;
1116use 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.
1434pub const STATUS_EVENT : & str = "package-status-changed" ;
1535pub 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.
193213pub 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+
239353pub 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 ( )
0 commit comments