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