Skip to main content

fast_telemetry_export/
spans.rs

1//! OTLP HTTP/protobuf span exporter.
2//!
3//! Exports completed spans from an [`fast_telemetry::span::SpanCollector`] to an
4//! OTLP-compatible collector via HTTP POST to `/v1/traces`.
5//! Larger payloads are gzip-compressed automatically, and failed exports retry
6//! with exponential backoff.
7
8use std::sync::Arc;
9use std::time::{Duration, SystemTime};
10
11use fast_telemetry::otlp::{build_resource, build_trace_export_request, pb};
12use fast_telemetry::span::SpanCollector;
13use prost::Message;
14use tokio::time::MissedTickBehavior;
15use tokio_util::sync::CancellationToken;
16
17/// Configuration for the OTLP span exporter.
18#[derive(Clone)]
19pub struct SpanExportConfig {
20    /// OTLP collector endpoint (scheme + host + port), e.g. `"http://localhost:4318"`.
21    /// The path `/v1/traces` is appended automatically.
22    pub endpoint: String,
23    /// Export interval (default: 10s).
24    pub interval: Duration,
25    /// `service.name` resource attribute.
26    pub service_name: String,
27    /// Instrumentation scope name (default: "fast-telemetry").
28    pub scope_name: String,
29    /// Additional resource attributes.
30    pub resource_attributes: Vec<(String, String)>,
31    /// Request timeout (default: 10s).
32    pub timeout: Duration,
33    /// Extra HTTP headers sent with every export request.
34    pub headers: Vec<(String, String)>,
35    /// Maximum number of spans per export batch (default: 512).
36    pub max_batch_size: usize,
37}
38
39impl Default for SpanExportConfig {
40    fn default() -> Self {
41        Self {
42            endpoint: "http://localhost:4318".to_string(),
43            interval: Duration::from_secs(10),
44            service_name: "unknown_service".to_string(),
45            scope_name: "fast-telemetry".to_string(),
46            resource_attributes: Vec::new(),
47            timeout: Duration::from_secs(10),
48            headers: Vec::new(),
49            max_batch_size: 512,
50        }
51    }
52}
53
54impl SpanExportConfig {
55    pub fn new(endpoint: impl Into<String>) -> Self {
56        Self {
57            endpoint: endpoint.into(),
58            ..Default::default()
59        }
60    }
61
62    pub fn with_interval(mut self, interval: Duration) -> Self {
63        self.interval = interval;
64        self
65    }
66
67    pub fn with_service_name(mut self, name: impl Into<String>) -> Self {
68        self.service_name = name.into();
69        self
70    }
71
72    pub fn with_scope_name(mut self, name: impl Into<String>) -> Self {
73        self.scope_name = name.into();
74        self
75    }
76
77    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
78        self.resource_attributes.push((key.into(), value.into()));
79        self
80    }
81
82    pub fn with_timeout(mut self, timeout: Duration) -> Self {
83        self.timeout = timeout;
84        self
85    }
86
87    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
88        self.headers.push((name.into(), value.into()));
89        self
90    }
91
92    pub fn with_max_batch_size(mut self, size: usize) -> Self {
93        self.max_batch_size = size;
94        self
95    }
96}
97
98/// Maximum backoff delay between retries after export failures.
99const MAX_BACKOFF: Duration = Duration::from_secs(300);
100
101/// Base backoff delay after the first failure.
102const BASE_BACKOFF: Duration = Duration::from_secs(5);
103
104/// Minimum payload size (bytes) before gzip compression is applied.
105const GZIP_THRESHOLD: usize = 1024;
106
107fn gzip_compress(data: &[u8], out: &mut Vec<u8>) -> bool {
108    if data.len() < GZIP_THRESHOLD {
109        return false;
110    }
111    use flate2::Compression;
112    use flate2::write::GzEncoder;
113    use std::io::Write;
114
115    out.clear();
116    let mut encoder = GzEncoder::new(out, Compression::fast());
117    let _ = encoder.write_all(data);
118    let _ = encoder.finish();
119    true
120}
121
122async fn send_otlp(
123    client: &reqwest::Client,
124    url: &str,
125    body: &[u8],
126    gzip_buf: &mut Vec<u8>,
127    extra_headers: &[(String, String)],
128) -> Result<reqwest::Response, reqwest::Error> {
129    let mut req = client
130        .post(url)
131        .header("Content-Type", "application/x-protobuf");
132
133    for (name, value) in extra_headers {
134        req = req.header(name, value);
135    }
136
137    if gzip_compress(body, gzip_buf) {
138        req.header("Content-Encoding", "gzip")
139            .body(gzip_buf.clone())
140            .send()
141            .await
142    } else {
143        req.body(body.to_vec()).send().await
144    }
145}
146
147/// Spawn the span exporter on a dedicated thread with its own single-threaded
148/// tokio runtime, matching the original design that avoids contending with the
149/// application's async runtime.
150pub fn spawn(
151    collector: Arc<SpanCollector>,
152    config: SpanExportConfig,
153    cancel: CancellationToken,
154) -> Option<std::thread::JoinHandle<()>> {
155    std::thread::Builder::new()
156        .name("span-exporter".to_string())
157        .spawn(move || {
158            let rt = tokio::runtime::Builder::new_current_thread()
159                .enable_all()
160                .build()
161                .expect("span exporter runtime");
162            rt.block_on(run(collector, config, cancel));
163        })
164        .ok()
165}
166
167/// Periodically flush this monoio worker's thread-local span buffer.
168///
169/// [`SpanCollector::drain_into`] can only drain spans that have already moved
170/// from a worker's thread-local buffer into the shared outbox. On long-lived
171/// monoio workers, low-volume spans may sit below the collector's automatic
172/// flush threshold for a long time, so run one of these tasks on each monoio
173/// worker that records spans.
174///
175/// The actual OTLP span exporter can remain [`spawn`], which runs on its own
176/// private Tokio runtime and drains the shared outboxes.
177#[cfg(feature = "monoio")]
178pub async fn run_local_flusher_monoio(
179    collector: Arc<SpanCollector>,
180    interval: Duration,
181    cancel: CancellationToken,
182) {
183    use monoio::time::MissedTickBehavior;
184
185    let mut interval = monoio::time::interval(interval);
186    interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
187    interval.tick().await;
188
189    loop {
190        monoio::select! {
191            _ = interval.tick() => {
192                collector.flush_local();
193            }
194            _ = cancel.cancelled() => {
195                collector.flush_local();
196                return;
197            }
198        }
199    }
200}
201
202/// Periodically flush this compio worker's thread-local span buffer.
203///
204/// This is the compio-native counterpart to `run_local_flusher_monoio`. Run
205/// one task on each compio worker that records spans; the OTLP span exporter can
206/// remain [`spawn`]. `cancel` may be any future that completes when the flusher
207/// should shut down.
208#[cfg(feature = "compio")]
209pub async fn run_local_flusher_compio(
210    collector: Arc<SpanCollector>,
211    interval: Duration,
212    cancel: impl std::future::Future<Output = ()>,
213) {
214    use futures_util::{FutureExt as _, select};
215
216    let mut interval = compio::time::interval(interval);
217    interval.tick().await;
218    let cancel = cancel.fuse();
219    let mut cancel = std::pin::pin!(cancel);
220
221    loop {
222        let tick = interval.tick();
223        let tick = std::pin::pin!(tick);
224
225        select! {
226            _ = tick.fuse() => {
227                collector.flush_local();
228            },
229            _ = cancel.as_mut() => {
230                collector.flush_local();
231                return;
232            }
233        }
234    }
235}
236
237/// Run the OTLP span export loop.
238///
239/// Drains completed spans from the collector in batches and sends them to
240/// `/v1/traces`. On cancellation, a final drain+export is performed.
241///
242/// # Example
243///
244/// ```ignore
245/// use std::sync::Arc;
246/// use std::time::Duration;
247///
248/// use fast_telemetry::SpanCollector;
249/// use fast_telemetry_export::spans::{SpanExportConfig, spawn};
250/// use tokio_util::sync::CancellationToken;
251///
252/// let collector = Arc::new(SpanCollector::new(4, 4096));
253/// let cancel = CancellationToken::new();
254/// let config = SpanExportConfig::new("http://otel-collector:4318")
255///     .with_service_name("myapp")
256///     .with_scope_name("proxy")
257///     .with_header("Authorization", "Bearer <token>")
258///     .with_timeout(Duration::from_secs(5))
259///     .with_max_batch_size(1024);
260///
261/// spawn(collector, config, cancel);
262/// ```
263pub async fn run(
264    collector: Arc<SpanCollector>,
265    config: SpanExportConfig,
266    cancel: CancellationToken,
267) {
268    let url = format!("{}/v1/traces", config.endpoint.trim_end_matches('/'));
269
270    log::info!(
271        "Starting OTLP span exporter, endpoint={url}, service={}",
272        config.service_name
273    );
274
275    let attr_refs: Vec<(&str, &str)> = config
276        .resource_attributes
277        .iter()
278        .map(|(k, v)| (k.as_str(), v.as_str()))
279        .collect();
280    let resource = build_resource(&config.service_name, &attr_refs);
281
282    let client = match reqwest::Client::builder().timeout(config.timeout).build() {
283        Ok(c) => c,
284        Err(e) => {
285            log::error!("Failed to build HTTP client for span exporter: {e}");
286            return;
287        }
288    };
289
290    let mut interval = tokio::time::interval(config.interval);
291    interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
292    interval.tick().await;
293
294    let mut consecutive_failures: u32 = 0;
295    let mut bufs = SpanExportBufs {
296        spans: Vec::with_capacity(config.max_batch_size),
297        encode: Vec::new(),
298        gzip: Vec::new(),
299    };
300
301    let ctx = SpanExportContext {
302        client: &client,
303        url: &url,
304        collector: &collector,
305        resource: &resource,
306        config: &config,
307    };
308
309    loop {
310        tokio::select! {
311            _ = interval.tick() => {}
312            _ = cancel.cancelled() => {
313                log::info!("Span exporter shutting down, performing final export");
314                export_once(&ctx, &mut bufs).await;
315                return;
316            }
317        }
318
319        if consecutive_failures > 0 {
320            let backoff = backoff_with_jitter(consecutive_failures);
321            log::debug!(
322                "Span export backing off {}ms (failures={consecutive_failures})",
323                backoff.as_millis()
324            );
325            tokio::select! {
326                _ = tokio::time::sleep(backoff) => {}
327                _ = cancel.cancelled() => {
328                    export_once(&ctx, &mut bufs).await;
329                    return;
330                }
331            }
332        }
333
334        bufs.spans.clear();
335        collector.drain_into(&mut bufs.spans);
336
337        if bufs.spans.is_empty() {
338            continue;
339        }
340
341        let total_drained = bufs.spans.len();
342        let dropped = total_drained.saturating_sub(config.max_batch_size);
343        bufs.spans.truncate(config.max_batch_size);
344        let span_count = bufs.spans.len();
345
346        if dropped > 0 {
347            log::debug!("Span export dropped {dropped} excess spans (exported {span_count})");
348        }
349
350        let otlp_spans: Vec<_> = bufs.spans.iter().map(|s| s.to_otlp()).collect();
351        let request = build_trace_export_request(&resource, &config.scope_name, otlp_spans);
352
353        bufs.encode.clear();
354        if let Err(e) = request.encode(&mut bufs.encode) {
355            log::warn!("Span protobuf encode failed: {e}");
356            continue;
357        }
358
359        let body_len = bufs.encode.len();
360
361        match send_otlp(&client, &url, &bufs.encode, &mut bufs.gzip, &config.headers).await {
362            Ok(resp) if resp.status().is_success() => {
363                consecutive_failures = 0;
364                log::debug!("Exported {span_count} spans ({body_len} bytes)");
365            }
366            Ok(resp) => {
367                consecutive_failures = consecutive_failures.saturating_add(1);
368                let status = resp.status();
369                let body = resp.text().await.unwrap_or_default();
370                log::warn!("Span export failed: status={status}, body={body}");
371            }
372            Err(e) => {
373                consecutive_failures = consecutive_failures.saturating_add(1);
374                log::warn!("Span export request failed: {e}");
375            }
376        }
377    }
378}
379
380struct SpanExportContext<'a> {
381    client: &'a reqwest::Client,
382    url: &'a str,
383    collector: &'a SpanCollector,
384    resource: &'a pb::Resource,
385    config: &'a SpanExportConfig,
386}
387
388struct SpanExportBufs {
389    spans: Vec<fast_telemetry::span::CompletedSpan>,
390    encode: Vec<u8>,
391    gzip: Vec<u8>,
392}
393
394async fn export_once(ctx: &SpanExportContext<'_>, bufs: &mut SpanExportBufs) {
395    bufs.spans.clear();
396    ctx.collector.drain_into(&mut bufs.spans);
397
398    if bufs.spans.is_empty() {
399        return;
400    }
401
402    let otlp_spans: Vec<_> = bufs.spans.iter().map(|s| s.to_otlp()).collect();
403    let request = build_trace_export_request(ctx.resource, &ctx.config.scope_name, otlp_spans);
404
405    bufs.encode.clear();
406    if let Err(e) = request.encode(&mut bufs.encode) {
407        log::warn!("Final span protobuf encode failed: {e}");
408        return;
409    }
410
411    match send_otlp(
412        ctx.client,
413        ctx.url,
414        &bufs.encode,
415        &mut bufs.gzip,
416        &ctx.config.headers,
417    )
418    .await
419    {
420        Ok(resp) if !resp.status().is_success() => {
421            let status = resp.status();
422            let body = resp.text().await.unwrap_or_default();
423            log::warn!("Final span export returned {status}: {body}");
424        }
425        Err(e) => log::warn!("Final span export failed: {e}"),
426        _ => {}
427    }
428}
429
430fn backoff_with_jitter(consecutive_failures: u32) -> Duration {
431    let exp = consecutive_failures.min(10);
432    let base_ms = BASE_BACKOFF.as_millis() as u64;
433    let backoff_ms = base_ms
434        .saturating_mul(1u64 << exp)
435        .min(MAX_BACKOFF.as_millis() as u64);
436
437    let nanos = SystemTime::now()
438        .duration_since(std::time::UNIX_EPOCH)
439        .unwrap_or_default()
440        .subsec_nanos();
441    let jitter_range = (backoff_ms / 4).max(1);
442    let jitter = (nanos as u64 % (jitter_range * 2 + 1)).saturating_sub(jitter_range);
443    let final_ms = backoff_ms.saturating_add(jitter);
444
445    Duration::from_millis(final_ms)
446}