1pub(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
96pub const PIPE_LOG_TARGET: &str = "offline_protocol::telemetry::pipe";
99
100pub const FINAL_FLUSH_BUDGET: Duration = Duration::from_secs(3);
102
103const ADOPTION_RETRY: Duration = Duration::from_millis(500);
107
108const ENVELOPE_OVERHEAD_BYTES: usize = 256;
110
111#[derive(Debug, Clone, PartialEq, Eq, Default)]
114pub struct TelemetryStats {
115 pub buffered: u64,
117 pub sent_events: u64,
119 pub accepted_events: u64,
122 pub dropped: u64,
134 pub session_id: String,
136 pub last_error: Option<String>,
138 pub last_flush_at_ms: Option<i64>,
140}
141
142pub(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
155pub(crate) struct PipeShared {
157 enabled: Arc<AtomicBool>,
159 debug: bool,
160 inline: bool,
165 pipeline: Mutex<Pipeline>,
166 store: Mutex<BatchStore>,
167 uploader: Mutex<Uploader>,
168 pending_attach: Mutex<Option<Backend>>,
171 adoption_blocked: AtomicBool,
174 device_id: Mutex<Option<String>>,
175 include_device_id: bool,
176 durable: AtomicBool,
179 envelope: Envelope,
180 max_batch_bytes: usize,
181 flush_interval: Duration,
182 clock: Clock,
183 signal: WakeSignal,
184 battery: AtomicU32,
187 sent_events: AtomicU64,
188 accepted_events: AtomicU64,
189 uploader_dropped: AtomicU64,
190 store_dropped: AtomicU64,
191 last_flush_at_ms: AtomicI64,
193 last_error: Mutex<Option<String>>,
194 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 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 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 self.set_last_error(uploader.last_error().map(str::to_string));
317 uploader.next_retry_at_ms()
318 }
319
320 fn adopt_pending_backend(&self) {
324 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 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 fn release_queue(&self) {
367 lock(&self.store).release();
368 self.durable.store(false, Ordering::Relaxed);
369 }
370}
371
372struct ReleaseQueue<'a>(&'a PipeShared);
374
375impl Drop for ReleaseQueue<'_> {
376 fn drop(&mut self) {
377 self.0.release_queue();
378 }
379}
380
381fn run_worker(shared: Arc<PipeShared>) {
385 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 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
423fn 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
486pub(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
501pub(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 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
540pub 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
557pub(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 pub(crate) spawn_thread: bool,
567}
568
569impl PipeParts {
570 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 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 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 pub(crate) fn sink(&self) -> Arc<dyn TelemetrySink> {
677 Arc::new(PipeSink {
678 shared: self.shared.clone(),
679 })
680 }
681
682 pub(crate) fn enabled_flag(&self) -> Arc<AtomicBool> {
684 self.shared.enabled.clone()
685 }
686
687 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 pub fn is_enabled(&self) -> bool {
706 self.shared.enabled.load(Ordering::Relaxed)
707 }
708
709 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 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 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 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 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 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 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 pub(crate) fn set_device_id(&self, device_id: Option<String>) {
836 *lock(&self.shared.device_id) = device_id;
837 }
838
839 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 pub fn is_stopped(&self) -> bool {
859 self.stopped.load(Ordering::Relaxed)
860 }
861
862 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 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}