1pub(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
95pub const PIPE_LOG_TARGET: &str = "offline_protocol::telemetry::pipe";
98
99pub const FINAL_FLUSH_BUDGET: Duration = Duration::from_secs(3);
101
102const ADOPTION_RETRY: Duration = Duration::from_millis(500);
106
107const ENVELOPE_OVERHEAD_BYTES: usize = 256;
109
110#[derive(Debug, Clone, PartialEq, Eq, Default)]
113pub struct TelemetryStats {
114 pub buffered: u64,
116 pub sent_events: u64,
118 pub accepted_events: u64,
121 pub dropped: u64,
133 pub session_id: String,
135 pub last_error: Option<String>,
137 pub last_flush_at_ms: Option<i64>,
139}
140
141pub(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
154pub(crate) struct PipeShared {
156 enabled: Arc<AtomicBool>,
158 debug: bool,
159 inline: bool,
164 pipeline: Mutex<Pipeline>,
165 store: Mutex<BatchStore>,
166 uploader: Mutex<Uploader>,
167 pending_attach: Mutex<Option<Backend>>,
170 adoption_blocked: AtomicBool,
173 device_id: Mutex<Option<String>>,
174 include_device_id: bool,
175 durable: AtomicBool,
178 envelope: Envelope,
179 max_batch_bytes: usize,
180 flush_interval: Duration,
181 clock: Clock,
182 signal: WakeSignal,
183 battery: AtomicU32,
186 sent_events: AtomicU64,
187 accepted_events: AtomicU64,
188 uploader_dropped: AtomicU64,
189 store_dropped: AtomicU64,
190 last_flush_at_ms: AtomicI64,
192 last_error: Mutex<Option<String>>,
193 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 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 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 self.set_last_error(uploader.last_error().map(str::to_string));
316 uploader.next_retry_at_ms()
317 }
318
319 fn adopt_pending_backend(&self) {
323 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 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 fn release_queue(&self) {
366 lock(&self.store).release();
367 self.durable.store(false, Ordering::Relaxed);
368 }
369}
370
371struct ReleaseQueue<'a>(&'a PipeShared);
373
374impl Drop for ReleaseQueue<'_> {
375 fn drop(&mut self) {
376 self.0.release_queue();
377 }
378}
379
380fn run_worker(shared: Arc<PipeShared>) {
384 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 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
422fn 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
485pub(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
500pub(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 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
539pub 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
556pub(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 pub(crate) spawn_thread: bool,
566}
567
568impl PipeParts {
569 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 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 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 pub(crate) fn sink(&self) -> Arc<dyn TelemetrySink> {
676 Arc::new(PipeSink {
677 shared: self.shared.clone(),
678 })
679 }
680
681 pub(crate) fn enabled_flag(&self) -> Arc<AtomicBool> {
683 self.shared.enabled.clone()
684 }
685
686 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 pub fn is_enabled(&self) -> bool {
705 self.shared.enabled.load(Ordering::Relaxed)
706 }
707
708 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 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 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 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 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 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 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 pub(crate) fn set_device_id(&self, device_id: Option<String>) {
835 *lock(&self.shared.device_id) = device_id;
836 }
837
838 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 pub fn is_stopped(&self) -> bool {
858 self.stopped.load(Ordering::Relaxed)
859 }
860
861 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 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}