1use 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#[derive(Clone)]
19pub struct SpanExportConfig {
20 pub endpoint: String,
23 pub interval: Duration,
25 pub service_name: String,
27 pub scope_name: String,
29 pub resource_attributes: Vec<(String, String)>,
31 pub timeout: Duration,
33 pub headers: Vec<(String, String)>,
35 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
98const MAX_BACKOFF: Duration = Duration::from_secs(300);
100
101const BASE_BACKOFF: Duration = Duration::from_secs(5);
103
104const 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
147pub 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#[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#[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
237pub 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}