Skip to main content

rocketmq_observability/exporter/
outage.rs

1// Copyright 2026 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Bounded admission contract for telemetry exporters during collector outages.
16//!
17//! The data plane uses [`TelemetryOutageQueue::try_enqueue`], which admits records synchronously
18//! without awaiting capacity. Export workers drain batches on their own owned lifecycle. Shutdown
19//! closes admission and uses one caller-provided absolute deadline; reaching that deadline drops
20//! and reports the remaining telemetry instead of extending the service shutdown budget.
21
22use std::fmt;
23#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
24use std::sync::atomic::AtomicBool;
25use std::sync::atomic::AtomicU64;
26#[cfg(feature = "otlp-logs")]
27use std::sync::atomic::AtomicUsize;
28use std::sync::atomic::Ordering;
29#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
30use std::sync::Arc;
31use std::sync::Mutex;
32use std::sync::TryLockError;
33use std::time::Instant;
34
35use rocketmq_runtime::BudgetDimension;
36use rocketmq_runtime::BudgetLimit;
37use rocketmq_runtime::BudgetedItem;
38use rocketmq_runtime::BudgetedQueue;
39use rocketmq_runtime::FullPolicy;
40use rocketmq_runtime::QueuePushOutcome;
41use rocketmq_runtime::QueuePushRejection;
42use rocketmq_runtime::ResourceBudgetTree;
43
44/// Default maximum number of telemetry records admitted during a collector outage.
45pub const DEFAULT_MAX_QUEUE_ITEMS: usize = 2_048;
46
47/// Default maximum estimated bytes admitted during a collector outage.
48pub const DEFAULT_MAX_QUEUE_BYTES: usize = 8 * 1024 * 1024;
49
50/// Default maximum size of one admitted telemetry record.
51pub const DEFAULT_MAX_RECORD_BYTES: usize = 64 * 1024;
52
53/// Default maximum number of records returned in one exporter batch.
54pub const DEFAULT_MAX_EXPORT_BATCH_ITEMS: usize = 512;
55
56/// Default interval before a partial exporter batch is flushed.
57pub const DEFAULT_SCHEDULED_DELAY_MILLIS: u64 = 5_000;
58
59/// Default maximum duration of one collector export attempt.
60pub const DEFAULT_EXPORT_TIMEOUT_MILLIS: u64 = 3_000;
61
62/// Default total deadline for telemetry provider shutdown.
63pub const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS: u64 = 5_000;
64
65/// Count and byte limits for an exporter outage queue.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct TelemetryQueueLimits {
68    /// Maximum number of queued records.
69    max_items: usize,
70    /// Maximum sum of estimated record bytes.
71    max_bytes: usize,
72    /// Maximum estimated size of one record.
73    max_record_bytes: usize,
74}
75
76impl TelemetryQueueLimits {
77    /// Creates validated non-zero queue limits.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`TelemetryQueueConfigError`] when a limit is zero or one record could exceed the
82    /// entire byte budget.
83    pub fn new(max_items: usize, max_bytes: usize, max_record_bytes: usize) -> Result<Self, TelemetryQueueConfigError> {
84        if max_items == 0 {
85            return Err(TelemetryQueueConfigError::ZeroLimit("max_items"));
86        }
87        if max_bytes == 0 {
88            return Err(TelemetryQueueConfigError::ZeroLimit("max_bytes"));
89        }
90        if max_record_bytes == 0 {
91            return Err(TelemetryQueueConfigError::ZeroLimit("max_record_bytes"));
92        }
93        if max_record_bytes > max_bytes {
94            return Err(TelemetryQueueConfigError::RecordExceedsQueue {
95                max_record_bytes,
96                max_queue_bytes: max_bytes,
97            });
98        }
99        Ok(Self {
100            max_items,
101            max_bytes,
102            max_record_bytes,
103        })
104    }
105
106    /// Returns the maximum number of queued records.
107    pub const fn max_items(self) -> usize {
108        self.max_items
109    }
110
111    /// Returns the maximum sum of estimated queued bytes.
112    pub const fn max_bytes(self) -> usize {
113        self.max_bytes
114    }
115
116    /// Returns the maximum estimated size of one record.
117    pub const fn max_record_bytes(self) -> usize {
118        self.max_record_bytes
119    }
120}
121
122impl Default for TelemetryQueueLimits {
123    fn default() -> Self {
124        Self {
125            max_items: DEFAULT_MAX_QUEUE_ITEMS,
126            max_bytes: DEFAULT_MAX_QUEUE_BYTES,
127            max_record_bytes: DEFAULT_MAX_RECORD_BYTES,
128        }
129    }
130}
131
132/// Invalid telemetry outage queue configuration.
133#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
134pub enum TelemetryQueueConfigError {
135    /// A queue limit was configured as zero.
136    #[error("telemetry queue limit {0} must be greater than zero")]
137    ZeroLimit(&'static str),
138    /// The maximum record size exceeds the entire byte budget.
139    #[error("telemetry max_record_bytes {max_record_bytes} exceeds max_queue_bytes {max_queue_bytes}")]
140    RecordExceedsQueue {
141        /// Maximum configured record size.
142        max_record_bytes: usize,
143        /// Maximum configured queue bytes.
144        max_queue_bytes: usize,
145    },
146}
147
148/// Reason a telemetry record was dropped before export.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum TelemetryDropReason {
151    /// The queue has stopped accepting records for shutdown.
152    Closed,
153    /// The queue item limit was reached.
154    ItemLimit,
155    /// The queue byte limit was reached.
156    ByteLimit,
157    /// The individual record exceeded its byte limit.
158    RecordTooLarge,
159    /// A legacy exporter adapter could not acquire its internal lock.
160    ///
161    /// The resource-budget-backed queue does not emit this reason; the variant remains part of the
162    /// public reporting contract for older adapters.
163    LockUnavailable,
164}
165
166impl TelemetryDropReason {
167    #[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
168    const fn as_str(self) -> &'static str {
169        match self {
170            Self::Closed => "closed",
171            Self::ItemLimit => "item_limit",
172            Self::ByteLimit => "byte_limit",
173            Self::RecordTooLarge => "record_too_large",
174            Self::LockUnavailable => "lock_unavailable",
175        }
176    }
177}
178
179/// Result of non-blocking telemetry admission.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum TelemetryEnqueueOutcome {
182    /// The record was admitted.
183    Accepted,
184    /// The record was dropped for the supplied reason.
185    Dropped(TelemetryDropReason),
186}
187
188/// Current queue and lifetime counters.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub struct TelemetryQueueSnapshot {
191    /// Records currently waiting for export.
192    pub queued_items: usize,
193    /// Estimated bytes currently waiting for export.
194    pub queued_bytes: usize,
195    /// Records admitted over the queue lifetime.
196    pub accepted_items: u64,
197    /// Estimated bytes admitted over the queue lifetime.
198    pub accepted_bytes: u64,
199    /// Records removed by exporter workers.
200    pub drained_items: u64,
201    /// Estimated bytes removed by exporter workers.
202    pub drained_bytes: u64,
203    /// Records dropped before export or at the shutdown deadline.
204    pub dropped_items: u64,
205    /// Estimated bytes dropped before export or at the shutdown deadline.
206    pub dropped_bytes: u64,
207    /// Whether new admission is closed.
208    pub closed: bool,
209}
210
211/// Final state produced when an outage queue drains or reaches its absolute deadline.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct TelemetryOutageShutdownReport {
214    /// Whether the absolute shutdown deadline expired with queued telemetry remaining.
215    pub timed_out: bool,
216    /// Records dropped specifically because the deadline expired.
217    pub deadline_dropped_items: u64,
218    /// Estimated bytes dropped specifically because the deadline expired.
219    pub deadline_dropped_bytes: u64,
220    /// Final queue and lifetime counters.
221    pub snapshot: TelemetryQueueSnapshot,
222}
223
224struct Queued<T> {
225    item: T,
226    estimated_bytes: usize,
227}
228
229/// A count-and-byte bounded queue for exporter adapters.
230///
231/// This type does not create a task or thread. The exporter remains owned by the service's
232/// existing lifecycle and calls [`Self::drain_batch`] from that owned worker.
233pub struct TelemetryOutageQueue<T> {
234    limits: TelemetryQueueLimits,
235    admission_gate: Mutex<()>,
236    queue: BudgetedQueue<Queued<T>>,
237    deferred: Mutex<Option<BudgetedItem<Queued<T>>>>,
238    shutdown_deadline: Mutex<Option<Instant>>,
239    accepted_items: AtomicU64,
240    accepted_bytes: AtomicU64,
241    drained_items: AtomicU64,
242    drained_bytes: AtomicU64,
243    dropped_items: AtomicU64,
244    dropped_bytes: AtomicU64,
245}
246
247impl<T> fmt::Debug for TelemetryOutageQueue<T> {
248    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249        formatter
250            .debug_struct("TelemetryOutageQueue")
251            .field("limits", &self.limits)
252            .field("queue", &self.queue)
253            .field("snapshot", &self.snapshot())
254            .finish_non_exhaustive()
255    }
256}
257
258impl<T> TelemetryOutageQueue<T> {
259    /// Creates an empty queue with validated limits.
260    pub fn new(limits: TelemetryQueueLimits) -> Self {
261        // `TelemetryQueueLimits` has private fields and can only be constructed through its
262        // validation boundary or `Default`, so the resource budget must accept these values.
263        let budget = match ResourceBudgetTree::new(
264            "telemetry-export",
265            BudgetLimit::new(limits.max_items, limits.max_bytes, FullPolicy::Reject),
266        ) {
267            Ok(tree) => tree.root(),
268            Err(error) => unreachable!("validated telemetry queue limits must form a resource budget: {error}"),
269        };
270        Self {
271            limits,
272            admission_gate: Mutex::new(()),
273            queue: BudgetedQueue::new(budget),
274            deferred: Mutex::new(None),
275            shutdown_deadline: Mutex::new(None),
276            accepted_items: AtomicU64::new(0),
277            accepted_bytes: AtomicU64::new(0),
278            drained_items: AtomicU64::new(0),
279            drained_bytes: AtomicU64::new(0),
280            dropped_items: AtomicU64::new(0),
281            dropped_bytes: AtomicU64::new(0),
282        }
283    }
284
285    /// Attempts to admit a telemetry record without waiting for an exporter lock.
286    ///
287    /// `estimated_bytes` must include the envelope and attribute payload estimate used by the
288    /// owning exporter. A zero estimate is accounted as one byte so the byte budget always moves.
289    pub fn try_enqueue(&self, item: T, estimated_bytes: usize) -> TelemetryEnqueueOutcome {
290        self.try_enqueue_with(item, estimated_bytes, || {})
291    }
292
293    /// Admits a record and synchronously forwards it to the owning SDK queue under one ordering
294    /// boundary.
295    ///
296    /// The SDK processor entrypoint used by this adapter is non-blocking. Keeping it inside the
297    /// admission gate prevents concurrent callers from reserving resource permits in one order
298    /// while reaching the SDK queue in another. Export completion may therefore release FIFO
299    /// permits without freeing the byte budget of a different in-flight record.
300    fn try_enqueue_with<F>(&self, item: T, estimated_bytes: usize, forward: F) -> TelemetryEnqueueOutcome
301    where
302        F: FnOnce(),
303    {
304        let estimated_bytes = estimated_bytes.max(1);
305        if estimated_bytes > self.limits.max_record_bytes {
306            self.record_drop(estimated_bytes);
307            return TelemetryEnqueueOutcome::Dropped(TelemetryDropReason::RecordTooLarge);
308        }
309
310        let _admission = match self.admission_gate.try_lock() {
311            Ok(guard) => guard,
312            Err(TryLockError::WouldBlock | TryLockError::Poisoned(_)) => {
313                self.record_drop(estimated_bytes);
314                return TelemetryEnqueueOutcome::Dropped(TelemetryDropReason::LockUnavailable);
315            }
316        };
317        // `begin_shutdown` closes the queue under this same gate, so a completed shutdown
318        // transition must take precedence over resource-budget exhaustion.
319        if self.queue.is_closed() {
320            self.record_drop(estimated_bytes);
321            return TelemetryEnqueueOutcome::Dropped(TelemetryDropReason::Closed);
322        }
323        let record = Queued { item, estimated_bytes };
324        if let QueuePushOutcome::Rejected {
325            item: _record,
326            rejection,
327        } = self.queue.try_push_data(record, estimated_bytes)
328        {
329            let reason = match rejection {
330                QueuePushRejection::Closed | QueuePushRejection::SlowConsumerClosed => TelemetryDropReason::Closed,
331                QueuePushRejection::DeadlineExceeded => TelemetryDropReason::ItemLimit,
332                QueuePushRejection::BudgetExhausted(error) => match error.dimension() {
333                    Some(BudgetDimension::Bytes) => TelemetryDropReason::ByteLimit,
334                    // This queue has no rate budget. Keep the exhaustive fallback fail-closed if
335                    // the shared budget implementation ever reports one.
336                    Some(BudgetDimension::Count | BudgetDimension::Rate) => TelemetryDropReason::ItemLimit,
337                    None => TelemetryDropReason::Closed,
338                },
339            };
340            self.record_drop(estimated_bytes);
341            return TelemetryEnqueueOutcome::Dropped(reason);
342        }
343
344        self.accepted_items.fetch_add(1, Ordering::Relaxed);
345        self.accepted_bytes.fetch_add(estimated_bytes as u64, Ordering::Relaxed);
346        forward();
347        TelemetryEnqueueOutcome::Accepted
348    }
349
350    /// Removes a bounded FIFO batch for an exporter worker.
351    ///
352    /// `max_items` and `max_bytes` are clamped to at least one. One admitted record may exceed
353    /// `max_bytes`; it is still returned alone so a worker can always make progress.
354    pub fn drain_batch(&self, max_items: usize, max_bytes: usize) -> Vec<T> {
355        let _admission = self.lock_admission();
356        let mut deferred = self.lock_deferred();
357        let mut items = Vec::new();
358        let mut drained_bytes = 0usize;
359        let max_items = max_items.max(1);
360        let max_bytes = max_bytes.max(1);
361        while items.len() < max_items {
362            let Some(record) = deferred.take().or_else(|| self.queue.try_pop_budgeted()) else {
363                break;
364            };
365            let record_bytes = record.retained_bytes();
366            if !items.is_empty() && drained_bytes.saturating_add(record_bytes) > max_bytes {
367                *deferred = Some(record);
368                break;
369            }
370            let (record, permit, _) = record.into_parts();
371            debug_assert_eq!(record.estimated_bytes, record_bytes);
372            drop(permit);
373            drained_bytes += record_bytes;
374            items.push(record.item);
375        }
376        self.drained_items.fetch_add(items.len() as u64, Ordering::Relaxed);
377        self.drained_bytes.fetch_add(drained_bytes as u64, Ordering::Relaxed);
378        items
379    }
380
381    /// Stops new admission and binds shutdown to one absolute deadline.
382    pub fn begin_shutdown(&self, deadline: Instant) {
383        let _admission = self.lock_admission();
384        *self.lock_shutdown_deadline() = Some(deadline);
385        self.queue.close();
386    }
387
388    /// Returns a final report once the queue drains or the absolute deadline expires.
389    ///
390    /// Before the deadline, `None` means the exporter worker should continue draining. At the
391    /// deadline, all remaining records are dropped and included in the report.
392    pub fn poll_shutdown(&self, now: Instant) -> Option<TelemetryOutageShutdownReport> {
393        let _admission = self.lock_admission();
394        let snapshot = self.snapshot_locked();
395        if !snapshot.closed {
396            return None;
397        }
398        if snapshot.queued_items == 0 {
399            return Some(TelemetryOutageShutdownReport {
400                timed_out: false,
401                deadline_dropped_items: 0,
402                deadline_dropped_bytes: 0,
403                snapshot,
404            });
405        }
406        let deadline = (*self.lock_shutdown_deadline())?;
407        if now < deadline {
408            return None;
409        }
410
411        let mut deadline_dropped_items = 0u64;
412        let mut deadline_dropped_bytes = 0u64;
413        if let Some(record) = self.lock_deferred().take() {
414            deadline_dropped_items += 1;
415            deadline_dropped_bytes += record.retained_bytes() as u64;
416            drop(record);
417        }
418        while let Some(record) = self.queue.try_pop_budgeted() {
419            deadline_dropped_items += 1;
420            deadline_dropped_bytes += record.retained_bytes() as u64;
421            drop(record);
422        }
423        self.dropped_items.fetch_add(deadline_dropped_items, Ordering::Relaxed);
424        self.dropped_bytes.fetch_add(deadline_dropped_bytes, Ordering::Relaxed);
425        Some(TelemetryOutageShutdownReport {
426            timed_out: deadline_dropped_items > 0,
427            deadline_dropped_items,
428            deadline_dropped_bytes,
429            snapshot: self.snapshot_locked(),
430        })
431    }
432
433    /// Returns current queue occupancy and lifetime counters.
434    pub fn snapshot(&self) -> TelemetryQueueSnapshot {
435        let _admission = self.lock_admission();
436        self.snapshot_locked()
437    }
438
439    fn snapshot_locked(&self) -> TelemetryQueueSnapshot {
440        let queue = self.queue.snapshot();
441        TelemetryQueueSnapshot {
442            // `reserved_count` and `retained_bytes` include a look-ahead item temporarily removed
443            // from the FIFO but still holding its resource permit.
444            queued_items: queue.reserved_count,
445            queued_bytes: queue.retained_bytes,
446            accepted_items: self.accepted_items.load(Ordering::Relaxed),
447            accepted_bytes: self.accepted_bytes.load(Ordering::Relaxed),
448            drained_items: self.drained_items.load(Ordering::Relaxed),
449            drained_bytes: self.drained_bytes.load(Ordering::Relaxed),
450            dropped_items: self.dropped_items.load(Ordering::Relaxed),
451            dropped_bytes: self.dropped_bytes.load(Ordering::Relaxed),
452            closed: queue.closed,
453        }
454    }
455
456    fn record_drop(&self, estimated_bytes: usize) {
457        self.dropped_items.fetch_add(1, Ordering::Relaxed);
458        self.dropped_bytes.fetch_add(estimated_bytes as u64, Ordering::Relaxed);
459    }
460
461    #[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
462    fn drop_remaining(&self) {
463        let _admission = self.lock_admission();
464        let mut dropped_items = 0u64;
465        let mut dropped_bytes = 0u64;
466        if let Some(record) = self.lock_deferred().take() {
467            dropped_items += 1;
468            dropped_bytes += record.retained_bytes() as u64;
469            drop(record);
470        }
471        while let Some(record) = self.queue.try_pop_budgeted() {
472            dropped_items += 1;
473            dropped_bytes += record.retained_bytes() as u64;
474            drop(record);
475        }
476        self.dropped_items.fetch_add(dropped_items, Ordering::Relaxed);
477        self.dropped_bytes.fetch_add(dropped_bytes, Ordering::Relaxed);
478    }
479
480    fn lock_deferred(&self) -> std::sync::MutexGuard<'_, Option<BudgetedItem<Queued<T>>>> {
481        self.deferred.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
482    }
483
484    fn lock_admission(&self) -> std::sync::MutexGuard<'_, ()> {
485        self.admission_gate
486            .lock()
487            .unwrap_or_else(std::sync::PoisonError::into_inner)
488    }
489
490    fn lock_shutdown_deadline(&self) -> std::sync::MutexGuard<'_, Option<Instant>> {
491        self.shutdown_deadline
492            .lock()
493            .unwrap_or_else(std::sync::PoisonError::into_inner)
494    }
495}
496
497impl<T> Default for TelemetryOutageQueue<T> {
498    fn default() -> Self {
499        Self::new(TelemetryQueueLimits::default())
500    }
501}
502
503#[cfg(feature = "otlp-traces")]
504fn trace_batch_config(limits: TelemetryQueueLimits) -> opentelemetry_sdk::trace::BatchConfig {
505    opentelemetry_sdk::trace::BatchConfigBuilder::default()
506        // The resource-budget queue counts both channel-resident and in-flight records. Giving
507        // the SDK channel the same count capacity means a successfully admitted record cannot be
508        // rejected by the SDK merely because an export batch is currently in flight.
509        .with_max_queue_size(limits.max_items())
510        .with_max_export_batch_size(DEFAULT_MAX_EXPORT_BATCH_ITEMS.min(limits.max_items()))
511        .with_scheduled_delay(std::time::Duration::from_millis(DEFAULT_SCHEDULED_DELAY_MILLIS))
512        .build()
513}
514
515#[cfg(feature = "otlp-logs")]
516fn log_batch_config(limits: TelemetryQueueLimits) -> opentelemetry_sdk::logs::BatchConfig {
517    let max_export_batch_size = log_export_batch_size(limits);
518    opentelemetry_sdk::logs::BatchConfigBuilder::default()
519        .with_max_queue_size(limits.max_items())
520        // A completed LogBatch still borrows the SDK worker's storage until the exporter method
521        // returns. Keep one full-sized record of count-and-byte headroom so another batch can
522        // begin and safely release the previous batch's permits without steady-state starvation.
523        .with_max_export_batch_size(max_export_batch_size)
524        .with_scheduled_delay(std::time::Duration::from_millis(DEFAULT_SCHEDULED_DELAY_MILLIS))
525        .build()
526}
527
528#[cfg(feature = "otlp-logs")]
529fn log_export_batch_size(limits: TelemetryQueueLimits) -> usize {
530    assert!(
531        limits.max_items() >= 2 && limits.max_bytes() >= limits.max_record_bytes().saturating_mul(2),
532        "log outage admission requires count and byte headroom for one maximum-sized record"
533    );
534    let count_bound = limits.max_items() - 1;
535    let byte_bound = (limits.max_bytes() - limits.max_record_bytes()) / limits.max_record_bytes();
536    DEFAULT_MAX_EXPORT_BATCH_ITEMS.min(count_bound).min(byte_bound)
537}
538
539#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
540#[derive(Debug)]
541struct AdmissionDropReporter {
542    signal: &'static str,
543    warning_emitted: AtomicBool,
544}
545
546#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
547impl AdmissionDropReporter {
548    const fn new(signal: &'static str) -> Self {
549        Self {
550            signal,
551            warning_emitted: AtomicBool::new(false),
552        }
553    }
554
555    fn record(&self, reason: TelemetryDropReason, limits: TelemetryQueueLimits) {
556        if !self.warning_emitted.swap(true, Ordering::Relaxed) {
557            let _suppressed = opentelemetry::Context::enter_telemetry_suppressed_scope();
558            tracing::warn!(
559                target: "rocketmq_observability",
560                signal = self.signal,
561                reason = reason.as_str(),
562                max_queue_items = limits.max_items(),
563                max_queue_bytes = limits.max_bytes(),
564                max_record_bytes = limits.max_record_bytes(),
565                "telemetry was rejected by the non-blocking count-and-byte admission boundary; further drops are summarized during shutdown"
566            );
567        }
568    }
569
570    fn report_shutdown(&self, snapshot: TelemetryQueueSnapshot) {
571        if snapshot.dropped_items == 0 {
572            return;
573        }
574        let _suppressed = opentelemetry::Context::enter_telemetry_suppressed_scope();
575        tracing::warn!(
576            target: "rocketmq_observability",
577            signal = self.signal,
578            accepted_items = snapshot.accepted_items,
579            accepted_bytes = snapshot.accepted_bytes,
580            drained_items = snapshot.drained_items,
581            drained_bytes = snapshot.drained_bytes,
582            dropped_items = snapshot.dropped_items,
583            dropped_bytes = snapshot.dropped_bytes,
584            "telemetry outage admission and shutdown drop totals"
585        );
586    }
587}
588
589#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
590fn shutdown_deadline(timeout: std::time::Duration) -> Instant {
591    let now = Instant::now();
592    now.checked_add(timeout).unwrap_or(now)
593}
594
595#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
596fn release_exported(queue: &TelemetryOutageQueue<()>, count: usize) {
597    if count > 0 {
598        let _ = queue.drain_batch(count, usize::MAX);
599    }
600}
601
602#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
603struct ByteEstimator {
604    total: usize,
605    limit: usize,
606}
607
608#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
609impl ByteEstimator {
610    fn new(limit: usize) -> Self {
611        Self { total: 0, limit }
612    }
613
614    fn add(&mut self, bytes: usize) {
615        if self.exceeded() {
616            return;
617        }
618        self.total = self.total.saturating_add(bytes);
619        if self.total > self.limit {
620            self.total = self.limit.saturating_add(1);
621        }
622    }
623
624    fn add_allocation_str(&mut self, value: &str) {
625        // `Key` and `StringValue` hide whether their storage is static, boxed, or ref-counted.
626        // Charging the payload plus an Arc header is conservative for every current SDK variant.
627        self.add(value.len().saturating_add(2 * std::mem::size_of::<usize>()));
628    }
629
630    fn exceeded(&self) -> bool {
631        self.total > self.limit
632    }
633
634    fn finish(self) -> usize {
635        self.total.max(1)
636    }
637}
638
639#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
640fn add_attribute(estimator: &mut ByteEstimator, attribute: &opentelemetry::KeyValue) {
641    estimator.add_allocation_str(attribute.key.as_str());
642    add_attribute_value(estimator, &attribute.value);
643}
644
645#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
646fn add_attribute_value(estimator: &mut ByteEstimator, value: &opentelemetry::Value) {
647    match value {
648        opentelemetry::Value::Bool(_) | opentelemetry::Value::I64(_) | opentelemetry::Value::F64(_) => {}
649        opentelemetry::Value::String(value) => estimator.add_allocation_str(value.as_str()),
650        opentelemetry::Value::Array(values) => match values {
651            opentelemetry::Array::Bool(values) => {
652                // Vec<bool>::capacity is in bits. Charging one byte per bit is deliberately
653                // conservative and avoids depending on its internal word representation.
654                estimator.add(values.capacity());
655            }
656            opentelemetry::Array::I64(values) => {
657                estimator.add(values.capacity().saturating_mul(std::mem::size_of::<i64>()));
658            }
659            opentelemetry::Array::F64(values) => {
660                estimator.add(values.capacity().saturating_mul(std::mem::size_of::<f64>()));
661            }
662            opentelemetry::Array::String(values) => {
663                estimator.add(
664                    values
665                        .capacity()
666                        .saturating_mul(std::mem::size_of::<opentelemetry::StringValue>()),
667                );
668                for value in values {
669                    estimator.add_allocation_str(value.as_str());
670                    if estimator.exceeded() {
671                        break;
672                    }
673                }
674            }
675            _ => estimator.total = estimator.limit.saturating_add(1),
676        },
677        _ => estimator.total = estimator.limit.saturating_add(1),
678    }
679}
680
681#[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
682fn add_instrumentation_scope(estimator: &mut ByteEstimator, scope: &opentelemetry::InstrumentationScope) {
683    // The SDK clones a scope into each queued record. Its private Vec clone is compact for the
684    // current implementation; charging two slots per observed attribute remains conservative if
685    // allocator growth rounds the capacity upward.
686    estimator.add_allocation_str(scope.name());
687    if let Some(version) = scope.version() {
688        estimator.add_allocation_str(version);
689    }
690    if let Some(schema_url) = scope.schema_url() {
691        estimator.add_allocation_str(schema_url);
692    }
693    let attribute_count = scope.attributes().count();
694    estimator.add(
695        attribute_count
696            .saturating_mul(2)
697            .saturating_mul(std::mem::size_of::<opentelemetry::KeyValue>()),
698    );
699    for attribute in scope.attributes() {
700        add_attribute(estimator, attribute);
701        if estimator.exceeded() {
702            break;
703        }
704    }
705}
706
707#[cfg(feature = "otlp-traces")]
708fn add_trace_state(estimator: &mut ByteEstimator, context: &opentelemetry::trace::SpanContext) {
709    let entries = context.trace_state().into_iter();
710    let entry_count = entries.len();
711    let rounded_slots = if entry_count == 0 {
712        0
713    } else {
714        entry_count
715            .checked_next_power_of_two()
716            .unwrap_or(usize::MAX)
717            .saturating_mul(2)
718    };
719    estimator.add(rounded_slots.saturating_mul(std::mem::size_of::<(String, String)>()));
720    for (key, value) in entries {
721        estimator.add_allocation_str(key);
722        estimator.add_allocation_str(value);
723        if estimator.exceeded() {
724            break;
725        }
726    }
727}
728
729#[cfg(feature = "otlp-traces")]
730fn estimate_span_bytes(span: &opentelemetry_sdk::trace::SpanData, limit: usize) -> usize {
731    let mut estimator = ByteEstimator::new(limit);
732    estimator.add(std::mem::size_of::<opentelemetry_sdk::trace::SpanData>());
733    if let std::borrow::Cow::Owned(name) = &span.name {
734        estimator.add(name.capacity());
735    }
736    add_trace_state(&mut estimator, &span.span_context);
737
738    estimator.add(
739        span.attributes
740            .capacity()
741            .saturating_mul(std::mem::size_of::<opentelemetry::KeyValue>()),
742    );
743    for attribute in &span.attributes {
744        add_attribute(&mut estimator, attribute);
745        if estimator.exceeded() {
746            return estimator.finish();
747        }
748    }
749
750    estimator.add(
751        span.events
752            .events
753            .capacity()
754            .saturating_mul(std::mem::size_of::<opentelemetry::trace::Event>()),
755    );
756    for event in &span.events.events {
757        if let std::borrow::Cow::Owned(name) = &event.name {
758            estimator.add(name.capacity());
759        }
760        estimator.add(
761            event
762                .attributes
763                .capacity()
764                .saturating_mul(std::mem::size_of::<opentelemetry::KeyValue>()),
765        );
766        for attribute in &event.attributes {
767            add_attribute(&mut estimator, attribute);
768            if estimator.exceeded() {
769                return estimator.finish();
770            }
771        }
772    }
773
774    estimator.add(
775        span.links
776            .links
777            .capacity()
778            .saturating_mul(std::mem::size_of::<opentelemetry::trace::Link>()),
779    );
780    for link in &span.links.links {
781        add_trace_state(&mut estimator, &link.span_context);
782        estimator.add(
783            link.attributes
784                .capacity()
785                .saturating_mul(std::mem::size_of::<opentelemetry::KeyValue>()),
786        );
787        for attribute in &link.attributes {
788            add_attribute(&mut estimator, attribute);
789            if estimator.exceeded() {
790                return estimator.finish();
791            }
792        }
793    }
794
795    if let opentelemetry::trace::Status::Error {
796        description: std::borrow::Cow::Owned(description),
797    } = &span.status
798    {
799        estimator.add(description.capacity());
800    }
801    add_instrumentation_scope(&mut estimator, &span.instrumentation_scope);
802    estimator.finish()
803}
804
805#[cfg(feature = "otlp-logs")]
806const LOG_INLINE_ATTRIBUTE_CAPACITY: usize = 5;
807
808#[cfg(feature = "otlp-logs")]
809const MAX_LOG_VALUE_DEPTH: usize = 64;
810
811#[cfg(feature = "otlp-logs")]
812fn estimate_log_bytes(
813    record: &opentelemetry_sdk::logs::SdkLogRecord,
814    instrumentation: &opentelemetry::InstrumentationScope,
815    limit: usize,
816) -> usize {
817    let mut estimator = ByteEstimator::new(limit);
818    estimator.add(std::mem::size_of::<(
819        opentelemetry_sdk::logs::SdkLogRecord,
820        opentelemetry::InstrumentationScope,
821    )>());
822    estimator.add(std::mem::size_of::<
823        Box<(
824            opentelemetry_sdk::logs::SdkLogRecord,
825            opentelemetry::InstrumentationScope,
826        )>,
827    >());
828    if let Some(target) = record.target() {
829        // BatchLogProcessor clones the target. Charge its full visible payload even when the
830        // source happens to borrow static storage.
831        estimator.add_allocation_str(target.as_ref());
832    }
833    if let Some(body) = record.body() {
834        add_log_value(&mut estimator, body, 0);
835    }
836
837    let attribute_count = record.attributes_iter().count();
838    let overflow_items = attribute_count.saturating_sub(LOG_INLINE_ATTRIBUTE_CAPACITY);
839    if overflow_items > 0 {
840        let overflow_capacity = overflow_items
841            .checked_next_power_of_two()
842            .unwrap_or(usize::MAX)
843            .max(LOG_INLINE_ATTRIBUTE_CAPACITY);
844        estimator.add(overflow_capacity.saturating_mul(std::mem::size_of::<
845            Option<(opentelemetry::Key, opentelemetry::logs::AnyValue)>,
846        >()));
847    }
848    for (key, value) in record.attributes_iter() {
849        estimator.add_allocation_str(key.as_str());
850        add_log_value(&mut estimator, value, 0);
851        if estimator.exceeded() {
852            return estimator.finish();
853        }
854    }
855    add_instrumentation_scope(&mut estimator, instrumentation);
856    estimator.finish()
857}
858
859#[cfg(feature = "otlp-logs")]
860fn add_log_value(estimator: &mut ByteEstimator, value: &opentelemetry::logs::AnyValue, depth: usize) {
861    if depth >= MAX_LOG_VALUE_DEPTH {
862        estimator.total = estimator.limit.saturating_add(1);
863        return;
864    }
865    match value {
866        opentelemetry::logs::AnyValue::Int(_)
867        | opentelemetry::logs::AnyValue::Double(_)
868        | opentelemetry::logs::AnyValue::Boolean(_) => {}
869        opentelemetry::logs::AnyValue::String(value) => {
870            estimator.add_allocation_str(value.as_str());
871        }
872        opentelemetry::logs::AnyValue::Bytes(values) => estimator.add(values.capacity()),
873        opentelemetry::logs::AnyValue::ListAny(values) => {
874            estimator.add(
875                values
876                    .capacity()
877                    .saturating_mul(std::mem::size_of::<opentelemetry::logs::AnyValue>()),
878            );
879            for value in values.iter() {
880                add_log_value(estimator, value, depth + 1);
881                if estimator.exceeded() {
882                    break;
883                }
884            }
885        }
886        opentelemetry::logs::AnyValue::Map(values) => {
887            // HashMap::capacity is its element admission capacity, not its raw bucket count.
888            // Doubling the visible capacity covers control bytes and the current 7/8 load factor.
889            estimator.add(values.capacity().saturating_mul(2).saturating_mul(
890                std::mem::size_of::<(opentelemetry::Key, opentelemetry::logs::AnyValue)>().saturating_add(1),
891            ));
892            for (key, value) in values.iter() {
893                estimator.add_allocation_str(key.as_str());
894                add_log_value(estimator, value, depth + 1);
895                if estimator.exceeded() {
896                    break;
897                }
898            }
899        }
900        _ => estimator.total = estimator.limit.saturating_add(1),
901    }
902}
903
904#[cfg(feature = "otlp-traces")]
905#[derive(Debug)]
906struct QueueReleasingSpanExporter<E> {
907    exporter: E,
908    queue: Arc<TelemetryOutageQueue<()>>,
909}
910
911#[cfg(feature = "otlp-traces")]
912impl<E> opentelemetry_sdk::trace::SpanExporter for QueueReleasingSpanExporter<E>
913where
914    E: opentelemetry_sdk::trace::SpanExporter,
915{
916    async fn export(&self, batch: Vec<opentelemetry_sdk::trace::SpanData>) -> opentelemetry_sdk::error::OTelSdkResult {
917        let batch_items = batch.len();
918        let result = self.exporter.export(batch).await;
919        // The span batch is owned by the exporter future. Once that future completes, the batch
920        // has been consumed and its resource permits can be released without an overlap window.
921        release_exported(&self.queue, batch_items);
922        result
923    }
924
925    fn shutdown_with_timeout(&self, timeout: std::time::Duration) -> opentelemetry_sdk::error::OTelSdkResult {
926        self.exporter.shutdown_with_timeout(timeout)
927    }
928
929    fn force_flush(&self) -> opentelemetry_sdk::error::OTelSdkResult {
930        self.exporter.force_flush()
931    }
932
933    fn set_resource(&mut self, resource: &opentelemetry_sdk::Resource) {
934        self.exporter.set_resource(resource);
935    }
936}
937
938/// OTLP trace processor whose outage admission is bounded by both record count and estimated bytes.
939///
940/// The SDK remains the worker and lifecycle owner. This wrapper does not create a task or thread:
941/// it reserves one [`TelemetryOutageQueue`] permit before handing a span to the SDK and the wrapped
942/// exporter releases that permit only after the corresponding owned export batch has completed.
943#[cfg(feature = "otlp-traces")]
944#[derive(Debug)]
945pub(crate) struct OutageBoundedBatchSpanProcessor {
946    inner: opentelemetry_sdk::trace::BatchSpanProcessor,
947    queue: Arc<TelemetryOutageQueue<()>>,
948    limits: TelemetryQueueLimits,
949    drop_reporter: AdmissionDropReporter,
950}
951
952#[cfg(feature = "otlp-traces")]
953impl OutageBoundedBatchSpanProcessor {
954    pub(crate) fn new<E>(exporter: E) -> Self
955    where
956        E: opentelemetry_sdk::trace::SpanExporter + Send + 'static,
957    {
958        Self::new_with_limits(exporter, TelemetryQueueLimits::default())
959    }
960
961    fn new_with_limits<E>(exporter: E, limits: TelemetryQueueLimits) -> Self
962    where
963        E: opentelemetry_sdk::trace::SpanExporter + Send + 'static,
964    {
965        let queue = Arc::new(TelemetryOutageQueue::new(limits));
966        let exporter = QueueReleasingSpanExporter {
967            exporter,
968            queue: Arc::clone(&queue),
969        };
970        let inner = opentelemetry_sdk::trace::BatchSpanProcessor::builder(exporter)
971            .with_batch_config(trace_batch_config(limits))
972            .build();
973        Self {
974            inner,
975            queue,
976            limits,
977            drop_reporter: AdmissionDropReporter::new("traces"),
978        }
979    }
980
981    #[cfg(test)]
982    fn admission_queue(&self) -> Arc<TelemetryOutageQueue<()>> {
983        Arc::clone(&self.queue)
984    }
985}
986
987#[cfg(feature = "otlp-traces")]
988impl opentelemetry_sdk::trace::SpanProcessor for OutageBoundedBatchSpanProcessor {
989    fn on_start(&self, span: &mut opentelemetry_sdk::trace::Span, context: &opentelemetry::Context) {
990        self.inner.on_start(span, context);
991    }
992
993    fn on_end(&self, span: opentelemetry_sdk::trace::SpanData) {
994        if !span.span_context.is_sampled() {
995            return;
996        }
997        let estimated_bytes = estimate_span_bytes(&span, self.limits.max_record_bytes());
998        match self
999            .queue
1000            .try_enqueue_with((), estimated_bytes, || self.inner.on_end(span))
1001        {
1002            TelemetryEnqueueOutcome::Accepted => {}
1003            TelemetryEnqueueOutcome::Dropped(reason) => self.drop_reporter.record(reason, self.limits),
1004        }
1005    }
1006
1007    fn force_flush(&self) -> opentelemetry_sdk::error::OTelSdkResult {
1008        self.inner.force_flush()
1009    }
1010
1011    fn shutdown_with_timeout(&self, timeout: std::time::Duration) -> opentelemetry_sdk::error::OTelSdkResult {
1012        let deadline = shutdown_deadline(timeout);
1013        self.queue.begin_shutdown(deadline);
1014        let result = self
1015            .inner
1016            .shutdown_with_timeout(deadline.saturating_duration_since(Instant::now()));
1017        // A successful SDK shutdown may still discard records beyond its final batch. A timed-out
1018        // SDK worker can also retain records briefly, but admission is already closed. Releasing
1019        // the permits here cannot reopen the queue and keeps shutdown non-blocking.
1020        self.queue.drop_remaining();
1021        self.drop_reporter.report_shutdown(self.queue.snapshot());
1022        result
1023    }
1024
1025    fn set_resource(&mut self, resource: &opentelemetry_sdk::Resource) {
1026        self.inner.set_resource(resource);
1027    }
1028}
1029
1030#[cfg(feature = "otlp-logs")]
1031#[derive(Debug)]
1032struct QueueReleasingLogExporter<E> {
1033    exporter: E,
1034    queue: Arc<TelemetryOutageQueue<()>>,
1035    completed_batch_items: Arc<AtomicUsize>,
1036}
1037
1038#[cfg(feature = "otlp-logs")]
1039impl<E> QueueReleasingLogExporter<E> {
1040    fn release_completed(&self) {
1041        release_exported(&self.queue, self.completed_batch_items.swap(0, Ordering::AcqRel));
1042    }
1043}
1044
1045#[cfg(feature = "otlp-logs")]
1046impl<E> opentelemetry_sdk::logs::LogExporter for QueueReleasingLogExporter<E>
1047where
1048    E: opentelemetry_sdk::logs::LogExporter,
1049{
1050    async fn export(&self, batch: opentelemetry_sdk::logs::LogBatch<'_>) -> opentelemetry_sdk::error::OTelSdkResult {
1051        // A LogBatch borrows the SDK worker's storage. Permits from the previous export are
1052        // released now, after the SDK has cleared that storage, instead of immediately after its
1053        // exporter future resolves while the borrowed batch is still alive.
1054        self.release_completed();
1055        let batch_items = batch.iter().count();
1056        let result = self.exporter.export(batch).await;
1057        self.completed_batch_items.fetch_add(batch_items, Ordering::Release);
1058        result
1059    }
1060
1061    fn shutdown_with_timeout(&self, timeout: std::time::Duration) -> opentelemetry_sdk::error::OTelSdkResult {
1062        self.release_completed();
1063        self.exporter.shutdown_with_timeout(timeout)
1064    }
1065
1066    fn event_enabled(&self, level: opentelemetry::logs::Severity, target: &str, name: Option<&str>) -> bool {
1067        self.exporter.event_enabled(level, target, name)
1068    }
1069
1070    fn set_resource(&mut self, resource: &opentelemetry_sdk::Resource) {
1071        self.exporter.set_resource(resource);
1072    }
1073}
1074
1075/// OTLP log processor whose queued and in-flight payload shares one count-and-byte budget.
1076#[cfg(feature = "otlp-logs")]
1077#[derive(Debug)]
1078pub(crate) struct OutageBoundedBatchLogProcessor {
1079    inner: opentelemetry_sdk::logs::BatchLogProcessor,
1080    queue: Arc<TelemetryOutageQueue<()>>,
1081    completed_batch_items: Arc<AtomicUsize>,
1082    limits: TelemetryQueueLimits,
1083    drop_reporter: AdmissionDropReporter,
1084}
1085
1086#[cfg(feature = "otlp-logs")]
1087impl OutageBoundedBatchLogProcessor {
1088    pub(crate) fn new<E>(exporter: E) -> Self
1089    where
1090        E: opentelemetry_sdk::logs::LogExporter + Send + Sync + 'static,
1091    {
1092        Self::new_with_limits(exporter, TelemetryQueueLimits::default())
1093    }
1094
1095    fn new_with_limits<E>(exporter: E, limits: TelemetryQueueLimits) -> Self
1096    where
1097        E: opentelemetry_sdk::logs::LogExporter + Send + Sync + 'static,
1098    {
1099        let queue = Arc::new(TelemetryOutageQueue::new(limits));
1100        let completed_batch_items = Arc::new(AtomicUsize::new(0));
1101        let exporter = QueueReleasingLogExporter {
1102            exporter,
1103            queue: Arc::clone(&queue),
1104            completed_batch_items: Arc::clone(&completed_batch_items),
1105        };
1106        let inner = opentelemetry_sdk::logs::BatchLogProcessor::builder(exporter)
1107            .with_batch_config(log_batch_config(limits))
1108            .build();
1109        Self {
1110            inner,
1111            queue,
1112            completed_batch_items,
1113            limits,
1114            drop_reporter: AdmissionDropReporter::new("logs"),
1115        }
1116    }
1117
1118    fn release_completed(&self) {
1119        release_exported(&self.queue, self.completed_batch_items.swap(0, Ordering::AcqRel));
1120    }
1121
1122    #[cfg(test)]
1123    fn admission_queue(&self) -> Arc<TelemetryOutageQueue<()>> {
1124        Arc::clone(&self.queue)
1125    }
1126}
1127
1128#[cfg(feature = "otlp-logs")]
1129impl opentelemetry_sdk::logs::LogProcessor for OutageBoundedBatchLogProcessor {
1130    fn emit(
1131        &self,
1132        record: &mut opentelemetry_sdk::logs::SdkLogRecord,
1133        instrumentation: &opentelemetry::InstrumentationScope,
1134    ) {
1135        let estimated_bytes = estimate_log_bytes(record, instrumentation, self.limits.max_record_bytes());
1136        match self
1137            .queue
1138            .try_enqueue_with((), estimated_bytes, || self.inner.emit(record, instrumentation))
1139        {
1140            TelemetryEnqueueOutcome::Accepted => {}
1141            TelemetryEnqueueOutcome::Dropped(reason) => self.drop_reporter.record(reason, self.limits),
1142        }
1143    }
1144
1145    fn force_flush(&self) -> opentelemetry_sdk::error::OTelSdkResult {
1146        let result = self.inner.force_flush();
1147        // Once force_flush returns, the SDK has cleared its borrowed batch storage.
1148        self.release_completed();
1149        result
1150    }
1151
1152    fn shutdown_with_timeout(&self, timeout: std::time::Duration) -> opentelemetry_sdk::error::OTelSdkResult {
1153        let deadline = shutdown_deadline(timeout);
1154        self.queue.begin_shutdown(deadline);
1155        let result = self
1156            .inner
1157            .shutdown_with_timeout(deadline.saturating_duration_since(Instant::now()));
1158        self.release_completed();
1159        self.queue.drop_remaining();
1160        self.drop_reporter.report_shutdown(self.queue.snapshot());
1161        result
1162    }
1163
1164    fn event_enabled(&self, level: opentelemetry::logs::Severity, target: &str, name: Option<&str>) -> bool {
1165        self.inner.event_enabled(level, target, name)
1166    }
1167
1168    fn set_resource(&mut self, resource: &opentelemetry_sdk::Resource) {
1169        self.inner.set_resource(resource);
1170    }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175    #[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
1176    use std::sync::Arc;
1177    #[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
1178    use std::sync::Condvar;
1179    #[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
1180    use std::sync::Mutex;
1181    use std::time::Duration;
1182
1183    use super::*;
1184
1185    #[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
1186    #[derive(Debug, Default)]
1187    struct ExportGate {
1188        state: Mutex<ExportGateState>,
1189        changed: Condvar,
1190    }
1191
1192    #[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
1193    #[derive(Debug, Default)]
1194    struct ExportGateState {
1195        started_count: usize,
1196        released: bool,
1197    }
1198
1199    #[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
1200    impl ExportGate {
1201        fn block_export(&self) {
1202            let mut state = self.state.lock().expect("export gate should remain available");
1203            state.started_count += 1;
1204            self.changed.notify_all();
1205            while !state.released {
1206                state = self.changed.wait(state).expect("export gate should remain available");
1207            }
1208        }
1209
1210        fn wait_until_started(&self, timeout: Duration) -> bool {
1211            self.wait_until_started_count(1, timeout)
1212        }
1213
1214        fn wait_until_started_count(&self, expected: usize, timeout: Duration) -> bool {
1215            let state = self.state.lock().expect("export gate should remain available");
1216            if state.started_count >= expected {
1217                return true;
1218            }
1219            self.changed
1220                .wait_timeout_while(state, timeout, |state| state.started_count < expected)
1221                .expect("export gate should remain available")
1222                .0
1223                .started_count
1224                >= expected
1225        }
1226
1227        fn release(&self) {
1228            let mut state = self.state.lock().expect("export gate should remain available");
1229            state.released = true;
1230            self.changed.notify_all();
1231        }
1232    }
1233
1234    #[cfg(feature = "otlp-traces")]
1235    #[derive(Debug, Clone)]
1236    struct BlockingSpanExporter {
1237        gate: Arc<ExportGate>,
1238    }
1239
1240    #[cfg(feature = "otlp-traces")]
1241    impl opentelemetry_sdk::trace::SpanExporter for BlockingSpanExporter {
1242        async fn export(
1243            &self,
1244            _batch: Vec<opentelemetry_sdk::trace::SpanData>,
1245        ) -> opentelemetry_sdk::error::OTelSdkResult {
1246            self.gate.block_export();
1247            Ok(())
1248        }
1249    }
1250
1251    #[cfg(feature = "otlp-logs")]
1252    #[derive(Debug, Clone)]
1253    struct BlockingLogExporter {
1254        gate: Arc<ExportGate>,
1255    }
1256
1257    #[cfg(feature = "otlp-logs")]
1258    impl opentelemetry_sdk::logs::LogExporter for BlockingLogExporter {
1259        async fn export(
1260            &self,
1261            _batch: opentelemetry_sdk::logs::LogBatch<'_>,
1262        ) -> opentelemetry_sdk::error::OTelSdkResult {
1263            self.gate.block_export();
1264            Ok(())
1265        }
1266    }
1267
1268    fn queue() -> TelemetryOutageQueue<&'static str> {
1269        TelemetryOutageQueue::new(TelemetryQueueLimits::new(2, 8, 6).expect("test limits should be valid"))
1270    }
1271
1272    #[test]
1273    fn queue_is_count_and_byte_bounded_and_drops_are_measurable() {
1274        let queue = queue();
1275
1276        assert_eq!(queue.try_enqueue("one", 3), TelemetryEnqueueOutcome::Accepted);
1277        assert_eq!(queue.try_enqueue("two", 5), TelemetryEnqueueOutcome::Accepted);
1278        assert_eq!(
1279            queue.try_enqueue("count-full", 1),
1280            TelemetryEnqueueOutcome::Dropped(TelemetryDropReason::ItemLimit)
1281        );
1282
1283        let snapshot = queue.snapshot();
1284        assert_eq!(snapshot.queued_items, 2);
1285        assert_eq!(snapshot.queued_bytes, 8);
1286        assert_eq!(snapshot.accepted_items, 2);
1287        assert_eq!(snapshot.dropped_items, 1);
1288        assert_eq!(snapshot.dropped_bytes, 1);
1289    }
1290
1291    #[test]
1292    fn record_and_byte_limits_fail_closed() {
1293        let queue = queue();
1294
1295        assert_eq!(
1296            queue.try_enqueue("oversized", 7),
1297            TelemetryEnqueueOutcome::Dropped(TelemetryDropReason::RecordTooLarge)
1298        );
1299        assert_eq!(queue.try_enqueue("five", 5), TelemetryEnqueueOutcome::Accepted);
1300        assert_eq!(
1301            queue.try_enqueue("byte-full", 4),
1302            TelemetryEnqueueOutcome::Dropped(TelemetryDropReason::ByteLimit)
1303        );
1304        assert_eq!(queue.snapshot().queued_bytes, 5);
1305    }
1306
1307    #[test]
1308    fn data_plane_drops_instead_of_waiting_for_queue_state() {
1309        let queue = queue();
1310        let _state_operation = queue
1311            .admission_gate
1312            .lock()
1313            .expect("test should hold the queue admission gate");
1314
1315        assert_eq!(
1316            queue.try_enqueue("contended", 2),
1317            TelemetryEnqueueOutcome::Dropped(TelemetryDropReason::LockUnavailable)
1318        );
1319        assert_eq!(queue.dropped_items.load(Ordering::Relaxed), 1);
1320        assert_eq!(queue.dropped_bytes.load(Ordering::Relaxed), 2);
1321    }
1322
1323    #[cfg(any(feature = "otlp-traces", feature = "otlp-logs"))]
1324    #[test]
1325    fn sdk_forwarding_cannot_reorder_resource_permits() {
1326        let queue = Arc::new(queue());
1327        let (forward_entered_tx, forward_entered_rx) = std::sync::mpsc::channel();
1328        let (release_forward_tx, release_forward_rx) = std::sync::mpsc::channel();
1329        let first_queue = Arc::clone(&queue);
1330        let first = std::thread::spawn(move || {
1331            first_queue.try_enqueue_with("first", 6, || {
1332                forward_entered_tx
1333                    .send(())
1334                    .expect("test should observe the first SDK forwarding boundary");
1335                release_forward_rx
1336                    .recv()
1337                    .expect("test should release the first SDK forwarding boundary");
1338            })
1339        });
1340        forward_entered_rx
1341            .recv()
1342            .expect("first admission should reach its SDK forwarding boundary");
1343
1344        let mut second_forwarded = false;
1345        assert_eq!(
1346            queue.try_enqueue_with("second", 1, || second_forwarded = true),
1347            TelemetryEnqueueOutcome::Dropped(TelemetryDropReason::LockUnavailable)
1348        );
1349        assert!(!second_forwarded);
1350
1351        release_forward_tx
1352            .send(())
1353            .expect("test should release the first forwarding boundary");
1354        assert_eq!(
1355            first.join().expect("first admission thread should finish"),
1356            TelemetryEnqueueOutcome::Accepted
1357        );
1358        assert_eq!(queue.drain_batch(2, usize::MAX), vec!["first"]);
1359        assert_eq!(queue.snapshot().queued_bytes, 0);
1360    }
1361
1362    #[test]
1363    fn deferred_lookahead_retains_its_shared_resource_budget() {
1364        let queue = queue();
1365        assert_eq!(queue.try_enqueue("one", 3), TelemetryEnqueueOutcome::Accepted);
1366        assert_eq!(queue.try_enqueue("two", 5), TelemetryEnqueueOutcome::Accepted);
1367
1368        assert_eq!(queue.drain_batch(2, 4), vec!["one"]);
1369        let snapshot = queue.snapshot();
1370        assert_eq!(snapshot.queued_items, 1);
1371        assert_eq!(snapshot.queued_bytes, 5);
1372        assert_eq!(
1373            queue.try_enqueue("over-budget", 4),
1374            TelemetryEnqueueOutcome::Dropped(TelemetryDropReason::ByteLimit)
1375        );
1376    }
1377
1378    #[test]
1379    fn exporter_drains_fifo_batches_with_bounded_accounting() {
1380        let queue = queue();
1381        assert_eq!(queue.try_enqueue("one", 3), TelemetryEnqueueOutcome::Accepted);
1382        assert_eq!(queue.try_enqueue("two", 5), TelemetryEnqueueOutcome::Accepted);
1383
1384        assert_eq!(queue.drain_batch(2, 4), vec!["one"]);
1385        assert_eq!(queue.drain_batch(2, 8), vec!["two"]);
1386        let snapshot = queue.snapshot();
1387        assert_eq!(snapshot.queued_items, 0);
1388        assert_eq!(snapshot.queued_bytes, 0);
1389        assert_eq!(snapshot.drained_items, 2);
1390        assert_eq!(snapshot.drained_bytes, 8);
1391    }
1392
1393    #[test]
1394    fn absolute_shutdown_deadline_reports_collector_outage() {
1395        let queue = queue();
1396        assert_eq!(queue.try_enqueue("one", 3), TelemetryEnqueueOutcome::Accepted);
1397        assert_eq!(queue.try_enqueue("two", 5), TelemetryEnqueueOutcome::Accepted);
1398        let deadline = Instant::now() + Duration::from_millis(10);
1399        queue.begin_shutdown(deadline);
1400
1401        assert_eq!(
1402            queue.try_enqueue("closed", 1),
1403            TelemetryEnqueueOutcome::Dropped(TelemetryDropReason::Closed)
1404        );
1405        assert!(queue.poll_shutdown(deadline - Duration::from_millis(1)).is_none());
1406        let report = queue
1407            .poll_shutdown(deadline)
1408            .expect("absolute deadline should finish shutdown");
1409
1410        assert!(report.timed_out);
1411        assert_eq!(report.deadline_dropped_items, 2);
1412        assert_eq!(report.deadline_dropped_bytes, 8);
1413        assert_eq!(report.snapshot.queued_items, 0);
1414        assert_eq!(report.snapshot.dropped_items, 3);
1415        assert_eq!(report.snapshot.dropped_bytes, 9);
1416    }
1417
1418    #[test]
1419    fn drained_queue_finishes_before_deadline_without_timeout() {
1420        let queue = queue();
1421        assert_eq!(queue.try_enqueue("one", 3), TelemetryEnqueueOutcome::Accepted);
1422        let deadline = Instant::now() + Duration::from_secs(1);
1423        queue.begin_shutdown(deadline);
1424        assert_eq!(queue.drain_batch(1, 8), vec!["one"]);
1425
1426        let report = queue.poll_shutdown(Instant::now()).expect("empty queue should finish");
1427        assert!(!report.timed_out);
1428        assert_eq!(report.deadline_dropped_items, 0);
1429        assert_eq!(report.snapshot.drained_items, 1);
1430    }
1431
1432    #[test]
1433    fn invalid_limits_are_rejected() {
1434        assert_eq!(
1435            TelemetryQueueLimits::new(0, 8, 4),
1436            Err(TelemetryQueueConfigError::ZeroLimit("max_items"))
1437        );
1438        assert_eq!(
1439            TelemetryQueueLimits::new(1, 4, 5),
1440            Err(TelemetryQueueConfigError::RecordExceedsQueue {
1441                max_record_bytes: 5,
1442                max_queue_bytes: 4,
1443            })
1444        );
1445    }
1446
1447    #[cfg(feature = "otlp-traces")]
1448    #[test]
1449    fn sdk_trace_batch_config_matches_resource_budget_count_capacity() {
1450        let limits = TelemetryQueueLimits::new(7, 8_192, 4_096).expect("test limits should be valid");
1451        let rendered = format!("{:?}", trace_batch_config(limits));
1452
1453        assert!(rendered.contains("max_queue_size: 7"));
1454        assert!(rendered.contains("max_export_batch_size: 7"));
1455    }
1456
1457    #[cfg(feature = "otlp-logs")]
1458    #[test]
1459    fn sdk_log_batch_config_matches_resource_budget_count_capacity() {
1460        let limits = TelemetryQueueLimits::new(7, 8_192, 1_024).expect("test limits should be valid");
1461        let rendered = format!("{:?}", log_batch_config(limits));
1462
1463        assert!(rendered.contains("max_queue_size: 7"));
1464        assert!(rendered.contains("max_export_batch_size: 6"));
1465    }
1466
1467    #[cfg(feature = "otlp-traces")]
1468    #[test]
1469    fn trace_budget_covers_sdk_queue_and_in_flight_export_batch() {
1470        use opentelemetry_sdk::trace::SpanProcessor as _;
1471
1472        let gate = Arc::new(ExportGate::default());
1473        let limits = TelemetryQueueLimits::new(2, 8_192, 4_096).expect("test limits should be valid");
1474        let processor = OutageBoundedBatchSpanProcessor::new_with_limits(
1475            BlockingSpanExporter {
1476                gate: Arc::clone(&gate),
1477            },
1478            limits,
1479        );
1480        let queue = processor.admission_queue();
1481
1482        processor.on_end(sampled_span("one"));
1483        processor.on_end(sampled_span("two"));
1484        let export_started = gate.wait_until_started(Duration::from_secs(2));
1485        let in_flight = queue.snapshot();
1486        processor.on_end(sampled_span("rejected"));
1487        let after_rejection = queue.snapshot();
1488
1489        gate.release();
1490        let shutdown_result = processor.shutdown_with_timeout(Duration::from_secs(2));
1491        let final_snapshot = queue.snapshot();
1492
1493        assert!(export_started, "the SDK should begin exporting the full batch");
1494        assert_eq!(in_flight.queued_items, 2);
1495        assert!(in_flight.queued_bytes > 0);
1496        assert!(in_flight.queued_bytes <= limits.max_bytes());
1497        assert_eq!(after_rejection.queued_items, 2);
1498        assert_eq!(after_rejection.dropped_items, 1);
1499        assert!(shutdown_result.is_ok());
1500        assert_eq!(final_snapshot.queued_items, 0);
1501        assert_eq!(final_snapshot.drained_items, 2);
1502    }
1503
1504    #[cfg(feature = "otlp-traces")]
1505    #[test]
1506    fn oversized_span_is_rejected_before_the_sdk_queue() {
1507        use opentelemetry_sdk::trace::SpanProcessor as _;
1508
1509        let gate = Arc::new(ExportGate::default());
1510        let limits = TelemetryQueueLimits::new(2, 1_024, 512).expect("test limits should be valid");
1511        let processor = OutageBoundedBatchSpanProcessor::new_with_limits(
1512            BlockingSpanExporter {
1513                gate: Arc::clone(&gate),
1514            },
1515            limits,
1516        );
1517        let queue = processor.admission_queue();
1518
1519        processor.on_end(sampled_span(&"x".repeat(2_048)));
1520        let rejected = queue.snapshot();
1521        gate.release();
1522        let shutdown_result = processor.shutdown_with_timeout(Duration::from_secs(2));
1523
1524        assert_eq!(rejected.accepted_items, 0);
1525        assert_eq!(rejected.dropped_items, 1);
1526        assert_eq!(rejected.queued_items, 0);
1527        assert!(shutdown_result.is_ok());
1528    }
1529
1530    #[cfg(feature = "otlp-logs")]
1531    #[test]
1532    fn log_budget_retains_borrowed_batches_without_starving_follow_up_exports() {
1533        use opentelemetry::logs::LoggerProvider as _;
1534
1535        let gate = Arc::new(ExportGate::default());
1536        let limits = TelemetryQueueLimits::new(2, 8_192, 4_096).expect("test limits should be valid");
1537        let processor = OutageBoundedBatchLogProcessor::new_with_limits(
1538            BlockingLogExporter {
1539                gate: Arc::clone(&gate),
1540            },
1541            limits,
1542        );
1543        let queue = processor.admission_queue();
1544        let provider = opentelemetry_sdk::logs::SdkLoggerProvider::builder()
1545            .with_log_processor(processor)
1546            .build();
1547        let logger = provider.logger("outage-test");
1548
1549        emit_log(&logger, "one");
1550        emit_log(&logger, "two");
1551        let export_started = gate.wait_until_started(Duration::from_secs(2));
1552        let in_flight = queue.snapshot();
1553        emit_log(&logger, "rejected");
1554        let after_rejection = queue.snapshot();
1555
1556        gate.release();
1557        let follow_up_export_started = gate.wait_until_started_count(2, Duration::from_secs(2));
1558        emit_log(&logger, "continued");
1559        let after_continuation = queue.snapshot();
1560        let shutdown_result = provider.shutdown_with_timeout(Duration::from_secs(2));
1561        let final_snapshot = queue.snapshot();
1562
1563        assert!(export_started, "the SDK should begin exporting the full batch");
1564        assert_eq!(in_flight.queued_items, 2);
1565        assert!(in_flight.queued_bytes > 0);
1566        assert!(in_flight.queued_bytes <= limits.max_bytes());
1567        assert_eq!(after_rejection.queued_items, 2);
1568        assert_eq!(after_rejection.dropped_items, 1);
1569        assert!(
1570            follow_up_export_started,
1571            "reserved headroom should let a second batch start without an explicit flush"
1572        );
1573        assert_eq!(after_continuation.accepted_items, 3);
1574        assert_eq!(after_continuation.dropped_items, 1);
1575        assert!(after_continuation.queued_items <= limits.max_items());
1576        assert!(shutdown_result.is_ok());
1577        assert_eq!(final_snapshot.queued_items, 0);
1578        assert_eq!(final_snapshot.drained_items, 3);
1579    }
1580
1581    #[cfg(feature = "otlp-traces")]
1582    fn sampled_span(name: &str) -> opentelemetry_sdk::trace::SpanData {
1583        opentelemetry_sdk::trace::SpanData {
1584            span_context: opentelemetry::trace::SpanContext::new(
1585                opentelemetry::TraceId::from(1),
1586                opentelemetry::SpanId::from(1),
1587                opentelemetry::TraceFlags::SAMPLED,
1588                false,
1589                opentelemetry::trace::TraceState::default(),
1590            ),
1591            parent_span_id: opentelemetry::SpanId::INVALID,
1592            parent_span_is_remote: false,
1593            span_kind: opentelemetry::trace::SpanKind::Internal,
1594            name: name.to_owned().into(),
1595            start_time: std::time::SystemTime::now(),
1596            end_time: std::time::SystemTime::now(),
1597            attributes: Vec::new(),
1598            dropped_attributes_count: 0,
1599            events: opentelemetry_sdk::trace::SpanEvents::default(),
1600            links: opentelemetry_sdk::trace::SpanLinks::default(),
1601            status: opentelemetry::trace::Status::Unset,
1602            instrumentation_scope: opentelemetry::InstrumentationScope::builder("outage-test").build(),
1603        }
1604    }
1605
1606    #[cfg(feature = "otlp-logs")]
1607    fn emit_log<L>(logger: &L, body: &str)
1608    where
1609        L: opentelemetry::logs::Logger,
1610    {
1611        use opentelemetry::logs::LogRecord as _;
1612
1613        let mut record = logger.create_log_record();
1614        record.set_body(body.to_owned().into());
1615        logger.emit(record);
1616    }
1617}