Skip to main content

offline_protocol/telemetry/pipe/
mod.rs

1//! The telemetry pipe: the client half of the hosted telemetry product,
2//! living in the core so every binding ships it from one implementation.
3//!
4//! # Shape
5//!
6//! ```text
7//!   emit path (any engine thread, under the engine's locks)
8//!     TelemetrySink::emit ─► classify ─► rollup / pairer / ring buffer
9//!                                              │  one mutex, no I/O
10//!   uploader thread (the one background thread in the workspace)
11//!     drain ring ─► cut batches ─► durable queue ─► HTTPS POST ─► stats
12//! ```
13//!
14//! The emit side is a bounded amount of work with no I/O: a gate on an
15//! atomic, a match on the record, and a push into a ring buffer under the
16//! pipe's own mutex. The uploader takes that mutex only to take the ring, so
17//! the one lock an engine thread can be holding is never held across storage
18//! or network I/O.
19//!
20//! The store and uploader mutexes *are* held across a drain, and deliberately:
21//! they guard the worker's own state, the alternative is a queue that can be
22//! reordered mid-send, and no engine thread reaches them. That last clause is
23//! load-bearing rather than incidental, so every handle method an engine
24//! thread can call reads an atomic instead ([`TelemetryPipe::stats`] takes
25//! only the pipeline mutex, [`TelemetryPipe::is_durable`] takes none). A
26//! method added here that locks the store would put a caller behind a
27//! fifteen-second request.
28//!
29//! Nothing here holds a reference to the engine, so a stopped pipe whose
30//! final request is still in flight can be dropped without keeping the
31//! engine alive. Such a pipe does keep its durable queue until its worker
32//! exits, and a pipe started meanwhile over the same storage holds its
33//! batches in memory until then: one writer per queue, for the reason
34//! `store.rs` gives.
35//!
36//! # What leaves the device
37//!
38//! Only what `WireEventData` in `wire.rs` can express: typed projections with no
39//! identifier field, the rollup and session summary the pipe computes, and
40//! the engine's own fixed `reason` tokens. `docs/telemetry.md` is the
41//! adopter-facing inventory; the golden fixture under `tests/` is the pin.
42//!
43//! # Session boundaries
44//!
45//! A session id is minted when telemetry is enabled and rotated on the first
46//! foreground after a background; the background edge emits the session
47//! summary and asks for a flush. Every buffered event is stamped with the
48//! session current at push time, and batches are cut on that stamp before
49//! they are cut on size, so a boundary event always carries the session it
50//! reports.
51
52pub(crate) mod classify;
53pub(crate) mod failure_limit;
54pub mod host;
55pub(crate) mod pipeline;
56pub(crate) mod rollup;
57pub(crate) mod scheduler;
58pub(crate) mod session_pairer;
59pub(crate) mod store;
60#[cfg(feature = "test-utils")]
61pub mod testing;
62pub(crate) mod uploader;
63pub(crate) mod wire;
64
65#[cfg(test)]
66mod tests;
67
68use std::collections::VecDeque;
69use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
70use std::sync::{Arc, Mutex, MutexGuard};
71use std::time::{Duration, Instant};
72
73use offline_protocol_telemetry_wire::{
74    Batch, BatchId, DeviceId, RawEvent, SessionId, CURRENT_WIRE_VERSION,
75};
76use offline_protocol_transport::{TransportStatus, TransportType};
77use uuid::Uuid;
78
79pub use host::{AppState, TelemetryHost, TelemetryOs};
80
81use crate::events::Event;
82use crate::protocol::state_crypto::StateRecordCipher;
83use crate::protocol_state_storage::ProtocolStateStorage;
84use crate::telemetry::config::TelemetryConfig;
85use crate::telemetry::pipe::pipeline::{LifecycleAction, Pipeline};
86use crate::telemetry::pipe::scheduler::{wake, Scheduler, WakeSignal};
87use crate::telemetry::pipe::store::{Attach, Backend, BatchStore, PendingBatch};
88use crate::telemetry::pipe::uploader::{
89    battery_defers, user_agent, HttpClient, Uploader, UreqClient, MAX_BATCHES_PER_WAKE, SDK_VERSION,
90};
91use crate::telemetry::pipe::wire::BufferedEvent;
92use crate::telemetry::sink::TelemetrySink;
93use crate::telemetry::TelemetryRecord;
94use crate::Error;
95
96/// The `tracing` target every pipe log line carries. The uniffi crate raises
97/// this target to `debug` while a pipe runs with `debug: true`.
98pub const PIPE_LOG_TARGET: &str = "offline_protocol::telemetry::pipe";
99
100/// How long a final flush (disable, end of session, drop) may block.
101pub const FINAL_FLUSH_BUDGET: Duration = Duration::from_secs(3);
102
103/// How soon a worker waiting for another pipe's queue asks for it again. The
104/// other pipe lets go when its worker exits, after the drain it was in and
105/// its final flush.
106const ADOPTION_RETRY: Duration = Duration::from_millis(500);
107
108/// Rough per-batch envelope overhead, for cutting batches on size.
109const ENVELOPE_OVERHEAD_BYTES: usize = 256;
110
111/// A snapshot of the pipe's counters, for diagnostics and for reconciling
112/// an invoice against the device.
113#[derive(Debug, Clone, PartialEq, Eq, Default)]
114pub struct TelemetryStats {
115    /// Events in the ring buffer, not yet cut into a batch.
116    pub buffered: u64,
117    /// Events in batches the ingest answered 2xx.
118    pub sent_events: u64,
119    /// Events the ingest reported accepted (the `accepted` count of each
120    /// 202 body, summed; a replay counts nothing new).
121    pub accepted_events: u64,
122    /// Events lost: ring overflow, queue caps, TTL expiry, permanent 4xx,
123    /// and everything queued or collected once the developer portal switched
124    /// telemetry off for the application.
125    ///
126    /// Events, never records. This number is what reconciles an invoice
127    /// against the device, so it counts in the unit the ingest meters. A
128    /// queued batch that will not open or parse on load is therefore absent
129    /// from it: how many events that batch held is precisely what was lost
130    /// with it, and a count of records added here would be two units summed
131    /// into one number. Those are reported by a warning on the pipe's log
132    /// target instead.
133    pub dropped: u64,
134    /// The current session id.
135    pub session_id: String,
136    /// The most recent send failure or configuration problem.
137    pub last_error: Option<String>,
138    /// When a batch was last accepted, epoch milliseconds.
139    pub last_flush_at_ms: Option<i64>,
140}
141
142/// Wall-clock source, injectable for tests.
143pub(crate) type Clock = Arc<dyn Fn() -> i64 + Send + Sync>;
144
145fn system_clock() -> Clock {
146    Arc::new(|| chrono::Utc::now().timestamp_millis())
147}
148
149struct Envelope {
150    app_version: String,
151    os: TelemetryOs,
152    os_major: u16,
153}
154
155/// Everything the emit side, the uploader thread and the handle share.
156pub(crate) struct PipeShared {
157    /// The hot-path gate, read at every emit site before anything else.
158    enabled: Arc<AtomicBool>,
159    debug: bool,
160    /// Run cycles on the calling thread instead of on a worker. The tests and
161    /// the benches; never a shipped binary, since `PipeParts::production`
162    /// always asks for a thread and a spawn that fails is an error rather
163    /// than a fallback.
164    inline: bool,
165    pipeline: Mutex<Pipeline>,
166    store: Mutex<BatchStore>,
167    uploader: Mutex<Uploader>,
168    /// A backend handed over by the engine, adopted by the worker so the
169    /// engine never waits behind an in-flight request.
170    pending_attach: Mutex<Option<Backend>>,
171    /// Set while that backend waits for another pipe in this process to let
172    /// go of the queue there; the worker asks again on a short timer.
173    adoption_blocked: AtomicBool,
174    device_id: Mutex<Option<String>>,
175    include_device_id: bool,
176    /// Mirrors `BatchStore::is_durable`, so a caller can ask without taking
177    /// the store mutex the uploader holds across a request.
178    durable: AtomicBool,
179    envelope: Envelope,
180    max_batch_bytes: usize,
181    flush_interval: Duration,
182    clock: Clock,
183    signal: WakeSignal,
184    /// Packed battery: bit 16 set when known, bit 8 when charging, low byte
185    /// the level.
186    battery: AtomicU32,
187    sent_events: AtomicU64,
188    accepted_events: AtomicU64,
189    uploader_dropped: AtomicU64,
190    store_dropped: AtomicU64,
191    /// `-1` until a batch has been accepted.
192    last_flush_at_ms: AtomicI64,
193    last_error: Mutex<Option<String>>,
194    /// Set once the ingest has answered `telemetry_disabled`: the
195    /// application's toggle is off in the developer portal. From then on the
196    /// ring is emptied into nothing at every cycle rather than cut into
197    /// batches, and a durable queue adopted afterwards is emptied as it is
198    /// adopted, so nothing the pipe holds or adopts is left on disk for a
199    /// later pipe to send, and the gap the toggle promises holds at the
200    /// device. Read on the cycle and at adoption, never on the emit path,
201    /// which keeps its one atomic load. Cleared only by a fresh pipe, which is
202    /// what the next `enable_telemetry` builds.
203    server_disabled: AtomicBool,
204}
205
206fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
207    mutex.lock().unwrap_or_else(|e| e.into_inner())
208}
209
210impl PipeShared {
211    fn note_battery(&self, level: Option<u8>, is_charging: bool) {
212        let packed = 1 << 16 | u32::from(is_charging) << 8 | u32::from(level.unwrap_or(0));
213        let packed = if level.is_some() {
214            packed
215        } else {
216            packed & !0xff | 0x8000
217        };
218        self.battery.store(packed, Ordering::Relaxed);
219    }
220
221    fn battery(&self) -> (Option<u8>, bool) {
222        let packed = self.battery.load(Ordering::Relaxed);
223        if packed & (1 << 16) == 0 {
224            return (None, false);
225        }
226        let level = if packed & 0x8000 != 0 {
227            None
228        } else {
229            Some((packed & 0xff) as u8)
230        };
231        (level, packed & (1 << 8) != 0)
232    }
233
234    fn set_last_error(&self, error: Option<String>) {
235        *lock(&self.last_error) = error;
236    }
237
238    /// One drain-and-send cycle. Returns when the backoff lifts, if a
239    /// backoff is in force, so the worker can wake for it.
240    fn cycle(&self, reasons: u8, deadline: Option<Instant>) -> Option<i64> {
241        let now = (self.clock)();
242        self.adopt_pending_backend();
243
244        let drained = lock(&self.pipeline).drain();
245        if self.server_disabled.load(Ordering::Relaxed) {
246            // Counted as dropped, in events, the unit the rest of `dropped`
247            // uses: the toggle is off, and these are the events it says are
248            // not kept.
249            self.uploader_dropped
250                .fetch_add(drained.len() as u64, Ordering::Relaxed);
251        } else if !drained.is_empty() {
252            let device_id = if self.include_device_id {
253                lock(&self.device_id).clone()
254            } else {
255                None
256            };
257            let batches = build_batches(
258                drained,
259                &self.envelope,
260                device_id,
261                self.max_batch_bytes,
262                now,
263                self.debug,
264            );
265            let mut store = lock(&self.store);
266            for batch in batches {
267                store.push(batch);
268            }
269            store.flush_index();
270            self.durable.store(store.is_durable(), Ordering::Relaxed);
271            self.store_dropped
272                .store(store.dropped_events(), Ordering::Relaxed);
273        }
274
275        let is_final = reasons & wake::FINAL != 0;
276        let (level, charging) = self.battery();
277        if !is_final && battery_defers(level, charging) {
278            tracing::debug!(
279                target: PIPE_LOG_TARGET,
280                battery_level = ?level,
281                "telemetry flush deferred on battery"
282            );
283            return lock(&self.uploader).next_retry_at_ms();
284        }
285
286        let mut store = lock(&self.store);
287        let mut uploader = lock(&self.uploader);
288        let report = uploader.drain(&mut store, now, MAX_BATCHES_PER_WAKE, deadline);
289        self.durable.store(store.is_durable(), Ordering::Relaxed);
290        self.store_dropped
291            .store(store.dropped_events(), Ordering::Relaxed);
292        drop(store);
293        self.sent_events
294            .fetch_add(report.sent_events, Ordering::Relaxed);
295        self.accepted_events
296            .fetch_add(report.accepted_events, Ordering::Relaxed);
297        self.uploader_dropped
298            .fetch_add(report.dropped_events, Ordering::Relaxed);
299        if report.batches_sent > 0 {
300            self.last_flush_at_ms.store(now, Ordering::Relaxed);
301        }
302        if report.disabled && !self.server_disabled.swap(true, Ordering::Relaxed) {
303            tracing::warn!(
304                target: PIPE_LOG_TARGET,
305                dropped_events = report.dropped_events,
306                "hosted telemetry is switched off for this application in the developer \
307                 portal; the queue was dropped and nothing more is collected until \
308                 enable_telemetry"
309            );
310        }
311        // Mirrored, not accumulated: the uploader clears its own error when a
312        // batch is accepted, and a stats reader asking "is telemetry working"
313        // after a recovered outage must not still be shown the outage. A
314        // cycle that sent nothing leaves the uploader's value alone, so this
315        // does not erase an error just because the queue was empty.
316        self.set_last_error(uploader.last_error().map(str::to_string));
317        uploader.next_retry_at_ms()
318    }
319
320    /// Offers the store the backend the engine handed over, if any. One that
321    /// is refused is kept for the next attempt unless a newer one arrived in
322    /// the meantime.
323    fn adopt_pending_backend(&self) {
324        // Taken and released before the store mutex, never held with it: the
325        // engine's `attach_storage` takes this one, and the store mutex is
326        // held across a request.
327        let Some(backend) = lock(&self.pending_attach).take() else {
328            return;
329        };
330        let mut store = lock(&self.store);
331        let refused = match store.attach(backend) {
332            Attach::Adopted => None,
333            Attach::Busy(backend) => Some((backend, true)),
334            Attach::Failed(backend) => Some((backend, false)),
335        };
336        if refused.is_none() && self.server_disabled.load(Ordering::Relaxed) {
337            // The toggle is off, and adoption is the one way a batch can still
338            // enter the store: the cycle no longer cuts any, but adopting
339            // loads whatever the queue on that storage already holds, an
340            // earlier launch's batches included. The halted uploader returns
341            // before it reads the queue, so a batch kept here would sit on
342            // disk until the next `enable_telemetry` resent it, which is the
343            // backfill the toggle promises not to make.
344            let mut dropped = 0u64;
345            while let Some(batch) = store.pop_front() {
346                dropped += u64::from(batch.event_count);
347            }
348            self.uploader_dropped.fetch_add(dropped, Ordering::Relaxed);
349        }
350        store.flush_index();
351        self.durable.store(store.is_durable(), Ordering::Relaxed);
352        self.store_dropped
353            .store(store.dropped_events(), Ordering::Relaxed);
354        drop(store);
355        self.adoption_blocked.store(
356            refused.as_ref().is_some_and(|(_, busy)| *busy),
357            Ordering::Relaxed,
358        );
359        if let Some((backend, _)) = refused {
360            lock(&self.pending_attach).get_or_insert(backend);
361        }
362    }
363
364    /// Lets go of the durable queue so another pipe over the same storage can
365    /// adopt it. Called once no further cycle will run.
366    fn release_queue(&self) {
367        lock(&self.store).release();
368        self.durable.store(false, Ordering::Relaxed);
369    }
370}
371
372/// Releases the pipe's durable queue when dropped. See [`run_worker`].
373struct ReleaseQueue<'a>(&'a PipeShared);
374
375impl Drop for ReleaseQueue<'_> {
376    fn drop(&mut self) {
377        self.0.release_queue();
378    }
379}
380
381/// The worker loop: wait for a reason or the interval, run one cycle,
382/// publish the flush generation, stop after the final flush, and let go of
383/// the durable queue on the way out.
384fn run_worker(shared: Arc<PipeShared>) {
385    // Declared first so it drops last, after the final flush, and on every
386    // way out of this function including an unwinding panic. A queue left
387    // claimed would keep every later pipe over the same storage in memory for
388    // the rest of the process.
389    let _release = ReleaseQueue(&shared);
390    let mut next_interval = Instant::now() + shared.flush_interval;
391    loop {
392        let wake_at = if shared.adoption_blocked.load(Ordering::Relaxed) {
393            next_interval.min(Instant::now() + ADOPTION_RETRY)
394        } else {
395            next_interval
396        };
397        let (reasons, generation) = shared.signal.wait(wake_at);
398        if reasons == wake::INTERVAL && Instant::now() < next_interval {
399            // Only the adoption retry came due. Nothing was asked for and the
400            // interval has not elapsed, so no batch is cut and no socket opens.
401            shared.adopt_pending_backend();
402            continue;
403        }
404        let stopping = reasons & wake::STOP != 0;
405        let deadline = stopping.then(|| Instant::now() + FINAL_FLUSH_BUDGET);
406        let retry_at = shared.cycle(reasons, deadline);
407        shared.signal.complete(generation);
408        if stopping {
409            break;
410        }
411        let now_ms = (shared.clock)();
412        let interval = Instant::now() + shared.flush_interval;
413        next_interval = match retry_at {
414            Some(at) if at > now_ms => {
415                let wait = Duration::from_millis((at - now_ms).min(i64::MAX / 2) as u64);
416                interval.min(Instant::now() + wait)
417            }
418            _ => interval,
419        };
420    }
421}
422
423/// Cuts drained events into batches that agree on their session and fit
424/// `max_batch_bytes`. Session first, size second: `session_id` lives on the
425/// envelope, so one batch can only ever claim one session.
426fn build_batches(
427    events: VecDeque<BufferedEvent>,
428    envelope: &Envelope,
429    device_id: Option<String>,
430    max_batch_bytes: usize,
431    now_ms: i64,
432    debug: bool,
433) -> Vec<PendingBatch> {
434    let mut batches = Vec::new();
435    let mut chunk: Vec<RawEvent> = Vec::new();
436    let mut chunk_session = Uuid::nil();
437    let mut chunk_bytes = ENVELOPE_OVERHEAD_BYTES;
438    let finish = |chunk: &mut Vec<RawEvent>, session: Uuid, batches: &mut Vec<PendingBatch>| {
439        if chunk.is_empty() {
440            return;
441        }
442        let batch = Batch {
443            batch_id: BatchId(Uuid::new_v4()),
444            session_id: SessionId(session),
445            device_id: device_id.clone().map(DeviceId),
446            wire_version: CURRENT_WIRE_VERSION,
447            app_version: envelope.app_version.clone(),
448            os: envelope.os.wire(),
449            os_major: envelope.os_major,
450            sdk_version: SDK_VERSION.to_string(),
451            events: std::mem::take(chunk),
452        };
453        if debug {
454            log_batch(&batch);
455        }
456        let Ok(body) = serde_json::to_string(&batch) else {
457            return;
458        };
459        batches.push(PendingBatch {
460            seq: None,
461            batch_id: batch.batch_id.0,
462            created_ms: now_ms,
463            event_count: batch.events.len().min(u32::MAX as usize) as u32,
464            body,
465        });
466    };
467    for item in events {
468        let raw = item.event.to_raw();
469        let bytes = serde_json::to_string(&raw).map_or(0, |s| s.len());
470        if !chunk.is_empty()
471            && (item.session != chunk_session || chunk_bytes + bytes > max_batch_bytes)
472        {
473            finish(&mut chunk, chunk_session, &mut batches);
474            chunk_bytes = ENVELOPE_OVERHEAD_BYTES;
475        }
476        if chunk.is_empty() {
477            chunk_session = item.session;
478        }
479        chunk.push(raw);
480        chunk_bytes += bytes;
481    }
482    finish(&mut chunk, chunk_session, &mut batches);
483    batches
484}
485
486/// The debug tap: one `debug` line per wire event, under [`PIPE_LOG_TARGET`].
487pub(crate) fn log_batch(batch: &Batch) {
488    for event in &batch.events {
489        tracing::debug!(
490            target: PIPE_LOG_TARGET,
491            batch_id = %batch.batch_id.0,
492            session_id = %batch.session_id.0,
493            event_type = %event.event_type,
494            ts_ms = event.ts_ms,
495            data = %event.data,
496            "telemetry wire event"
497        );
498    }
499}
500
501/// The sink the engine dispatches into while a pipe is enabled.
502pub(crate) struct PipeSink {
503    shared: Arc<PipeShared>,
504}
505
506impl TelemetrySink for PipeSink {
507    fn emit(&self, record: &TelemetryRecord) {
508        if !self.shared.enabled.load(Ordering::Relaxed) {
509            return;
510        }
511        if let TelemetryRecord::Device(snapshot) = record {
512            self.shared
513                .note_battery(snapshot.battery_level, snapshot.is_charging);
514        }
515        let now = (self.shared.clock)();
516        let trigger = lock(&self.shared.pipeline).handle(record, now);
517        if trigger {
518            self.shared.signal.wake(wake::SIZE);
519        }
520    }
521
522    fn try_emit_protocol_event(&self, event: &Event) -> bool {
523        if !self.shared.enabled.load(Ordering::Relaxed) {
524            return true;
525        }
526        // Dropped by name before any clock read or lock: this is the path
527        // the sixty-odd uncollected events take.
528        if !classify::PROTOCOL_ALLOWLIST.contains(&event.telemetry_name()) {
529            return true;
530        }
531        let now = (self.shared.clock)();
532        let trigger = lock(&self.shared.pipeline).handle_protocol_event(event, now);
533        if trigger {
534            self.shared.signal.wake(wake::SIZE);
535        }
536        true
537    }
538}
539
540/// A running telemetry pipe. Created by `OfflineProtocol::enable_telemetry`
541/// and stopped by `disable_telemetry` or by being dropped.
542pub struct TelemetryPipe {
543    shared: Arc<PipeShared>,
544    scheduler: Mutex<Option<Scheduler>>,
545    stopped: AtomicBool,
546}
547
548impl std::fmt::Debug for TelemetryPipe {
549    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
550        f.debug_struct("TelemetryPipe")
551            .field("enabled", &self.shared.enabled.load(Ordering::Relaxed))
552            .field("stopped", &self.stopped.load(Ordering::Relaxed))
553            .finish()
554    }
555}
556
557/// What a pipe is built from, beyond its configuration.
558pub(crate) struct PipeParts {
559    pub(crate) host: TelemetryHost,
560    pub(crate) backend: Backend,
561    pub(crate) device_id: Option<String>,
562    pub(crate) client: Box<dyn HttpClient>,
563    pub(crate) clock: Clock,
564    pub(crate) jitter: Box<dyn FnMut() -> u64 + Send>,
565    /// `false` runs cycles inline on the caller (tests).
566    pub(crate) spawn_thread: bool,
567}
568
569impl PipeParts {
570    /// The production parts: the ureq client, the system clock, OS entropy
571    /// for the jitter, and a thread.
572    pub(crate) fn production(
573        host: TelemetryHost,
574        backend: Backend,
575        device_id: Option<String>,
576    ) -> Self {
577        #[cfg(feature = "test-utils")]
578        let client = testing::override_client().unwrap_or_else(|| Box::new(UreqClient::new()));
579        #[cfg(not(feature = "test-utils"))]
580        let client: Box<dyn HttpClient> = Box::new(UreqClient::new());
581        Self {
582            host,
583            backend,
584            device_id,
585            client,
586            clock: system_clock(),
587            jitter: Box::new(|| {
588                use rand_core::RngCore;
589                u64::from(rand_core::OsRng.next_u32())
590            }),
591            spawn_thread: true,
592        }
593    }
594}
595
596impl TelemetryPipe {
597    /// Builds and starts a pipe. The configuration must already be valid.
598    pub(crate) fn start(config: &TelemetryConfig, parts: PipeParts) -> Result<Arc<Self>, Error> {
599        let now = (parts.clock)();
600        let pipeline = Pipeline::new(
601            config.max_buffered_records(),
602            config.max_batch_bytes(),
603            config.routing_diagnostic(),
604            now,
605        );
606        let uploader = Uploader::new(
607            parts.client,
608            config.api_key().unwrap_or_default(),
609            config.app_id().unwrap_or_default(),
610            user_agent(parts.host.os.user_agent_token(), parts.host.os_major),
611            parts.jitter,
612        );
613        let mut store = BatchStore::new(
614            Backend::Memory,
615            config.app_id().unwrap_or_default().to_string(),
616        );
617        let (pending_attach, adoption_blocked) = match store.attach(parts.backend) {
618            Attach::Adopted => (None, false),
619            Attach::Busy(backend) => (Some(backend), true),
620            Attach::Failed(backend) => (Some(backend), false),
621        };
622        let durable = store.is_durable();
623        let shared = Arc::new(PipeShared {
624            enabled: Arc::new(AtomicBool::new(true)),
625            debug: config.debug(),
626            inline: !parts.spawn_thread,
627            pipeline: Mutex::new(pipeline),
628            store: Mutex::new(store),
629            uploader: Mutex::new(uploader),
630            pending_attach: Mutex::new(pending_attach),
631            adoption_blocked: AtomicBool::new(adoption_blocked),
632            device_id: Mutex::new(parts.device_id),
633            include_device_id: config.include_device_id(),
634            durable: AtomicBool::new(durable),
635            envelope: Envelope {
636                app_version: config.app_version().to_string(),
637                os: parts.host.os,
638                os_major: parts.host.os_major,
639            },
640            max_batch_bytes: config.max_batch_bytes(),
641            flush_interval: config.flush_interval(),
642            clock: parts.clock,
643            signal: WakeSignal::default(),
644            battery: AtomicU32::new(0),
645            sent_events: AtomicU64::new(0),
646            accepted_events: AtomicU64::new(0),
647            uploader_dropped: AtomicU64::new(0),
648            store_dropped: AtomicU64::new(0),
649            last_flush_at_ms: AtomicI64::new(-1),
650            last_error: Mutex::new(None),
651            server_disabled: AtomicBool::new(false),
652        });
653        // The host's state at enable time, recorded rather than reported:
654        // there is no session to close yet, and the seed exists so that a
655        // pipe enabled in the background rotates on the first foreground.
656        // See `Pipeline::seed_app_state`. Done before the worker exists, so
657        // it can never observe an unseeded pipeline.
658        lock(&shared.pipeline).seed_app_state(parts.host.app_state);
659        let scheduler = if parts.spawn_thread {
660            let worker = shared.clone();
661            Some(
662                Scheduler::spawn(move || run_worker(worker))
663                    .map_err(|e| Error::Other(format!("telemetry thread could not start: {e}")))?,
664            )
665        } else {
666            None
667        };
668        Ok(Arc::new(Self {
669            shared,
670            scheduler: Mutex::new(scheduler),
671            stopped: AtomicBool::new(false),
672        }))
673    }
674
675    /// The sink to install on the engine.
676    pub(crate) fn sink(&self) -> Arc<dyn TelemetrySink> {
677        Arc::new(PipeSink {
678            shared: self.shared.clone(),
679        })
680    }
681
682    /// The hot-path gate, shared with the engine's telemetry context.
683    pub(crate) fn enabled_flag(&self) -> Arc<AtomicBool> {
684        self.shared.enabled.clone()
685    }
686
687    /// Stops or resumes collection. Off, an emit costs one atomic load and
688    /// nothing is buffered; what is already queued still drains.
689    ///
690    /// Resuming restamps the summary anchor. The duration is the one summary
691    /// field derived from a clock rather than accumulated from records, so
692    /// unlike every counter it keeps growing while collection is off, and the
693    /// first row afterwards would otherwise bill the entire opt-out. Only the
694    /// off-to-on edge does this, so repeated `set_enabled(true)` calls on a
695    /// live pipe do not keep resetting the span.
696    pub fn set_enabled(&self, enabled: bool) {
697        let was = self.shared.enabled.swap(enabled, Ordering::Relaxed);
698        if enabled && !was {
699            let now = (self.shared.clock)();
700            lock(&self.shared.pipeline).resume_collection(now);
701        }
702    }
703
704    /// Whether collection is on.
705    pub fn is_enabled(&self) -> bool {
706        self.shared.enabled.load(Ordering::Relaxed)
707    }
708
709    /// Asks the uploader to flush now. Returns immediately.
710    pub fn flush(&self) {
711        if self.shared.inline {
712            self.shared.cycle(wake::EXPLICIT, None);
713            return;
714        }
715        self.shared.signal.wake(wake::EXPLICIT);
716    }
717
718    /// Flushes and waits up to `budget` for the uploader to finish the
719    /// cycle. Returns whether it finished in time.
720    pub fn flush_blocking(&self, budget: Duration) -> bool {
721        if self.shared.inline {
722            self.shared
723                .cycle(wake::EXPLICIT, Some(Instant::now() + budget));
724            return true;
725        }
726        if self.stopped.load(Ordering::Relaxed) {
727            return false;
728        }
729        let generation = self.shared.signal.request_flush(wake::EXPLICIT);
730        self.shared
731            .signal
732            .wait_completed(generation, Instant::now() + budget)
733    }
734
735    /// A snapshot of the counters.
736    pub fn stats(&self) -> TelemetryStats {
737        let (buffered, ring_dropped, session_id) = {
738            let pipeline = lock(&self.shared.pipeline);
739            (
740                pipeline.buffered() as u64,
741                pipeline.dropped(),
742                pipeline.session_id().to_string(),
743            )
744        };
745        let last_flush = self.shared.last_flush_at_ms.load(Ordering::Relaxed);
746        TelemetryStats {
747            buffered,
748            sent_events: self.shared.sent_events.load(Ordering::Relaxed),
749            accepted_events: self.shared.accepted_events.load(Ordering::Relaxed),
750            dropped: ring_dropped
751                + self.shared.store_dropped.load(Ordering::Relaxed)
752                + self.shared.uploader_dropped.load(Ordering::Relaxed),
753            session_id,
754            last_error: lock(&self.shared.last_error).clone(),
755            last_flush_at_ms: (last_flush >= 0).then_some(last_flush),
756        }
757    }
758
759    /// Closes the current session: summary, a blocking flush within
760    /// [`FINAL_FLUSH_BUDGET`], then a fresh session id.
761    ///
762    /// A no-op while collection is off, for the reason given on
763    /// [`Self::notify_app_state`]. [`Self::flush`] is the call that pushes a
764    /// queued backlog without collecting anything new.
765    pub fn end_session(&self) {
766        if !self.is_enabled() {
767            return;
768        }
769        let now = (self.shared.clock)();
770        lock(&self.shared.pipeline).emit_session_summary(now);
771        self.flush_blocking(FINAL_FLUSH_BUDGET);
772        let now = (self.shared.clock)();
773        lock(&self.shared.pipeline).rotate_session(now);
774    }
775
776    /// Reports an application lifecycle transition.
777    ///
778    /// While collection is off the transition is recorded but nothing is
779    /// emitted. A session summary is collection: it is a row the ingest
780    /// stores and bills, carrying the session duration and every counter the
781    /// pairer accumulated, so emitting one for a user who switched telemetry
782    /// off would break the promise `docs/telemetry.md` makes for that switch.
783    /// The edge is still tracked, so the next foreground after collection
784    /// resumes rotates the session rather than being read as a non-edge.
785    pub fn notify_app_state(&self, state: AppState) {
786        if !self.is_enabled() {
787            lock(&self.shared.pipeline).seed_app_state(state);
788            return;
789        }
790        let now = (self.shared.clock)();
791        let action = lock(&self.shared.pipeline).app_state(state, now);
792        if action == LifecycleAction::BackgroundFlush {
793            if self.shared.inline {
794                self.shared.cycle(wake::BACKGROUND, None);
795            } else {
796                self.shared.signal.wake(wake::BACKGROUND);
797            }
798        }
799    }
800
801    /// Reports a transport status change observed by the engine's tick. An
802    /// internet-capable transport becoming available is a reason to try
803    /// sending what an offline stretch queued.
804    pub(crate) fn notify_transport(&self, transport: TransportType, status: TransportStatus) {
805        if matches!(transport, TransportType::Internet | TransportType::Nostr)
806            && status == TransportStatus::Available
807        {
808            if self.shared.inline {
809                self.shared.cycle(wake::TRANSPORT, None);
810            } else {
811                self.shared.signal.wake(wake::TRANSPORT);
812            }
813        }
814    }
815
816    /// Hands the pipe a durable backend. Adopted by the uploader on its next
817    /// cycle, which persists whatever is pending, so the caller never waits
818    /// behind a request. While another pipe in this process still owns the
819    /// queue on that storage, adoption waits for it to let go.
820    pub(crate) fn attach_storage(
821        &self,
822        storage: Arc<dyn ProtocolStateStorage>,
823        cipher: StateRecordCipher,
824    ) {
825        *lock(&self.shared.pending_attach) = Some(Backend::Sealed { storage, cipher });
826        if self.shared.inline {
827            self.shared.cycle(wake::ATTACH, None);
828        } else {
829            self.shared.signal.wake(wake::ATTACH);
830        }
831    }
832
833    /// Sets the per-install id stamped on batches when the configuration
834    /// opted in. Re-asked by the engine until it resolves.
835    pub(crate) fn set_device_id(&self, device_id: Option<String>) {
836        *lock(&self.shared.device_id) = device_id;
837    }
838
839    /// Stops the uploader after one final flush, waiting up to `budget` for
840    /// the thread to exit before detaching it. Idempotent.
841    pub fn stop(&self, budget: Duration) -> bool {
842        if self.stopped.swap(true, Ordering::SeqCst) {
843            return true;
844        }
845        self.shared.signal.request_stop();
846        let mut scheduler = lock(&self.scheduler);
847        match scheduler.as_mut() {
848            Some(scheduler) => scheduler.join_until(Instant::now() + budget),
849            None => {
850                self.shared.cycle(wake::STOP, Some(Instant::now() + budget));
851                self.shared.release_queue();
852                true
853            }
854        }
855    }
856
857    /// Whether `stop` has run.
858    pub fn is_stopped(&self) -> bool {
859        self.stopped.load(Ordering::Relaxed)
860    }
861
862    /// Whether the batch queue is mirrored to storage.
863    ///
864    /// Reads an atomic rather than the store mutex, which the uploader holds
865    /// across a request: a caller asking a question about configuration must
866    /// not be parked behind a fifteen-second socket timeout.
867    pub fn is_durable(&self) -> bool {
868        self.shared.durable.load(Ordering::Relaxed)
869    }
870
871    #[cfg(test)]
872    pub(crate) fn shared(&self) -> &Arc<PipeShared> {
873        &self.shared
874    }
875
876    #[cfg(test)]
877    pub(crate) fn run_cycle(&self, reasons: u8) {
878        self.shared.cycle(reasons, None);
879    }
880}
881
882impl Drop for TelemetryPipe {
883    /// Signals the final flush and detaches the worker without waiting for it.
884    ///
885    /// The flush still happens, bounded by [`FINAL_FLUSH_BUDGET`] on the
886    /// worker's own thread; what the dropping thread does not do is block for
887    /// it. A drop lands wherever the last handle happens to be released, and
888    /// on the mobile bindings that is a teardown path: iOS releases native
889    /// modules on the main thread during a bridge reload, so waiting here put
890    /// three seconds inside the scene-update watchdog's window. A caller that
891    /// needs to observe the flush calls [`Self::stop`] with a budget first,
892    /// which is what `disable_telemetry` does.
893    ///
894    /// An inline pipe is the exception, and only because it has to be: with
895    /// no worker there is no other thread to run the flush on, so the
896    /// dropping thread is the only one that can. That configuration is the
897    /// tests and the benches, never a shipped binary.
898    fn drop(&mut self) {
899        let budget = if self.shared.inline {
900            FINAL_FLUSH_BUDGET
901        } else {
902            Duration::ZERO
903        };
904        self.stop(budget);
905    }
906}