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