pub(crate) mod classify;
pub(crate) mod failure_limit;
pub mod host;
pub(crate) mod pipeline;
pub(crate) mod rollup;
pub(crate) mod scheduler;
pub(crate) mod session_pairer;
pub(crate) mod store;
#[cfg(feature = "test-utils")]
pub mod testing;
pub(crate) mod uploader;
pub(crate) mod wire;
#[cfg(test)]
mod tests;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use offline_protocol_telemetry_wire::{
Batch, BatchId, DeviceId, RawEvent, SessionId, CURRENT_WIRE_VERSION,
};
use offline_protocol_transport::{TransportStatus, TransportType};
use uuid::Uuid;
pub use host::{AppState, TelemetryHost, TelemetryOs};
use crate::events::Event;
use crate::protocol::state_crypto::StateRecordCipher;
use crate::protocol_state_storage::ProtocolStateStorage;
use crate::telemetry::config::TelemetryConfig;
use crate::telemetry::pipe::pipeline::{LifecycleAction, Pipeline};
use crate::telemetry::pipe::scheduler::{wake, Scheduler, WakeSignal};
use crate::telemetry::pipe::store::{Attach, Backend, BatchStore, PendingBatch};
use crate::telemetry::pipe::uploader::{
battery_defers, user_agent, HttpClient, Uploader, UreqClient, MAX_BATCHES_PER_WAKE, SDK_VERSION,
};
use crate::telemetry::pipe::wire::BufferedEvent;
use crate::telemetry::sink::TelemetrySink;
use crate::telemetry::TelemetryRecord;
use crate::Error;
pub const PIPE_LOG_TARGET: &str = "offline_protocol::telemetry::pipe";
pub const FINAL_FLUSH_BUDGET: Duration = Duration::from_secs(3);
const ADOPTION_RETRY: Duration = Duration::from_millis(500);
const ENVELOPE_OVERHEAD_BYTES: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TelemetryStats {
pub buffered: u64,
pub sent_events: u64,
pub accepted_events: u64,
pub dropped: u64,
pub session_id: String,
pub last_error: Option<String>,
pub last_flush_at_ms: Option<i64>,
}
pub(crate) type Clock = Arc<dyn Fn() -> i64 + Send + Sync>;
fn system_clock() -> Clock {
Arc::new(|| chrono::Utc::now().timestamp_millis())
}
struct Envelope {
app_version: String,
os: TelemetryOs,
os_major: u16,
}
pub(crate) struct PipeShared {
enabled: Arc<AtomicBool>,
debug: bool,
inline: bool,
pipeline: Mutex<Pipeline>,
store: Mutex<BatchStore>,
uploader: Mutex<Uploader>,
pending_attach: Mutex<Option<Backend>>,
adoption_blocked: AtomicBool,
device_id: Mutex<Option<String>>,
include_device_id: bool,
durable: AtomicBool,
envelope: Envelope,
max_batch_bytes: usize,
flush_interval: Duration,
clock: Clock,
signal: WakeSignal,
battery: AtomicU32,
sent_events: AtomicU64,
accepted_events: AtomicU64,
uploader_dropped: AtomicU64,
store_dropped: AtomicU64,
last_flush_at_ms: AtomicI64,
last_error: Mutex<Option<String>>,
server_disabled: AtomicBool,
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|e| e.into_inner())
}
impl PipeShared {
fn note_battery(&self, level: Option<u8>, is_charging: bool) {
let packed = 1 << 16 | u32::from(is_charging) << 8 | u32::from(level.unwrap_or(0));
let packed = if level.is_some() {
packed
} else {
packed & !0xff | 0x8000
};
self.battery.store(packed, Ordering::Relaxed);
}
fn battery(&self) -> (Option<u8>, bool) {
let packed = self.battery.load(Ordering::Relaxed);
if packed & (1 << 16) == 0 {
return (None, false);
}
let level = if packed & 0x8000 != 0 {
None
} else {
Some((packed & 0xff) as u8)
};
(level, packed & (1 << 8) != 0)
}
fn set_last_error(&self, error: Option<String>) {
*lock(&self.last_error) = error;
}
fn cycle(&self, reasons: u8, deadline: Option<Instant>) -> Option<i64> {
let now = (self.clock)();
self.adopt_pending_backend();
let drained = lock(&self.pipeline).drain();
if self.server_disabled.load(Ordering::Relaxed) {
self.uploader_dropped
.fetch_add(drained.len() as u64, Ordering::Relaxed);
} else if !drained.is_empty() {
let device_id = if self.include_device_id {
lock(&self.device_id).clone()
} else {
None
};
let batches = build_batches(
drained,
&self.envelope,
device_id,
self.max_batch_bytes,
now,
self.debug,
);
let mut store = lock(&self.store);
for batch in batches {
store.push(batch);
}
store.flush_index();
self.durable.store(store.is_durable(), Ordering::Relaxed);
self.store_dropped
.store(store.dropped_events(), Ordering::Relaxed);
}
let is_final = reasons & wake::FINAL != 0;
let (level, charging) = self.battery();
if !is_final && battery_defers(level, charging) {
tracing::debug!(
target: PIPE_LOG_TARGET,
battery_level = ?level,
"telemetry flush deferred on battery"
);
return lock(&self.uploader).next_retry_at_ms();
}
let mut store = lock(&self.store);
let mut uploader = lock(&self.uploader);
let report = uploader.drain(&mut store, now, MAX_BATCHES_PER_WAKE, deadline);
self.durable.store(store.is_durable(), Ordering::Relaxed);
self.store_dropped
.store(store.dropped_events(), Ordering::Relaxed);
drop(store);
self.sent_events
.fetch_add(report.sent_events, Ordering::Relaxed);
self.accepted_events
.fetch_add(report.accepted_events, Ordering::Relaxed);
self.uploader_dropped
.fetch_add(report.dropped_events, Ordering::Relaxed);
if report.batches_sent > 0 {
self.last_flush_at_ms.store(now, Ordering::Relaxed);
}
if report.disabled && !self.server_disabled.swap(true, Ordering::Relaxed) {
tracing::warn!(
target: PIPE_LOG_TARGET,
dropped_events = report.dropped_events,
"hosted telemetry is switched off for this application in the developer \
portal; the queue was dropped and nothing more is collected until \
enable_telemetry"
);
}
self.set_last_error(uploader.last_error().map(str::to_string));
uploader.next_retry_at_ms()
}
fn adopt_pending_backend(&self) {
let Some(backend) = lock(&self.pending_attach).take() else {
return;
};
let mut store = lock(&self.store);
let refused = match store.attach(backend) {
Attach::Adopted => None,
Attach::Busy(backend) => Some((backend, true)),
Attach::Failed(backend) => Some((backend, false)),
};
if refused.is_none() && self.server_disabled.load(Ordering::Relaxed) {
let mut dropped = 0u64;
while let Some(batch) = store.pop_front() {
dropped += u64::from(batch.event_count);
}
self.uploader_dropped.fetch_add(dropped, Ordering::Relaxed);
}
store.flush_index();
self.durable.store(store.is_durable(), Ordering::Relaxed);
self.store_dropped
.store(store.dropped_events(), Ordering::Relaxed);
drop(store);
self.adoption_blocked.store(
refused.as_ref().is_some_and(|(_, busy)| *busy),
Ordering::Relaxed,
);
if let Some((backend, _)) = refused {
lock(&self.pending_attach).get_or_insert(backend);
}
}
fn release_queue(&self) {
lock(&self.store).release();
self.durable.store(false, Ordering::Relaxed);
}
}
struct ReleaseQueue<'a>(&'a PipeShared);
impl Drop for ReleaseQueue<'_> {
fn drop(&mut self) {
self.0.release_queue();
}
}
fn run_worker(shared: Arc<PipeShared>) {
let _release = ReleaseQueue(&shared);
let mut next_interval = Instant::now() + shared.flush_interval;
loop {
let wake_at = if shared.adoption_blocked.load(Ordering::Relaxed) {
next_interval.min(Instant::now() + ADOPTION_RETRY)
} else {
next_interval
};
let (reasons, generation) = shared.signal.wait(wake_at);
if reasons == wake::INTERVAL && Instant::now() < next_interval {
shared.adopt_pending_backend();
continue;
}
let stopping = reasons & wake::STOP != 0;
let deadline = stopping.then(|| Instant::now() + FINAL_FLUSH_BUDGET);
let retry_at = shared.cycle(reasons, deadline);
shared.signal.complete(generation);
if stopping {
break;
}
let now_ms = (shared.clock)();
let interval = Instant::now() + shared.flush_interval;
next_interval = match retry_at {
Some(at) if at > now_ms => {
let wait = Duration::from_millis((at - now_ms).min(i64::MAX / 2) as u64);
interval.min(Instant::now() + wait)
}
_ => interval,
};
}
}
fn build_batches(
events: VecDeque<BufferedEvent>,
envelope: &Envelope,
device_id: Option<String>,
max_batch_bytes: usize,
now_ms: i64,
debug: bool,
) -> Vec<PendingBatch> {
let mut batches = Vec::new();
let mut chunk: Vec<RawEvent> = Vec::new();
let mut chunk_session = Uuid::nil();
let mut chunk_bytes = ENVELOPE_OVERHEAD_BYTES;
let finish = |chunk: &mut Vec<RawEvent>, session: Uuid, batches: &mut Vec<PendingBatch>| {
if chunk.is_empty() {
return;
}
let batch = Batch {
batch_id: BatchId(Uuid::new_v4()),
session_id: SessionId(session),
device_id: device_id.clone().map(DeviceId),
wire_version: CURRENT_WIRE_VERSION,
app_version: envelope.app_version.clone(),
os: envelope.os.wire(),
os_major: envelope.os_major,
sdk_version: SDK_VERSION.to_string(),
events: std::mem::take(chunk),
};
if debug {
log_batch(&batch);
}
let Ok(body) = serde_json::to_string(&batch) else {
return;
};
batches.push(PendingBatch {
seq: None,
batch_id: batch.batch_id.0,
created_ms: now_ms,
event_count: batch.events.len().min(u32::MAX as usize) as u32,
body,
});
};
for item in events {
let raw = item.event.to_raw();
let bytes = serde_json::to_string(&raw).map_or(0, |s| s.len());
if !chunk.is_empty()
&& (item.session != chunk_session || chunk_bytes + bytes > max_batch_bytes)
{
finish(&mut chunk, chunk_session, &mut batches);
chunk_bytes = ENVELOPE_OVERHEAD_BYTES;
}
if chunk.is_empty() {
chunk_session = item.session;
}
chunk.push(raw);
chunk_bytes += bytes;
}
finish(&mut chunk, chunk_session, &mut batches);
batches
}
pub(crate) fn log_batch(batch: &Batch) {
for event in &batch.events {
tracing::debug!(
target: PIPE_LOG_TARGET,
batch_id = %batch.batch_id.0,
session_id = %batch.session_id.0,
event_type = %event.event_type,
ts_ms = event.ts_ms,
data = %event.data,
"telemetry wire event"
);
}
}
pub(crate) struct PipeSink {
shared: Arc<PipeShared>,
}
impl TelemetrySink for PipeSink {
fn emit(&self, record: &TelemetryRecord) {
if !self.shared.enabled.load(Ordering::Relaxed) {
return;
}
if let TelemetryRecord::Device(snapshot) = record {
self.shared
.note_battery(snapshot.battery_level, snapshot.is_charging);
}
let now = (self.shared.clock)();
let trigger = lock(&self.shared.pipeline).handle(record, now);
if trigger {
self.shared.signal.wake(wake::SIZE);
}
}
fn try_emit_protocol_event(&self, event: &Event) -> bool {
if !self.shared.enabled.load(Ordering::Relaxed) {
return true;
}
if !classify::PROTOCOL_ALLOWLIST.contains(&event.telemetry_name()) {
return true;
}
let now = (self.shared.clock)();
let trigger = lock(&self.shared.pipeline).handle_protocol_event(event, now);
if trigger {
self.shared.signal.wake(wake::SIZE);
}
true
}
}
pub struct TelemetryPipe {
shared: Arc<PipeShared>,
scheduler: Mutex<Option<Scheduler>>,
stopped: AtomicBool,
}
impl std::fmt::Debug for TelemetryPipe {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TelemetryPipe")
.field("enabled", &self.shared.enabled.load(Ordering::Relaxed))
.field("stopped", &self.stopped.load(Ordering::Relaxed))
.finish()
}
}
pub(crate) struct PipeParts {
pub(crate) host: TelemetryHost,
pub(crate) backend: Backend,
pub(crate) device_id: Option<String>,
pub(crate) client: Box<dyn HttpClient>,
pub(crate) clock: Clock,
pub(crate) jitter: Box<dyn FnMut() -> u64 + Send>,
pub(crate) spawn_thread: bool,
}
impl PipeParts {
pub(crate) fn production(
host: TelemetryHost,
backend: Backend,
device_id: Option<String>,
) -> Self {
#[cfg(feature = "test-utils")]
let client = testing::override_client().unwrap_or_else(|| Box::new(UreqClient::new()));
#[cfg(not(feature = "test-utils"))]
let client: Box<dyn HttpClient> = Box::new(UreqClient::new());
Self {
host,
backend,
device_id,
client,
clock: system_clock(),
jitter: Box::new(|| {
use rand_core::RngCore;
u64::from(rand_core::OsRng.next_u32())
}),
spawn_thread: true,
}
}
}
impl TelemetryPipe {
pub(crate) fn start(config: &TelemetryConfig, parts: PipeParts) -> Result<Arc<Self>, Error> {
let now = (parts.clock)();
let pipeline = Pipeline::new(
config.max_buffered_records(),
config.max_batch_bytes(),
config.routing_diagnostic(),
now,
);
let uploader = Uploader::new(
parts.client,
config.api_key().unwrap_or_default(),
config.app_id().unwrap_or_default(),
user_agent(parts.host.os.user_agent_token(), parts.host.os_major),
parts.jitter,
);
let mut store = BatchStore::new(
Backend::Memory,
config.app_id().unwrap_or_default().to_string(),
);
let (pending_attach, adoption_blocked) = match store.attach(parts.backend) {
Attach::Adopted => (None, false),
Attach::Busy(backend) => (Some(backend), true),
Attach::Failed(backend) => (Some(backend), false),
};
let durable = store.is_durable();
let shared = Arc::new(PipeShared {
enabled: Arc::new(AtomicBool::new(true)),
debug: config.debug(),
inline: !parts.spawn_thread,
pipeline: Mutex::new(pipeline),
store: Mutex::new(store),
uploader: Mutex::new(uploader),
pending_attach: Mutex::new(pending_attach),
adoption_blocked: AtomicBool::new(adoption_blocked),
device_id: Mutex::new(parts.device_id),
include_device_id: config.include_device_id(),
durable: AtomicBool::new(durable),
envelope: Envelope {
app_version: config.app_version().to_string(),
os: parts.host.os,
os_major: parts.host.os_major,
},
max_batch_bytes: config.max_batch_bytes(),
flush_interval: config.flush_interval(),
clock: parts.clock,
signal: WakeSignal::default(),
battery: AtomicU32::new(0),
sent_events: AtomicU64::new(0),
accepted_events: AtomicU64::new(0),
uploader_dropped: AtomicU64::new(0),
store_dropped: AtomicU64::new(0),
last_flush_at_ms: AtomicI64::new(-1),
last_error: Mutex::new(None),
server_disabled: AtomicBool::new(false),
});
lock(&shared.pipeline).seed_app_state(parts.host.app_state);
let scheduler = if parts.spawn_thread {
let worker = shared.clone();
Some(
Scheduler::spawn(move || run_worker(worker))
.map_err(|e| Error::Other(format!("telemetry thread could not start: {e}")))?,
)
} else {
None
};
Ok(Arc::new(Self {
shared,
scheduler: Mutex::new(scheduler),
stopped: AtomicBool::new(false),
}))
}
pub(crate) fn sink(&self) -> Arc<dyn TelemetrySink> {
Arc::new(PipeSink {
shared: self.shared.clone(),
})
}
pub(crate) fn enabled_flag(&self) -> Arc<AtomicBool> {
self.shared.enabled.clone()
}
pub fn set_enabled(&self, enabled: bool) {
let was = self.shared.enabled.swap(enabled, Ordering::Relaxed);
if enabled && !was {
let now = (self.shared.clock)();
lock(&self.shared.pipeline).resume_collection(now);
}
}
pub fn is_enabled(&self) -> bool {
self.shared.enabled.load(Ordering::Relaxed)
}
pub fn flush(&self) {
if self.shared.inline {
self.shared.cycle(wake::EXPLICIT, None);
return;
}
self.shared.signal.wake(wake::EXPLICIT);
}
pub fn flush_blocking(&self, budget: Duration) -> bool {
if self.shared.inline {
self.shared
.cycle(wake::EXPLICIT, Some(Instant::now() + budget));
return true;
}
if self.stopped.load(Ordering::Relaxed) {
return false;
}
let generation = self.shared.signal.request_flush(wake::EXPLICIT);
self.shared
.signal
.wait_completed(generation, Instant::now() + budget)
}
pub fn stats(&self) -> TelemetryStats {
let (buffered, ring_dropped, session_id) = {
let pipeline = lock(&self.shared.pipeline);
(
pipeline.buffered() as u64,
pipeline.dropped(),
pipeline.session_id().to_string(),
)
};
let last_flush = self.shared.last_flush_at_ms.load(Ordering::Relaxed);
TelemetryStats {
buffered,
sent_events: self.shared.sent_events.load(Ordering::Relaxed),
accepted_events: self.shared.accepted_events.load(Ordering::Relaxed),
dropped: ring_dropped
+ self.shared.store_dropped.load(Ordering::Relaxed)
+ self.shared.uploader_dropped.load(Ordering::Relaxed),
session_id,
last_error: lock(&self.shared.last_error).clone(),
last_flush_at_ms: (last_flush >= 0).then_some(last_flush),
}
}
pub fn end_session(&self) {
if !self.is_enabled() {
return;
}
let now = (self.shared.clock)();
lock(&self.shared.pipeline).emit_session_summary(now);
self.flush_blocking(FINAL_FLUSH_BUDGET);
let now = (self.shared.clock)();
lock(&self.shared.pipeline).rotate_session(now);
}
pub fn notify_app_state(&self, state: AppState) {
if !self.is_enabled() {
lock(&self.shared.pipeline).seed_app_state(state);
return;
}
let now = (self.shared.clock)();
let action = lock(&self.shared.pipeline).app_state(state, now);
if action == LifecycleAction::BackgroundFlush {
if self.shared.inline {
self.shared.cycle(wake::BACKGROUND, None);
} else {
self.shared.signal.wake(wake::BACKGROUND);
}
}
}
pub(crate) fn notify_transport(&self, transport: TransportType, status: TransportStatus) {
if matches!(transport, TransportType::Internet | TransportType::Nostr)
&& status == TransportStatus::Available
{
if self.shared.inline {
self.shared.cycle(wake::TRANSPORT, None);
} else {
self.shared.signal.wake(wake::TRANSPORT);
}
}
}
pub(crate) fn attach_storage(
&self,
storage: Arc<dyn ProtocolStateStorage>,
cipher: StateRecordCipher,
) {
*lock(&self.shared.pending_attach) = Some(Backend::Sealed { storage, cipher });
if self.shared.inline {
self.shared.cycle(wake::ATTACH, None);
} else {
self.shared.signal.wake(wake::ATTACH);
}
}
pub(crate) fn set_device_id(&self, device_id: Option<String>) {
*lock(&self.shared.device_id) = device_id;
}
pub fn stop(&self, budget: Duration) -> bool {
if self.stopped.swap(true, Ordering::SeqCst) {
return true;
}
self.shared.signal.request_stop();
let mut scheduler = lock(&self.scheduler);
match scheduler.as_mut() {
Some(scheduler) => scheduler.join_until(Instant::now() + budget),
None => {
self.shared.cycle(wake::STOP, Some(Instant::now() + budget));
self.shared.release_queue();
true
}
}
}
pub fn is_stopped(&self) -> bool {
self.stopped.load(Ordering::Relaxed)
}
pub fn is_durable(&self) -> bool {
self.shared.durable.load(Ordering::Relaxed)
}
#[cfg(test)]
pub(crate) fn shared(&self) -> &Arc<PipeShared> {
&self.shared
}
#[cfg(test)]
pub(crate) fn run_cycle(&self, reasons: u8) {
self.shared.cycle(reasons, None);
}
}
impl Drop for TelemetryPipe {
fn drop(&mut self) {
let budget = if self.shared.inline {
FINAL_FLUSH_BUDGET
} else {
Duration::ZERO
};
self.stop(budget);
}
}