Skip to main content

micromegas_telemetry_sink/
http_event_sink.rs

1use micromegas_telemetry::stream_info::StreamInfo;
2use micromegas_telemetry::wire_format::encode_cbor;
3use micromegas_tracing::{
4    event::{EventSink, TracingBlock},
5    flush_monitor::FlushMonitor,
6    images::{ImageBlock, ImageStream},
7    logs::{LogBlock, LogMetadata, LogStream},
8    metrics::{MetricsBlock, MetricsStream},
9    prelude::*,
10    property_set::Property,
11    spans::{ThreadBlock, ThreadStream},
12};
13use std::{
14    cmp::max,
15    collections::{HashMap, VecDeque},
16    fmt,
17    sync::{
18        Arc, Condvar, Mutex,
19        atomic::{AtomicBool, AtomicIsize, AtomicU64, Ordering},
20    },
21    time::Duration,
22};
23use tokio::sync::{OwnedSemaphorePermit, Semaphore};
24use tokio_retry2::{RetryError, strategy::ExponentialBackoff};
25
26use crate::request_decorator::RequestDecorator;
27use crate::stream_block::StreamBlock;
28use crate::stream_info::make_stream_info;
29
30/// A retry strategy: an exponential backoff, capped to a fixed number of attempts.
31type RetryStrategy = core::iter::Take<ExponentialBackoff>;
32
33/// Error type for ingestion client operations.
34/// Explicitly categorizes errors to control retry behavior.
35///
36/// Logging strategy: Transient errors (5xx, network) use `debug!` to avoid
37/// polluting instrumented applications' logs with telemetry infrastructure noise.
38/// Permanent errors (4xx) use `warn!` since they indicate a bug in the client.
39#[derive(Clone, Debug)]
40enum IngestionClientError {
41    /// Transient error - should retry (network issues, 5xx responses)
42    Transient(String),
43    /// Permanent error - should NOT retry (4xx responses, malformed data)
44    Permanent(String),
45}
46
47impl std::fmt::Display for IngestionClientError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            IngestionClientError::Transient(msg) => write!(f, "transient error: {msg}"),
51            IngestionClientError::Permanent(msg) => write!(f, "permanent error: {msg}"),
52        }
53    }
54}
55
56impl std::error::Error for IngestionClientError {}
57
58impl IngestionClientError {
59    fn into_retry(self) -> RetryError<Self> {
60        match self {
61            IngestionClientError::Transient(_) => RetryError::transient(self),
62            IngestionClientError::Permanent(_) => RetryError::permanent(self),
63        }
64    }
65}
66
67/// Upload priority classes, drained strictly in this order (Metadata first,
68/// Traces last) and used to decide which items are shed first under
69/// backpressure. Mirrors the Unreal telemetry sink's `EUploadPriority`.
70///
71/// Also indexes [`HttpSinkConfig::retry_by_priority`]: index 0 is Metadata,
72/// 1 is Logs, 2 is Metrics, 3 is Traces.
73#[derive(Copy, Clone, Debug, PartialEq, Eq)]
74pub enum UploadPriority {
75    /// `insert_process` / `insert_stream` — never dropped.
76    Metadata = 0,
77    /// Log blocks.
78    Logs = 1,
79    /// Metrics blocks.
80    Metrics = 2,
81    /// Thread and image blocks.
82    Traces = 3,
83}
84
85const NUM_PRIORITIES: usize = 4;
86
87/// How long `on_shutdown`/`Drop` wait for the worker's final drain before
88/// giving up on a graceful shutdown.
89const SHUTDOWN_WAIT_TIMEOUT: Duration = Duration::from_secs(5);
90
91/// Reports drops accumulated by [`SharedQueue::note_dropped`], emitting one
92/// metric and one log line per priority class that dropped anything since the
93/// last call.
94///
95/// Must only be called from the worker thread's top-level loop, never from
96/// inside an `EventSink` callback: dispatch invokes `on_process_*_block` while
97/// holding the global log/metrics stream mutex, so emitting telemetry from
98/// the enqueue path (`try_push` runs on the caller's thread) would re-enter
99/// that same non-reentrant mutex and deadlock the process.
100fn report_dropped_items(queue: &SharedQueue) {
101    for priority in [
102        UploadPriority::Metadata,
103        UploadPriority::Logs,
104        UploadPriority::Metrics,
105        UploadPriority::Traces,
106    ] {
107        let count = queue.dropped[priority as usize].swap(0, Ordering::Relaxed);
108        if count == 0 {
109            continue;
110        }
111        warn!("dropped {count} telemetry items, priority={priority:?}");
112        match priority {
113            UploadPriority::Metadata => {
114                imetric!("telemetry_dropped_metadata", "count", count);
115            }
116            UploadPriority::Logs => {
117                imetric!("telemetry_dropped_logs", "count", count);
118            }
119            UploadPriority::Metrics => {
120                imetric!("telemetry_dropped_metrics", "count", count);
121            }
122            UploadPriority::Traces => {
123                imetric!("telemetry_dropped_traces", "count", count);
124            }
125        }
126    }
127}
128
129enum Payload {
130    Process(Arc<ProcessInfo>),
131    Stream(Arc<StreamInfo>),
132    Block {
133        block: Arc<dyn StreamBlock + Send + Sync>,
134        kind: &'static str,
135    },
136}
137
138struct QueuedItem {
139    priority: UploadPriority,
140    /// Raw uncompressed queued size (`0` for metadata, `len_bytes()` for blocks).
141    bytes: usize,
142    payload: Payload,
143}
144
145/// A priority-ordered, byte-budgeted queue shared between the application
146/// threads (enqueue side) and the dedicated upload worker thread (drain side).
147///
148/// The `notify` condvar is paired with the `queues` mutex: every state change
149/// that the worker might be waiting on (a new item, a freed in-flight slot, or
150/// shutdown) is signaled while holding (or right after briefly re-acquiring)
151/// that same mutex. This is what prevents a lost wakeup: the worker always
152/// re-checks its wake predicate under the lock immediately before waiting, so
153/// a signal can never land in the gap between the check and the wait.
154struct SharedQueue {
155    queues: Mutex<[VecDeque<QueuedItem>; NUM_PRIORITIES]>,
156    queue_bytes: AtomicIsize,
157    queue_count: AtomicIsize,
158    /// Number of items dispatched to a send task (via `spawn_item`) whose
159    /// task has not completed yet. Unlike `queue_count`, which drops as soon
160    /// as an item is popped off the deque, this stays elevated for the
161    /// entire lifetime of the HTTP send (including retries), so it reflects
162    /// genuine outstanding network activity.
163    in_flight_count: AtomicIsize,
164    notify: Condvar,
165    shutdown: AtomicBool,
166    soft_bytes: usize,
167    hard_bytes: usize,
168    /// Per-priority count of items dropped since the worker last reported.
169    /// Incremented on the enqueue path and drained by the worker thread's
170    /// `report_dropped_items` — the enqueue path itself must never emit
171    /// telemetry (see `report_dropped_items`).
172    dropped: [AtomicU64; NUM_PRIORITIES],
173}
174
175impl SharedQueue {
176    fn new(soft_bytes: usize, hard_bytes: usize) -> Self {
177        Self {
178            queues: Mutex::new([
179                VecDeque::new(),
180                VecDeque::new(),
181                VecDeque::new(),
182                VecDeque::new(),
183            ]),
184            queue_bytes: AtomicIsize::new(0),
185            queue_count: AtomicIsize::new(0),
186            in_flight_count: AtomicIsize::new(0),
187            notify: Condvar::new(),
188            shutdown: AtomicBool::new(false),
189            soft_bytes,
190            hard_bytes: hard_bytes.max(soft_bytes),
191            dropped: [
192                AtomicU64::new(0),
193                AtomicU64::new(0),
194                AtomicU64::new(0),
195                AtomicU64::new(0),
196            ],
197        }
198    }
199
200    /// Records a dropped item for later reporting by the worker thread. Kept
201    /// to a plain atomic increment on purpose: this runs on the enqueue path,
202    /// inside dispatch callbacks that hold the global stream mutexes.
203    fn note_dropped(&self, priority: UploadPriority) {
204        self.dropped[priority as usize].fetch_add(1, Ordering::Relaxed);
205    }
206
207    /// Enqueues `item`, applying the graded byte-budget drop policy. Returns
208    /// `false` if the item was dropped instead of queued.
209    fn try_push(&self, item: QueuedItem) -> bool {
210        let current = self.queue_bytes.load(Ordering::Relaxed).max(0) as usize;
211        let should_drop = match item.priority {
212            UploadPriority::Metadata => false,
213            UploadPriority::Traces => current >= self.soft_bytes,
214            UploadPriority::Logs | UploadPriority::Metrics => current >= self.hard_bytes,
215        };
216        if should_drop {
217            self.note_dropped(item.priority);
218            return false;
219        }
220        // The shutdown check and the push must happen under the same lock
221        // that guards `pop_highest`'s drain loop: otherwise an enqueuer could
222        // observe `shutdown == false`, get preempted, and push after the
223        // worker's final drain has already run to completion and signaled
224        // `shutdown_complete` — silently leaking the item into an abandoned
225        // queue. Checking here, inside the critical section, makes this
226        // mutually exclusive with the drain's emptiness check.
227        let mut guard = self.queues.lock().unwrap();
228        if self.shutdown.load(Ordering::SeqCst) {
229            drop(guard);
230            // The worker has committed to (or already finished) its final
231            // drain and will never pop this item, so queuing it would leak
232            // it forever. Reject and count the drop instead. No log/metric
233            // is emitted here: this path can run inside a dispatch callback
234            // that holds the global stream mutex (see `report_dropped_items`),
235            // and post-shutdown the dispatch sink is gone anyway.
236            self.note_dropped(item.priority);
237            return false;
238        }
239        self.queue_bytes
240            .fetch_add(item.bytes as isize, Ordering::Relaxed);
241        self.queue_count.fetch_add(1, Ordering::Relaxed);
242        guard[item.priority as usize].push_back(item);
243        self.notify.notify_all();
244        true
245    }
246
247    /// Pops the highest-priority queued item (Metadata, then Logs, Metrics,
248    /// Traces), if any.
249    fn pop_highest(&self) -> Option<QueuedItem> {
250        let mut guard = self.queues.lock().unwrap();
251        for deque in guard.iter_mut() {
252            if let Some(item) = deque.pop_front() {
253                drop(guard);
254                self.queue_bytes
255                    .fetch_sub(item.bytes as isize, Ordering::Relaxed);
256                self.queue_count.fetch_sub(1, Ordering::Relaxed);
257                return Some(item);
258            }
259        }
260        None
261    }
262
263    /// Wakes the worker. Used both for actual state changes (shutdown) and as
264    /// a pure synchronization fence (a completed send freeing an in-flight
265    /// slot) — see the struct-level doc comment for why acquiring `queues`
266    /// here is required for correctness, not just a mutation guard.
267    fn wake(&self) {
268        let _guard = self.queues.lock().unwrap();
269        self.notify.notify_all();
270    }
271}
272
273/// Releases a semaphore permit and wakes the worker when a spawned send task
274/// finishes, whether it returns normally or panics — an ordinary statement at
275/// the end of the task body would be skipped on unwind, stranding the permit
276/// until the next timeout tick.
277struct WakeOnDrop {
278    queue: Arc<SharedQueue>,
279    _permit: Option<OwnedSemaphorePermit>,
280}
281
282impl Drop for WakeOnDrop {
283    fn drop(&mut self) {
284        // Release the permit BEFORE waking. `wake()` does `notify_all` under
285        // the queues lock and returns before drop-glue would drop `_permit`,
286        // so a worker that wasn't waiting at notify time could grab the lock
287        // in the gap between the notify and the permit release, read
288        // `available_permits() == 0` with the queue non-empty, and re-sleep
289        // until the next flush tick. Freeing the slot first makes it visible
290        // to any worker that evaluates its wait predicate after the notify.
291        drop(self._permit.take());
292        self.queue.wake();
293    }
294}
295
296/// Configuration for [`HttpEventSink`]'s transport: how much to buffer, how
297/// aggressively to shed load under backpressure, how much concurrency to
298/// allow, and how hard to retry each priority class.
299///
300/// The byte caps sit far above what a healthy co-located ingestion service
301/// (e.g. a monolith) will ever accumulate, so a normal run drops nothing;
302/// they only bite during a real outage.
303pub struct HttpSinkConfig {
304    /// Soft cap, in bytes: once the queue holds at least this many bytes,
305    /// new `Traces` items (thread and image blocks) are dropped. Default 128 MiB.
306    pub max_queue_bytes: usize,
307    /// Hard cap, in bytes: once the queue holds at least this many bytes,
308    /// new `Logs`/`Metrics` items are dropped too. Clamped to be at least
309    /// `max_queue_bytes`. `Metadata` (process/stream) is never dropped.
310    /// Default 256 MiB.
311    pub hard_queue_bytes: usize,
312    /// Maximum number of `insert_*` HTTP requests in flight at once. Set to
313    /// `1` to restore strictly serial sends. Default 3.
314    pub max_in_flight_requests: usize,
315    /// Per-request timeout (covers connect + send + receive for one attempt).
316    ///
317    /// This is a deliberate addition beyond Unreal parity: Unreal's
318    /// per-priority retry window is a total retry *budget*, not a socket
319    /// timeout (it never sets one either), so a single attempt against an
320    /// ingestion service that accepts the TCP connection but never responds
321    /// can hang indefinitely. Without a bound here, that hang is fatal at
322    /// shutdown: `Drop for HttpEventSink` joins the worker thread, so a
323    /// short-lived process would freeze on exit whenever ingestion is
324    /// unresponsive (as opposed to merely offline, which fails fast with a
325    /// connection error). Default 10 seconds.
326    pub request_timeout: Duration,
327    /// Retry strategy per [`UploadPriority`] (indexed by
328    /// `UploadPriority as usize`).
329    pub retry_by_priority: [RetryStrategy; NUM_PRIORITIES],
330}
331
332impl HttpSinkConfig {
333    /// Default soft byte cap: 128 MiB.
334    pub const DEFAULT_MAX_QUEUE_BYTES: usize = 128 * 1024 * 1024;
335    /// Default hard byte cap: 256 MiB.
336    pub const DEFAULT_HARD_QUEUE_BYTES: usize = 256 * 1024 * 1024;
337    /// Default in-flight request cap (Unreal parity).
338    pub const DEFAULT_MAX_IN_FLIGHT_REQUESTS: usize = 3;
339    /// Default per-request timeout.
340    pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
341
342    /// The default per-priority retry table: Metadata gets the most retries,
343    /// Traces the fewest, mirroring the Unreal sink's
344    /// `RetryCountByPriority` (`{10,5,2,1}`).
345    pub fn default_retry_by_priority() -> [RetryStrategy; NUM_PRIORITIES] {
346        [
347            ExponentialBackoff::from_millis(10).take(10), // Metadata
348            ExponentialBackoff::from_millis(10).take(5),  // Logs
349            ExponentialBackoff::from_millis(10).take(2),  // Metrics
350            ExponentialBackoff::from_millis(10).take(1),  // Traces
351        ]
352    }
353}
354
355impl Default for HttpSinkConfig {
356    fn default() -> Self {
357        Self {
358            max_queue_bytes: Self::DEFAULT_MAX_QUEUE_BYTES,
359            hard_queue_bytes: Self::DEFAULT_HARD_QUEUE_BYTES,
360            max_in_flight_requests: Self::DEFAULT_MAX_IN_FLIGHT_REQUESTS,
361            request_timeout: Self::DEFAULT_REQUEST_TIMEOUT,
362            retry_by_priority: Self::default_retry_by_priority(),
363        }
364    }
365}
366
367/// State shared by every send spawned by the worker loop: the HTTP client,
368/// the current process info (set synchronously as soon as the `Startup` item
369/// is dequeued, independently of whether the send itself succeeds), and the
370/// per-priority retry table.
371struct WorkerShared {
372    client: reqwest::Client,
373    addr: String,
374    process_info: Mutex<Option<Arc<ProcessInfo>>>,
375    decorator: Arc<dyn RequestDecorator>,
376    retry_by_priority: [RetryStrategy; NUM_PRIORITIES],
377}
378
379/// The subset of [`HttpSinkConfig`] needed by the worker thread, grouped to
380/// keep `thread_proc`/`run`'s parameter lists manageable.
381struct WorkerConfig {
382    max_in_flight_requests: usize,
383    request_timeout: Duration,
384    retry_by_priority: [RetryStrategy; NUM_PRIORITIES],
385}
386
387pub struct HttpEventSink {
388    thread: Option<std::thread::JoinHandle<()>>,
389    queue: Arc<SharedQueue>,
390    pending_stream_meta: Mutex<HashMap<uuid::Uuid, Arc<StreamInfo>>>,
391    shutdown_complete: Arc<(Mutex<bool>, std::sync::Condvar)>,
392}
393
394impl Drop for HttpEventSink {
395    fn drop(&mut self) {
396        // Bounded like `on_shutdown`: if the worker is stuck (e.g. mid-retry
397        // against an ingestion service that accepts connections but never
398        // responds), we must not block process exit on `handle.join()`
399        // indefinitely. Abandon the thread instead: the OS reclaims it when
400        // the process exits, or if it eventually finishes on its own.
401        if !self.signal_shutdown_and_wait() {
402            eprintln!(
403                "Warning: telemetry thread did not shut down within the timeout, abandoning it"
404            );
405            return;
406        }
407
408        if let Some(handle) = self.thread.take()
409            && let Err(e) = handle.join()
410        {
411            // Don't panic on join failure, just log it
412            eprintln!("Warning: telemetry thread join failed: {:?}", e);
413        }
414    }
415}
416
417impl HttpEventSink {
418    /// Creates a new `HttpEventSink`.
419    ///
420    /// This function spawns a new thread that handles sending telemetry data
421    /// to the specified HTTP server. Sends run on a dedicated tokio runtime,
422    /// with up to `config.max_in_flight_requests` requests in flight
423    /// concurrently, drained strictly in priority order (Metadata, Logs,
424    /// Metrics, Traces).
425    ///
426    /// # Arguments
427    ///
428    /// * `addr_server` - The address of the HTTP server to send data to.
429    /// * `config` - Transport configuration: byte budgets, concurrency, retries.
430    /// * `make_decorator` - A closure that returns a `RequestDecorator` for modifying HTTP requests.
431    pub fn new(
432        addr_server: &str,
433        config: HttpSinkConfig,
434        make_decorator: Box<dyn FnOnce() -> Arc<dyn RequestDecorator> + Send>,
435    ) -> Self {
436        let addr = addr_server.to_owned();
437        let queue = Arc::new(SharedQueue::new(
438            config.max_queue_bytes,
439            config.hard_queue_bytes,
440        ));
441        let thread_queue = queue.clone();
442        let worker_config = WorkerConfig {
443            max_in_flight_requests: config.max_in_flight_requests,
444            request_timeout: config.request_timeout,
445            retry_by_priority: config.retry_by_priority,
446        };
447        let shutdown_complete = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
448        let thread_shutdown_complete = shutdown_complete.clone();
449        Self {
450            thread: Some(std::thread::spawn(move || {
451                Self::thread_proc(
452                    addr,
453                    thread_queue,
454                    worker_config,
455                    make_decorator,
456                    thread_shutdown_complete,
457                );
458            })),
459            queue,
460            pending_stream_meta: Mutex::new(HashMap::new()),
461            shutdown_complete,
462        }
463    }
464
465    /// Signals shutdown and waits (bounded) for the worker's final drain to
466    /// complete. Returns `false` if the wait timed out — the worker may
467    /// still be stuck mid-send, and the thread should not be joined
468    /// unconditionally in that case.
469    fn signal_shutdown_and_wait(&self) -> bool {
470        self.queue.shutdown.store(true, Ordering::SeqCst);
471        self.queue.wake();
472
473        let (lock, cvar) = &*self.shutdown_complete;
474        let completed = lock.lock().unwrap();
475        let (completed, result) = cvar
476            .wait_timeout_while(completed, SHUTDOWN_WAIT_TIMEOUT, |&mut c| !c)
477            .unwrap();
478        drop(completed);
479        !result.timed_out()
480    }
481
482    fn flush_pending_stream_meta(&self, stream_id: uuid::Uuid) {
483        if let Some(stream_info) = self.pending_stream_meta.lock().unwrap().remove(&stream_id) {
484            self.queue.try_push(QueuedItem {
485                priority: UploadPriority::Metadata,
486                bytes: 0,
487                payload: Payload::Stream(stream_info),
488            });
489        }
490    }
491
492    #[span_fn]
493    async fn push_process(
494        client: &reqwest::Client,
495        root_path: &str,
496        process_info: Arc<ProcessInfo>,
497        retry_strategy: RetryStrategy,
498        decorator: &dyn RequestDecorator,
499    ) -> Result<(), IngestionClientError> {
500        debug!("sending process {process_info:?}");
501        let url = format!("{root_path}/ingestion/insert_process");
502        let body: bytes::Bytes = encode_cbor(&*process_info)
503            .map_err(|e| IngestionClientError::Permanent(format!("encoding process: {e}")))?
504            .into();
505        tokio_retry2::Retry::spawn(retry_strategy, || async {
506            let mut request = client.post(&url).body(body.clone()).build().map_err(|e| {
507                IngestionClientError::Permanent(format!("building request: {e}")).into_retry()
508            })?;
509
510            if let Err(e) = decorator.decorate(&mut request).await {
511                debug!("request decorator: {e:?}");
512                return Err(
513                    IngestionClientError::Transient(format!("decorating request: {e}"))
514                        .into_retry(),
515                );
516            }
517
518            let response = client.execute(request).await.map_err(|e| {
519                IngestionClientError::Transient(format!("network error: {e}")).into_retry()
520            })?;
521
522            let status = response.status();
523            match status.as_u16() {
524                200..=299 => Ok(()),
525                400..=499 => {
526                    let body = response.text().await.unwrap_or_default();
527                    warn!("insert_process client error ({status}): {body}");
528                    Err(IngestionClientError::Permanent(body).into_retry())
529                }
530                500..=599 => {
531                    let body = response.text().await.unwrap_or_default();
532                    debug!("insert_process server error ({status}): {body}");
533                    Err(IngestionClientError::Transient(format!("{status}: {body}")).into_retry())
534                }
535                _ => {
536                    let body = response.text().await.unwrap_or_default();
537                    warn!("insert_process unexpected status ({status}): {body}");
538                    Err(IngestionClientError::Permanent(format!("{status}: {body}")).into_retry())
539                }
540            }
541        })
542        .await
543    }
544
545    #[span_fn]
546    async fn push_stream(
547        client: &reqwest::Client,
548        root_path: &str,
549        stream_info: Arc<StreamInfo>,
550        retry_strategy: RetryStrategy,
551        decorator: &dyn RequestDecorator,
552    ) -> Result<(), IngestionClientError> {
553        let url = format!("{root_path}/ingestion/insert_stream");
554        let body: bytes::Bytes = encode_cbor(&*stream_info)
555            .map_err(|e| IngestionClientError::Permanent(format!("encoding stream: {e}")))?
556            .into();
557        tokio_retry2::Retry::spawn(retry_strategy, || async {
558            let mut request = client.post(&url).body(body.clone()).build().map_err(|e| {
559                IngestionClientError::Permanent(format!("building request: {e}")).into_retry()
560            })?;
561
562            if let Err(e) = decorator.decorate(&mut request).await {
563                debug!("request decorator: {e:?}");
564                return Err(
565                    IngestionClientError::Transient(format!("decorating request: {e}"))
566                        .into_retry(),
567                );
568            }
569
570            let response = client.execute(request).await.map_err(|e| {
571                IngestionClientError::Transient(format!("network error: {e}")).into_retry()
572            })?;
573
574            let status = response.status();
575            match status.as_u16() {
576                200..=299 => Ok(()),
577                400..=499 => {
578                    let body = response.text().await.unwrap_or_default();
579                    warn!("insert_stream client error ({status}): {body}");
580                    Err(IngestionClientError::Permanent(body).into_retry())
581                }
582                500..=599 => {
583                    let body = response.text().await.unwrap_or_default();
584                    debug!("insert_stream server error ({status}): {body}");
585                    Err(IngestionClientError::Transient(format!("{status}: {body}")).into_retry())
586                }
587                _ => {
588                    let body = response.text().await.unwrap_or_default();
589                    warn!("insert_stream unexpected status ({status}): {body}");
590                    Err(IngestionClientError::Permanent(format!("{status}: {body}")).into_retry())
591                }
592            }
593        })
594        .await
595    }
596
597    #[span_fn]
598    async fn push_block(
599        client: &reqwest::Client,
600        root_path: &str,
601        buffer: &dyn StreamBlock,
602        retry_strategy: RetryStrategy,
603        decorator: &dyn RequestDecorator,
604        process_info: &ProcessInfo,
605    ) -> Result<(), IngestionClientError> {
606        trace!("push_block");
607        let encoded_block: bytes::Bytes = buffer
608            .encode_bin(process_info)
609            .map_err(|e| IngestionClientError::Permanent(format!("encoding block: {e}")))?
610            .into();
611
612        let url = format!("{root_path}/ingestion/insert_block");
613
614        tokio_retry2::Retry::spawn(retry_strategy, || async {
615            let mut request = client
616                .post(&url)
617                .body(encoded_block.clone())
618                .build()
619                .map_err(|e| {
620                    IngestionClientError::Permanent(format!("building request: {e}")).into_retry()
621                })?;
622
623            if let Err(e) = decorator.decorate(&mut request).await {
624                debug!("request decorator: {e:?}");
625                return Err(
626                    IngestionClientError::Transient(format!("decorating request: {e}"))
627                        .into_retry(),
628                );
629            }
630
631            trace!("push_block: executing request");
632
633            let response = client.execute(request).await.map_err(|e| {
634                IngestionClientError::Transient(format!("network error: {e}")).into_retry()
635            })?;
636
637            let status = response.status();
638            match status.as_u16() {
639                200..=299 => Ok(()),
640                400..=499 => {
641                    let body = response.text().await.unwrap_or_default();
642                    warn!("insert_block client error ({status}): {body}");
643                    Err(IngestionClientError::Permanent(body).into_retry())
644                }
645                500..=599 => {
646                    let body = response.text().await.unwrap_or_default();
647                    debug!("insert_block server error ({status}): {body}");
648                    Err(IngestionClientError::Transient(format!("{status}: {body}")).into_retry())
649                }
650                _ => {
651                    let body = response.text().await.unwrap_or_default();
652                    warn!("insert_block unexpected status ({status}): {body}");
653                    Err(IngestionClientError::Permanent(format!("{status}: {body}")).into_retry())
654                }
655            }
656        })
657        .await
658    }
659
660    /// Dispatches one dequeued item onto the tokio runtime. `permit`, if
661    /// present, is held for the duration of the send and gates concurrency;
662    /// during the final shutdown drain it is `None` so all remaining items go
663    /// out at once. `Payload::Process` sets the shared `process_info`
664    /// synchronously here (before spawning), matching the pre-existing
665    /// guarantee that blocks can rely on it being set as soon as `Startup` is
666    /// dequeued, independently of whether the send itself succeeds.
667    fn spawn_item(
668        shared: &Arc<WorkerShared>,
669        queue: &Arc<SharedQueue>,
670        item: QueuedItem,
671        join_set: &mut tokio::task::JoinSet<()>,
672        permit: Option<OwnedSemaphorePermit>,
673    ) {
674        // Counted as outstanding from the moment it's handed to a send task
675        // until that task is reaped in one of the `run` loop's join-set
676        // drain points, so `is_busy()` stays true for the whole send
677        // (including retries), not just while the item sits in the deque.
678        queue.in_flight_count.fetch_add(1, Ordering::Relaxed);
679        if let Payload::Process(ref info) = item.payload {
680            *shared.process_info.lock().unwrap() = Some(info.clone());
681        }
682        let shared = shared.clone();
683        let queue = queue.clone();
684        let retry_strategy = shared.retry_by_priority[item.priority as usize].clone();
685        join_set.spawn(async move {
686            // Releases the permit and wakes the worker on both normal
687            // completion and panic/unwind, so a panicking send doesn't
688            // strand a semaphore permit or a waiting enqueuer until the
689            // next timeout tick.
690            let _wake_on_drop = WakeOnDrop {
691                queue,
692                _permit: permit,
693            };
694            match item.payload {
695                Payload::Process(info) => {
696                    if let Err(e) = Self::push_process(
697                        &shared.client,
698                        &shared.addr,
699                        info,
700                        retry_strategy,
701                        shared.decorator.as_ref(),
702                    )
703                    .await
704                    {
705                        error!("error sending process: {e}");
706                    }
707                }
708                Payload::Stream(stream_info) => {
709                    if let Err(e) = Self::push_stream(
710                        &shared.client,
711                        &shared.addr,
712                        stream_info,
713                        retry_strategy,
714                        shared.decorator.as_ref(),
715                    )
716                    .await
717                    {
718                        error!("error sending stream: {e}");
719                    }
720                }
721                Payload::Block { block, kind } => {
722                    let maybe_process_info = shared.process_info.lock().unwrap().clone();
723                    if let Some(process_info) = maybe_process_info {
724                        if let Err(e) = Self::push_block(
725                            &shared.client,
726                            &shared.addr,
727                            block.as_ref(),
728                            retry_strategy,
729                            shared.decorator.as_ref(),
730                            &process_info,
731                        )
732                        .await
733                        {
734                            error!("error sending {kind}: {e}");
735                        }
736                    } else {
737                        error!("trying to send blocks before Startup message");
738                    }
739                }
740            }
741        });
742    }
743
744    async fn run(
745        addr: String,
746        queue: Arc<SharedQueue>,
747        config: WorkerConfig,
748        make_decorator: Box<dyn FnOnce() -> Arc<dyn RequestDecorator> + Send>,
749        shutdown_complete: Arc<(Mutex<bool>, std::sync::Condvar)>,
750    ) {
751        let client = match reqwest::Client::builder()
752            .pool_idle_timeout(Some(core::time::Duration::from_secs(2)))
753            .timeout(config.request_timeout)
754            .build()
755        {
756            Ok(client) => client,
757            Err(e) => {
758                error!("Error creating http client: {e:?}");
759                return;
760            }
761        };
762        // eagerly connect, a new process message is sure to follow if it's not already in queue
763        if let Some(process_id) = micromegas_tracing::dispatch::process_id() {
764            let cpu_tracing_enabled =
765                micromegas_tracing::dispatch::cpu_tracing_enabled().unwrap_or(false);
766            info!("process_id={process_id}, cpu_tracing_enabled={cpu_tracing_enabled}");
767        }
768        let shared = Arc::new(WorkerShared {
769            client,
770            addr,
771            process_info: Mutex::new(None),
772            decorator: make_decorator(),
773            retry_by_priority: config.retry_by_priority,
774        });
775        let semaphore = Arc::new(Semaphore::new(config.max_in_flight_requests.max(1)));
776        let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
777        let flusher = FlushMonitor::default();
778
779        loop {
780            if queue.shutdown.load(Ordering::SeqCst) {
781                break;
782            }
783
784            loop {
785                let permit = match Arc::clone(&semaphore).try_acquire_owned() {
786                    Ok(permit) => permit,
787                    Err(_) => break,
788                };
789                match queue.pop_highest() {
790                    Some(item) => {
791                        Self::spawn_item(&shared, &queue, item, &mut join_set, Some(permit))
792                    }
793                    None => {
794                        drop(permit);
795                        break;
796                    }
797                }
798            }
799            // Reap finished sends so the JoinSet doesn't grow unbounded.
800            while let Some(result) = join_set.try_join_next() {
801                queue.in_flight_count.fetch_sub(1, Ordering::Relaxed);
802                if let Err(e) = result {
803                    error!("telemetry send task panicked: {e}");
804                }
805            }
806
807            // clamp to zero as the original code did: time_to_flush_seconds() returns i64
808            // and goes negative when a flush is overdue; a negative value cast to
809            // Duration::from_secs(u64) would wrap to ~u64::MAX and never wake.
810            let timeout = Duration::from_secs(max(0, flusher.time_to_flush_seconds()) as u64);
811            {
812                let guard = queue.queues.lock().unwrap();
813                let empty = guard.iter().all(VecDeque::is_empty);
814                let permit_available = semaphore.available_permits() > 0;
815                if !queue.shutdown.load(Ordering::SeqCst) && (empty || !permit_available) {
816                    let _ = queue.notify.wait_timeout(guard, timeout);
817                }
818            }
819            flusher.tick();
820            report_dropped_items(&queue);
821        }
822
823        debug!("received shutdown signal, flushing remaining data");
824        // Final drain: submit everything left, bypassing the concurrency gate
825        // so shutdown doesn't wait on `max_in_flight_requests` round trips.
826        let mut drained = 0;
827        while let Some(item) = queue.pop_highest() {
828            drained += 1;
829            Self::spawn_item(&shared, &queue, item, &mut join_set, None);
830        }
831        while let Some(result) = join_set.join_next().await {
832            queue.in_flight_count.fetch_sub(1, Ordering::Relaxed);
833            if let Err(e) = result {
834                error!("telemetry send task panicked: {e}");
835            }
836        }
837        debug!("telemetry thread shutdown complete, drained {drained} remaining items");
838        // Best-effort: post-shutdown the dispatch sink is usually already
839        // swapped out, but any drops still pending from the last window get
840        // one final chance to be reported.
841        report_dropped_items(&queue);
842
843        let (lock, cvar) = &*shutdown_complete;
844        let mut completed = lock.lock().unwrap();
845        *completed = true;
846        cvar.notify_all();
847    }
848
849    fn thread_proc(
850        addr: String,
851        queue: Arc<SharedQueue>,
852        config: WorkerConfig,
853        make_decorator: Box<dyn FnOnce() -> Arc<dyn RequestDecorator> + Send>,
854        shutdown_complete: Arc<(Mutex<bool>, std::sync::Condvar)>,
855    ) {
856        // TODO: add runtime as configuration option (or create one only if global don't exist)
857        let tokio_runtime = match tokio::runtime::Runtime::new() {
858            Ok(rt) => rt,
859            Err(e) => {
860                error!("Failed to create tokio runtime for telemetry: {e}");
861                return;
862            }
863        };
864        tokio_runtime.block_on(Self::run(
865            addr,
866            queue,
867            config,
868            make_decorator,
869            shutdown_complete,
870        ));
871    }
872}
873
874impl EventSink for HttpEventSink {
875    fn on_startup(&self, process_info: Arc<ProcessInfo>) {
876        self.queue.try_push(QueuedItem {
877            priority: UploadPriority::Metadata,
878            bytes: 0,
879            payload: Payload::Process(process_info),
880        });
881    }
882
883    fn on_shutdown(&self) {
884        // Signals shutdown, wakes the worker, and waits (bounded) for its
885        // final drain. If it times out, the caller (and the eventual `Drop`)
886        // still won't block indefinitely — see `signal_shutdown_and_wait`.
887        self.signal_shutdown_and_wait();
888    }
889
890    fn on_log_enabled(&self, _metadata: &LogMetadata) -> bool {
891        // If all previous filter succeeds this sink always agrees
892        true
893    }
894
895    fn on_log(
896        &self,
897        _metadata: &LogMetadata,
898        _properties: &[Property],
899        _time: i64,
900        _args: fmt::Arguments<'_>,
901    ) {
902    }
903
904    fn on_init_log_stream(&self, log_stream: &LogStream) {
905        self.pending_stream_meta.lock().unwrap().insert(
906            log_stream.stream_id(),
907            Arc::new(make_stream_info(log_stream)),
908        );
909    }
910
911    fn on_process_log_block(&self, log_block: Arc<LogBlock>) {
912        self.flush_pending_stream_meta(log_block.stream_id);
913        let bytes = log_block.len_bytes();
914        self.queue.try_push(QueuedItem {
915            priority: UploadPriority::Logs,
916            bytes,
917            payload: Payload::Block {
918                block: log_block,
919                kind: "log block",
920            },
921        });
922    }
923
924    fn on_init_metrics_stream(&self, metrics_stream: &MetricsStream) {
925        self.pending_stream_meta.lock().unwrap().insert(
926            metrics_stream.stream_id(),
927            Arc::new(make_stream_info(metrics_stream)),
928        );
929    }
930
931    fn on_process_metrics_block(&self, metrics_block: Arc<MetricsBlock>) {
932        self.flush_pending_stream_meta(metrics_block.stream_id);
933        let bytes = metrics_block.len_bytes();
934        self.queue.try_push(QueuedItem {
935            priority: UploadPriority::Metrics,
936            bytes,
937            payload: Payload::Block {
938                block: metrics_block,
939                kind: "metrics block",
940            },
941        });
942    }
943
944    fn on_init_thread_stream(&self, thread_stream: &ThreadStream) {
945        self.pending_stream_meta.lock().unwrap().insert(
946            thread_stream.stream_id(),
947            Arc::new(make_stream_info(thread_stream)),
948        );
949    }
950
951    fn on_process_thread_block(&self, thread_block: Arc<ThreadBlock>) {
952        self.flush_pending_stream_meta(thread_block.stream_id);
953        let bytes = thread_block.len_bytes();
954        self.queue.try_push(QueuedItem {
955            priority: UploadPriority::Traces,
956            bytes,
957            payload: Payload::Block {
958                block: thread_block,
959                kind: "thread block",
960            },
961        });
962    }
963
964    fn on_init_image_stream(&self, stream: &ImageStream) {
965        self.pending_stream_meta
966            .lock()
967            .unwrap()
968            .insert(stream.stream_id(), Arc::new(make_stream_info(stream)));
969    }
970
971    fn on_process_image_block(&self, block: Arc<ImageBlock>) {
972        self.flush_pending_stream_meta(block.stream_id);
973        let bytes = block.len_bytes();
974        self.queue.try_push(QueuedItem {
975            priority: UploadPriority::Traces,
976            bytes,
977            payload: Payload::Block {
978                block,
979                kind: "image block",
980            },
981        });
982    }
983
984    fn is_busy(&self) -> bool {
985        let queued = self.queue.queue_count.load(Ordering::Relaxed);
986        debug_assert!(queued >= 0, "queue_count went negative: {queued}");
987        let in_flight = self.queue.in_flight_count.load(Ordering::Relaxed);
988        debug_assert!(in_flight >= 0, "in_flight_count went negative: {in_flight}");
989        queued > 0 || in_flight > 0
990    }
991}