otlp_stdout_span_exporter/
lib.rs

1//! A span exporter that writes OpenTelemetry spans to stdout in OTLP format.
2//!
3//! This crate provides an implementation of OpenTelemetry's [`SpanExporter`] that writes spans to stdout
4//! in OTLP (OpenTelemetry Protocol) format. It is particularly useful in serverless environments like
5//! AWS Lambda where writing to stdout is a common pattern for exporting telemetry data.
6//!
7//! # Features
8//!
9//! - Uses OTLP Protobuf serialization for efficient encoding
10//! - Applies GZIP compression with configurable levels
11//! - Detects service name from environment variables
12//! - Supports custom headers via environment variables
13//! - Supports writing to stdout or named pipe
14//! - Consistent JSON output format
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use opentelemetry::global;
20//! use opentelemetry::trace::Tracer;
21//! use opentelemetry_sdk::{trace::SdkTracerProvider, Resource};
22//! use otlp_stdout_span_exporter::OtlpStdoutSpanExporter;
23//!
24//! #[tokio::main]
25//! async fn main() {
26//!     // Create a new stdout exporter with default configuration (stdout output)
27//!     let exporter = OtlpStdoutSpanExporter::default();
28//!
29//!     // Or create one that writes to a named pipe
30//!     let pipe_exporter = OtlpStdoutSpanExporter::builder()
31//!         .pipe(true)  // Will write to /tmp/otlp-stdout-span-exporter.pipe
32//!         .build();
33//!
34//!     // Create a new tracer provider with batch export
35//!     let provider = SdkTracerProvider::builder()
36//!         .with_batch_exporter(exporter)
37//!         .build();
38//!
39//!     // Register the provider with the OpenTelemetry global API
40//!     global::set_tracer_provider(provider.clone());
41//!
42//!     // Create a tracer
43//!     let tracer = global::tracer("my-service");
44//!
45//!     // Create spans
46//!     tracer.in_span("parent-operation", |_cx| {
47//!         println!("Doing work...");
48//!         
49//!         // Create nested spans
50//!         tracer.in_span("child-operation", |_cx| {
51//!             println!("Doing more work...");
52//!         });
53//!     });
54//!     
55//!     // Flush the provider to ensure all spans are exported
56//!     if let Err(err) = provider.force_flush() {
57//!         println!("Error flushing provider: {:?}", err);
58//!     }
59//! }
60//! ```
61//!
62//! # Environment Variables
63//!
64//! The exporter respects the following environment variables:
65//!
66//! - `OTEL_SERVICE_NAME`: Service name to use in output
67//! - `AWS_LAMBDA_FUNCTION_NAME`: Fallback service name (if `OTEL_SERVICE_NAME` not set)
68//! - `OTEL_EXPORTER_OTLP_HEADERS`: Global headers for OTLP export
69//! - `OTEL_EXPORTER_OTLP_TRACES_HEADERS`: Trace-specific headers (takes precedence if conflicting with `OTEL_EXPORTER_OTLP_HEADERS`)
70//! - `OTLP_STDOUT_SPAN_EXPORTER_COMPRESSION_LEVEL`: GZIP compression level (0-9, default: 6)
71//! - `OTLP_STDOUT_SPAN_EXPORTER_OUTPUT_TYPE`: Output type ("pipe" or "stdout", default: "stdout")
72//!
73//! # Configuration Precedence
74//!
75//! All configuration values follow this strict precedence order:
76//!
77//! 1. Environment variables (highest precedence)
78//! 2. Constructor parameters
79//! 3. Default values (lowest precedence)
80//!
81//! For example, when determining the output type:
82//!
83//! ```rust
84//! use otlp_stdout_span_exporter::OtlpStdoutSpanExporter;
85//!
86//! // This will use OTLP_STDOUT_SPAN_EXPORTER_OUTPUT_TYPE if set,
87//! // otherwise it will write to a named pipe as specified in the constructor
88//! let pipe_exporter = OtlpStdoutSpanExporter::builder()
89//!     .pipe(true)
90//!     .build();
91//!
92//! // This will use the environment variable if set, or default to stdout
93//! let default_exporter = OtlpStdoutSpanExporter::default();
94//! ```
95//!
96//! # Output Format
97//!
98//! The exporter writes each batch of spans as a JSON object to stdout or the named pipe:
99//!
100//! ```json
101//! {
102//!   "__otel_otlp_stdout": "0.1.0",
103//!   "source": "my-service",
104//!   "endpoint": "http://localhost:4318/v1/traces",
105//!   "method": "POST",
106//!   "content-type": "application/x-protobuf",
107//!   "content-encoding": "gzip",
108//!   "headers": {
109//!     "api-key": "secret123",
110//!     "custom-header": "value"
111//!   },
112//!   "payload": "<base64-encoded-gzipped-protobuf>",
113//!   "base64": true
114//! }
115//! ```
116
117use async_trait::async_trait;
118use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
119use bon::bon;
120use flate2::{write::GzEncoder, Compression};
121use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
122use opentelemetry_proto::transform::common::tonic::ResourceAttributesWithSchema;
123use opentelemetry_proto::transform::trace::tonic::group_spans_by_resource_and_scope;
124use opentelemetry_sdk::error::OTelSdkResult;
125use opentelemetry_sdk::resource::Resource;
126use opentelemetry_sdk::{
127    error::OTelSdkError,
128    trace::{SpanData, SpanExporter},
129};
130use prost::Message;
131use serde::{Deserialize, Serialize};
132use std::{
133    collections::HashMap,
134    env,
135    fmt::{self, Display},
136    fs::OpenOptions,
137    io::{self, Write},
138    path::PathBuf,
139    result::Result,
140    str::FromStr,
141    sync::{Arc, Mutex},
142};
143
144mod constants;
145use constants::{defaults, env_vars};
146
147// Make the constants module and its sub-modules publicly available
148pub mod consts {
149    //! Constants used by the exporter.
150    //!
151    //! This module provides constants for environment variables,
152    //! default values, and resource attributes.
153
154    pub use crate::constants::defaults;
155    pub use crate::constants::env_vars;
156    pub use crate::constants::resource_attributes;
157}
158
159const VERSION: &str = env!("CARGO_PKG_VERSION");
160
161/// Log level for the exported spans
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
163pub enum LogLevel {
164    /// Debug level
165    Debug,
166    /// Info level (default)
167    #[default]
168    Info,
169    /// Warning level
170    Warn,
171    /// Error level (least verbose)
172    Error,
173}
174
175impl FromStr for LogLevel {
176    type Err = String;
177
178    fn from_str(s: &str) -> Result<Self, Self::Err> {
179        match s.to_lowercase().as_str() {
180            "debug" => Ok(LogLevel::Debug),
181            "info" => Ok(LogLevel::Info),
182            "warn" | "warning" => Ok(LogLevel::Warn),
183            "error" => Ok(LogLevel::Error),
184            _ => Err(format!("Invalid log level: {}", s)),
185        }
186    }
187}
188
189impl Display for LogLevel {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        match self {
192            LogLevel::Debug => write!(f, "DEBUG"),
193            LogLevel::Info => write!(f, "INFO"),
194            LogLevel::Warn => write!(f, "WARN"),
195            LogLevel::Error => write!(f, "ERROR"),
196        }
197    }
198}
199
200/// Trait for output handling
201///
202/// This trait defines the interface for writing output lines. It is implemented
203/// by both the standard output handler and named pipe output handlers.
204pub trait Output: Send + Sync + std::fmt::Debug + std::any::Any {
205    /// Writes a single line of output
206    ///
207    /// # Arguments
208    ///
209    /// * `line` - The line to write
210    ///
211    /// # Returns
212    ///
213    /// Returns `Ok(())` if the write was successful, or a `TraceError` if it failed
214    fn write_line(&self, line: &str) -> Result<(), OTelSdkError>;
215
216    /// Checks if the output target is a named pipe.
217    ///
218    /// Returns `true` if the output is configured to write to a named pipe,
219    /// `false` otherwise (e.g., stdout).
220    fn is_pipe(&self) -> bool;
221
222    /// Performs a "touch" operation on the output target, primarily for named pipes.
223    ///
224    /// For named pipes, this should open and immediately close the pipe for writing
225    /// to generate an EOF signal for readers.
226    /// For other output types (like stdout), this is typically a no-op.
227    ///
228    /// # Returns
229    ///
230    /// Returns `Ok(())` if the touch was successful or is a no-op,
231    /// or a `TraceError` if opening/closing the pipe failed.
232    fn touch_pipe(&self) -> Result<(), OTelSdkError> {
233        // Default implementation is a no-op
234        Ok(())
235    }
236}
237
238/// Standard output implementation that writes to stdout
239#[derive(Debug, Default)]
240struct StdOutput;
241
242impl Output for StdOutput {
243    fn write_line(&self, line: &str) -> Result<(), OTelSdkError> {
244        // Get a locked stdout handle once
245        let stdout = io::stdout();
246        let mut handle = stdout.lock();
247
248        // Write the line and a newline in one operation
249        writeln!(handle, "{}", line).map_err(|e| OTelSdkError::InternalFailure(e.to_string()))?;
250
251        Ok(())
252    }
253
254    fn is_pipe(&self) -> bool {
255        false // Stdout is not a pipe
256    }
257}
258
259/// Output implementation that writes to a named pipe
260#[derive(Debug)]
261struct NamedPipeOutput {
262    path: PathBuf,
263}
264
265impl NamedPipeOutput {
266    fn new() -> Result<Self, OTelSdkError> {
267        let path_buf = PathBuf::from(defaults::PIPE_PATH);
268        if !path_buf.exists() {
269            log::warn!("Named pipe does not exist: {}", defaults::PIPE_PATH);
270            // On Unix systems we could create it with mkfifo but this would need cfg platform specifics
271        }
272
273        Ok(Self { path: path_buf })
274    }
275}
276
277impl Output for NamedPipeOutput {
278    fn write_line(&self, line: &str) -> Result<(), OTelSdkError> {
279        // Open the pipe for writing
280        let mut file = OpenOptions::new()
281            .write(true)
282            .open(&self.path)
283            .map_err(|e| OTelSdkError::InternalFailure(format!("Failed to open pipe: {}", e)))?;
284
285        // Write line with newline
286        writeln!(file, "{}", line).map_err(|e| {
287            OTelSdkError::InternalFailure(format!("Failed to write to pipe: {}", e))
288        })?;
289
290        Ok(())
291    }
292
293    fn is_pipe(&self) -> bool {
294        true // This implementation is for named pipes
295    }
296
297    fn touch_pipe(&self) -> Result<(), OTelSdkError> {
298        // Open the pipe for writing and immediately close it (RAII handles close)
299        let _file = OpenOptions::new()
300            .write(true)
301            .open(&self.path)
302            .map_err(|e| OTelSdkError::InternalFailure(format!("Failed to touch pipe: {}", e)))?;
303        Ok(())
304    }
305}
306
307/// An Output implementation that writes lines to an internal buffer.
308#[derive(Clone, Default)]
309pub struct BufferOutput {
310    buffer: Arc<Mutex<Vec<String>>>,
311}
312
313impl BufferOutput {
314    /// Creates a new, empty BufferOutput.
315    pub fn new() -> Self {
316        Self::default()
317    }
318
319    /// Retrieves all lines currently in the buffer, clearing the buffer afterwards.
320    pub fn take_lines(&self) -> Result<Vec<String>, OTelSdkError> {
321        let mut guard = self.buffer.lock().map_err(|e| {
322            OTelSdkError::InternalFailure(format!(
323                "Failed to lock buffer mutex for take_lines: {}",
324                e
325            ))
326        })?;
327        Ok(std::mem::take(&mut *guard)) // Efficiently swaps the Vec with an empty one and wraps in Ok
328    }
329}
330
331impl Output for BufferOutput {
332    fn write_line(&self, line: &str) -> Result<(), OTelSdkError> {
333        let mut guard = self.buffer.lock().map_err(|e| {
334            OTelSdkError::InternalFailure(format!(
335                "Failed to lock buffer mutex for write_line: {}",
336                e
337            ))
338        })?;
339        guard.push(line.to_string());
340        Ok(())
341    }
342
343    fn is_pipe(&self) -> bool {
344        false // BufferOutput is not a pipe
345    }
346}
347
348// Need to implement Debug manually as Mutex doesn't implement Debug
349impl fmt::Debug for BufferOutput {
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        // Avoid locking the mutex in debug formatting if possible, or provide minimal info
352        f.debug_struct("BufferOutput").finish_non_exhaustive()
353    }
354}
355
356/// Helper function to create output based on type
357fn create_output(use_pipe: bool) -> Arc<dyn Output> {
358    if use_pipe {
359        match NamedPipeOutput::new() {
360            Ok(output) => Arc::new(output),
361            Err(e) => {
362                log::warn!(
363                    "Failed to create named pipe output: {}, falling back to stdout",
364                    e
365                );
366                Arc::new(StdOutput)
367            }
368        }
369    } else {
370        Arc::new(StdOutput)
371    }
372}
373
374/// Output format for the OTLP stdout exporter
375///
376/// This struct defines the JSON structure that will be written to stdout
377/// for each batch of spans.
378#[derive(Debug, Serialize, Deserialize)]
379pub struct ExporterOutput {
380    /// Version identifier for the output format
381    #[serde(rename = "__otel_otlp_stdout")]
382    pub version: String,
383    /// Service name that generated the spans
384    pub source: String,
385    /// OTLP endpoint (always http://localhost:4318/v1/traces)
386    pub endpoint: String,
387    /// HTTP method (always POST)
388    pub method: String,
389    /// Content type (always application/x-protobuf)
390    #[serde(rename = "content-type")]
391    pub content_type: String,
392    /// Content encoding (always gzip)
393    #[serde(rename = "content-encoding")]
394    pub content_encoding: String,
395    /// Custom headers from environment variables
396    #[serde(skip_serializing_if = "ExporterOutput::is_headers_empty")]
397    pub headers: Option<HashMap<String, String>>,
398    /// Base64-encoded, gzipped, protobuf-serialized span data
399    pub payload: String,
400    /// Whether the payload is base64 encoded (always true)
401    pub base64: bool,
402    /// Log level for filtering (optional)
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub level: Option<String>,
405}
406
407impl ExporterOutput {
408    /// Helper function for serde to skip serializing empty headers
409    fn is_headers_empty(headers: &Option<HashMap<String, String>>) -> bool {
410        headers.as_ref().map_or(true, |h| h.is_empty())
411    }
412}
413
414/// A span exporter that writes spans to stdout in OTLP format
415///
416/// This exporter implements the OpenTelemetry [`SpanExporter`] trait and writes spans
417/// to stdout in OTLP format with Protobuf serialization and GZIP compression.
418///
419/// # Features
420///
421/// - Configurable GZIP compression level (0-9)
422/// - Environment variable support for service name and headers
423/// - Efficient batching of spans
424/// - Base64 encoding of compressed data
425///
426/// # Example
427///
428/// ```rust,no_run
429/// use opentelemetry_sdk::runtime;
430/// use otlp_stdout_span_exporter::OtlpStdoutSpanExporter;
431///
432/// // Create an exporter with maximum compression
433/// let exporter = OtlpStdoutSpanExporter::builder()
434///     .compression_level(9)
435///     .build();
436/// ```
437#[derive(Debug)]
438pub struct OtlpStdoutSpanExporter {
439    /// GZIP compression level (0-9)
440    compression_level: u8,
441    /// Optional resource to be included with all spans
442    resource: Option<Resource>,
443    // Optional headers
444    headers: Option<HashMap<String, String>>,
445    /// Output implementation (stdout or named pipe)
446    output: Arc<dyn Output>,
447    /// Optional log level for the exported spans
448    level: Option<LogLevel>,
449}
450
451impl Default for OtlpStdoutSpanExporter {
452    fn default() -> Self {
453        Self::builder().build()
454    }
455}
456#[bon]
457impl OtlpStdoutSpanExporter {
458    /// Create a new `OtlpStdoutSpanExporter` with default configuration.
459    ///
460    /// This uses a GZIP compression level of 6 unless overridden by an environment variable.
461    ///
462    /// # Output Type
463    ///
464    /// The output type is determined in the following order:
465    ///
466    /// 1. The `OTLP_STDOUT_SPAN_EXPORTER_OUTPUT_TYPE` environment variable if set ("pipe" or "stdout")
467    /// 2. Constructor parameter (pipe)
468    /// 3. Default (stdout)
469    ///
470    /// # Example
471    ///
472    /// ```
473    /// use otlp_stdout_span_exporter::OtlpStdoutSpanExporter;
474    ///
475    /// let exporter = OtlpStdoutSpanExporter::default();
476    /// ```
477    #[builder]
478    pub fn new(
479        compression_level: Option<u8>,
480        resource: Option<Resource>,
481        headers: Option<HashMap<String, String>>,
482        output: Option<Arc<dyn Output>>,
483        level: Option<LogLevel>,
484        pipe: Option<bool>,
485    ) -> Self {
486        // Set gzip_level with proper precedence (env var > constructor param > default)
487        let compression_level = match env::var(env_vars::COMPRESSION_LEVEL) {
488            Ok(value) => match value.parse::<u8>() {
489                Ok(level) if level <= 9 => level,
490                Ok(level) => {
491                    log::warn!(
492                        "Invalid value in {}: {} (must be 0-9), using fallback",
493                        env_vars::COMPRESSION_LEVEL,
494                        level
495                    );
496                    compression_level.unwrap_or(defaults::COMPRESSION_LEVEL)
497                }
498                Err(_) => {
499                    log::warn!(
500                        "Failed to parse {}: {}, using fallback",
501                        env_vars::COMPRESSION_LEVEL,
502                        value
503                    );
504                    compression_level.unwrap_or(defaults::COMPRESSION_LEVEL)
505                }
506            },
507            Err(_) => {
508                // No environment variable, use parameter or default
509                compression_level.unwrap_or(defaults::COMPRESSION_LEVEL)
510            }
511        };
512
513        // Combine constructor headers with environment headers, giving priority to env vars
514        let headers = match headers {
515            Some(constructor_headers) => {
516                if let Some(env_headers) = Self::parse_headers() {
517                    // Merge, with env headers taking precedence
518                    let mut merged = constructor_headers;
519                    merged.extend(env_headers);
520                    Some(merged)
521                } else {
522                    // No env headers, use constructor headers
523                    Some(constructor_headers)
524                }
525            }
526            None => Self::parse_headers(), // Use env headers only
527        };
528
529        // Set log level with proper precedence (env var > constructor param > default)
530        let level = match env::var(env_vars::LOG_LEVEL) {
531            Ok(value) => match LogLevel::from_str(&value) {
532                Ok(log_level) => Some(log_level),
533                Err(e) => {
534                    log::warn!(
535                        "Invalid log level in {}: {}, using fallback",
536                        env_vars::LOG_LEVEL,
537                        e
538                    );
539                    level
540                }
541            },
542            Err(_) => {
543                // No environment variable, use parameter
544                level
545            }
546        };
547
548        // Determine output type with proper precedence (env var > constructor > default)
549        let use_pipe = match env::var(env_vars::OUTPUT_TYPE) {
550            Ok(value) => value.to_lowercase() == "pipe",
551            Err(_) => pipe.unwrap_or(false),
552        };
553
554        // Create output implementation
555        let output = output.unwrap_or_else(|| create_output(use_pipe));
556
557        Self {
558            compression_level,
559            resource,
560            headers,
561            output,
562            level,
563        }
564    }
565
566    /// Get the service name from environment variables.
567    ///
568    /// The service name is determined in the following order:
569    ///
570    /// 1. OTEL_SERVICE_NAME
571    /// 2. AWS_LAMBDA_FUNCTION_NAME
572    /// 3. "unknown-service" (fallback)
573    fn get_service_name() -> String {
574        env::var(env_vars::SERVICE_NAME)
575            .or_else(|_| env::var(env_vars::AWS_LAMBDA_FUNCTION_NAME))
576            .unwrap_or_else(|_| defaults::SERVICE_NAME.to_string())
577    }
578
579    #[cfg(test)]
580    fn with_test_output() -> (Self, Arc<TestOutput>) {
581        let output = Arc::new(TestOutput::new());
582
583        // Use the standard builder() method and explicitly set the output
584        let exporter = Self::builder().output(output.clone()).build();
585
586        (exporter, output)
587    }
588
589    /// Parse headers from environment variables
590    ///
591    /// This function reads headers from both global and trace-specific
592    /// environment variables, with trace-specific headers taking precedence.
593    fn parse_headers() -> Option<HashMap<String, String>> {
594        // Function to get and parse headers from an env var
595        let get_headers = |var_name: &str| -> Option<HashMap<String, String>> {
596            env::var(var_name).ok().map(|header_str| {
597                let mut map = HashMap::new();
598                Self::parse_header_string(&header_str, &mut map);
599                map
600            })
601        };
602
603        // Try to get headers from both env vars
604        let global_headers = get_headers("OTEL_EXPORTER_OTLP_HEADERS");
605        let trace_headers = get_headers("OTEL_EXPORTER_OTLP_TRACES_HEADERS");
606
607        // If no headers were found in either env var, return None
608        if global_headers.is_none() && trace_headers.is_none() {
609            return None;
610        }
611
612        // Create a merged map, with trace headers taking precedence
613        let mut result = HashMap::new();
614
615        // Add global headers first (if any)
616        if let Some(headers) = global_headers {
617            result.extend(headers);
618        }
619
620        // Add trace-specific headers (if any) - these will override any duplicates
621        if let Some(headers) = trace_headers {
622            result.extend(headers);
623        }
624
625        // Return None for empty map, otherwise Some
626        if result.is_empty() {
627            None
628        } else {
629            Some(result)
630        }
631    }
632
633    /// Parse a header string in the format key1=value1,key2=value2
634    ///
635    /// # Arguments
636    ///
637    /// * `header_str` - The header string to parse
638    /// * `headers` - The map to store parsed headers in
639    fn parse_header_string(header_str: &str, headers: &mut HashMap<String, String>) {
640        for pair in header_str.split(',') {
641            if let Some((key, value)) = pair.split_once('=') {
642                let key = key.trim().to_lowercase();
643                // Skip content-type and content-encoding as they are fixed
644                if key != "content-type" && key != "content-encoding" {
645                    headers.insert(key, value.trim().to_string());
646                }
647            }
648        }
649    }
650}
651
652#[async_trait]
653impl SpanExporter for OtlpStdoutSpanExporter {
654    /// Export spans to stdout in OTLP format
655    ///
656    /// This function:
657    /// 1. Converts spans to OTLP format
658    /// 2. Serializes them to protobuf
659    /// 3. Compresses the data with GZIP
660    /// 4. Base64 encodes the result
661    /// 5. Writes a JSON object to stdout
662    ///
663    /// # Arguments
664    ///
665    /// * `batch` - A vector of spans to export
666    ///
667    /// # Returns
668    ///
669    /// Returns a resolved future with `Ok(())` if the export was successful, or a `TraceError` if it failed
670    fn export(
671        &self,
672        batch: Vec<SpanData>,
673    ) -> impl std::future::Future<Output = OTelSdkResult> + Send {
674        // Check for empty batch and pipe output configuration
675        if batch.is_empty() && self.output.is_pipe() {
676            // Perform the "pipe touch" operation: open for writing and immediately close.
677            let touch_result = self.output.touch_pipe();
678            return Box::pin(std::future::ready(touch_result));
679        }
680
681        // Original export logic for non-empty batches or stdout output
682        let result = (|| {
683            // Convert spans to OTLP format
684            let resource = self
685                .resource
686                .clone()
687                .unwrap_or_else(|| opentelemetry_sdk::Resource::builder_empty().build());
688            let resource_attrs = ResourceAttributesWithSchema::from(&resource);
689            let resource_spans = group_spans_by_resource_and_scope(batch, &resource_attrs);
690            let request = ExportTraceServiceRequest { resource_spans };
691
692            // Serialize to protobuf
693            let proto_bytes = request.encode_to_vec();
694
695            // Compress with GZIP
696            let mut encoder =
697                GzEncoder::new(Vec::new(), Compression::new(self.compression_level as u32));
698            encoder
699                .write_all(&proto_bytes)
700                .map_err(|e| OTelSdkError::InternalFailure(e.to_string()))?;
701            let compressed_bytes = encoder
702                .finish()
703                .map_err(|e| OTelSdkError::InternalFailure(e.to_string()))?;
704
705            // Base64 encode
706            let payload = base64_engine.encode(compressed_bytes);
707
708            // Prepare the output
709            let output_data = ExporterOutput {
710                version: VERSION.to_string(),
711                source: Self::get_service_name(),
712                endpoint: defaults::ENDPOINT.to_string(),
713                method: "POST".to_string(),
714                content_type: "application/x-protobuf".to_string(),
715                content_encoding: "gzip".to_string(),
716                headers: self.headers.clone(),
717                payload,
718                base64: true,
719                level: self.level.map(|l| l.to_string()),
720            };
721
722            // Write using the output implementation
723            self.output.write_line(
724                &serde_json::to_string(&output_data)
725                    .map_err(|e| OTelSdkError::InternalFailure(e.to_string()))?,
726            )?;
727
728            Ok(())
729        })();
730
731        // Return a resolved future with the result
732        Box::pin(std::future::ready(result))
733    }
734
735    /// Shuts down the exporter
736    ///
737    /// This is a no-op for stdout export as no cleanup is needed.
738    ///
739    /// # Returns
740    ///
741    /// Returns `Ok(())` as there is nothing to clean up.
742    fn shutdown(&mut self) -> Result<(), OTelSdkError> {
743        Ok(())
744    }
745
746    /// Force flushes any pending spans
747    ///
748    /// This is a no-op for stdout export as spans are written immediately.
749    ///
750    /// # Returns
751    ///
752    /// Returns `Ok(())` as there is nothing to flush.
753    fn force_flush(&mut self) -> Result<(), OTelSdkError> {
754        Ok(())
755    }
756
757    /// Sets the resource for this exporter.
758    ///
759    /// This method stores a clone of the provided resource to be used when exporting spans.
760    /// The resource represents the entity producing telemetry and will be included in the
761    /// exported trace data.
762    ///
763    /// # Arguments
764    ///
765    /// * `resource` - The resource to associate with this exporter
766    fn set_resource(&mut self, resource: &opentelemetry_sdk::Resource) {
767        self.resource = Some(<opentelemetry_sdk::Resource as Into<Resource>>::into(
768            resource.clone(),
769        ));
770    }
771}
772
773#[cfg(doctest)]
774#[macro_use]
775extern crate doc_comment;
776
777#[cfg(doctest)]
778use doc_comment::doctest;
779
780#[cfg(doctest)]
781doctest!("../README.md", readme);
782
783/// Test output implementation that captures to a buffer
784#[cfg(test)]
785#[derive(Debug, Default)]
786struct TestOutput {
787    buffer: Arc<Mutex<Vec<String>>>,
788}
789
790#[cfg(test)]
791impl TestOutput {
792    fn new() -> Self {
793        Self {
794            buffer: Arc::new(Mutex::new(Vec::new())),
795        }
796    }
797
798    fn get_output(&self) -> Vec<String> {
799        self.buffer.lock().unwrap().clone()
800    }
801}
802
803#[cfg(test)]
804impl Output for TestOutput {
805    fn write_line(&self, line: &str) -> Result<(), OTelSdkError> {
806        self.buffer.lock().unwrap().push(line.to_string());
807        Ok(())
808    }
809
810    fn is_pipe(&self) -> bool {
811        false // TestOutput is not a pipe
812    }
813
814    // touch_pipe uses the default no-op implementation
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820    use opentelemetry::{
821        trace::{SpanContext, SpanId, SpanKind, Status, TraceFlags, TraceId, TraceState},
822        InstrumentationScope, KeyValue,
823    };
824    use opentelemetry_sdk::trace::{SpanData, SpanEvents, SpanLinks};
825    use serde_json::Value;
826    use serial_test::serial;
827    use std::time::SystemTime;
828
829    fn create_test_span() -> SpanData {
830        let trace_id_bytes = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 42];
831        let span_id_bytes = [0, 0, 0, 0, 0, 0, 0, 123];
832        let parent_id_bytes = [0, 0, 0, 0, 0, 0, 0, 42];
833
834        let span_context = SpanContext::new(
835            TraceId::from_bytes(trace_id_bytes),
836            SpanId::from_bytes(span_id_bytes),
837            TraceFlags::default(),
838            false,
839            TraceState::default(),
840        );
841
842        SpanData {
843            span_context,
844            parent_span_id: SpanId::from_bytes(parent_id_bytes),
845            span_kind: SpanKind::Client,
846            name: "test-span".into(),
847            start_time: SystemTime::UNIX_EPOCH,
848            end_time: SystemTime::UNIX_EPOCH,
849            attributes: vec![KeyValue::new("test.key", "test-value")],
850            dropped_attributes_count: 0,
851            events: SpanEvents::default(),
852            links: SpanLinks::default(),
853            status: Status::Ok,
854            instrumentation_scope: InstrumentationScope::builder("test-library")
855                .with_version("1.0.0")
856                .with_schema_url("https://opentelemetry.io/schema/1.0.0")
857                .build(),
858        }
859    }
860
861    #[test]
862    fn test_parse_headers() {
863        std::env::set_var("OTEL_EXPORTER_OTLP_HEADERS", "key1=value1,key2=value2");
864        std::env::set_var(
865            "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
866            "key2=override,key3=value3",
867        );
868
869        let headers = OtlpStdoutSpanExporter::parse_headers();
870
871        // Headers should be Some since we set environment variables
872        assert!(headers.is_some());
873        let headers = headers.unwrap();
874
875        assert_eq!(headers.get("key1").unwrap(), "value1");
876        assert_eq!(headers.get("key2").unwrap(), "override");
877        assert_eq!(headers.get("key3").unwrap(), "value3");
878
879        // Clean up
880        std::env::remove_var("OTEL_EXPORTER_OTLP_HEADERS");
881        std::env::remove_var("OTEL_EXPORTER_OTLP_TRACES_HEADERS");
882    }
883
884    #[test]
885    fn test_service_name_resolution() {
886        // Test OTEL_SERVICE_NAME priority
887        std::env::set_var(env_vars::SERVICE_NAME, "otel-service");
888        std::env::set_var(env_vars::AWS_LAMBDA_FUNCTION_NAME, "lambda-function");
889        assert_eq!(OtlpStdoutSpanExporter::get_service_name(), "otel-service");
890
891        // Test AWS_LAMBDA_FUNCTION_NAME fallback
892        std::env::remove_var(env_vars::SERVICE_NAME);
893        assert_eq!(
894            OtlpStdoutSpanExporter::get_service_name(),
895            "lambda-function"
896        );
897
898        // Test default fallback
899        std::env::remove_var(env_vars::AWS_LAMBDA_FUNCTION_NAME);
900        assert_eq!(
901            OtlpStdoutSpanExporter::get_service_name(),
902            defaults::SERVICE_NAME
903        );
904    }
905
906    #[test]
907    fn test_compression_level_precedence() {
908        // Test env var takes precedence over options
909        std::env::set_var(env_vars::COMPRESSION_LEVEL, "3");
910        let exporter = OtlpStdoutSpanExporter::builder()
911            .compression_level(7)
912            .build();
913        assert_eq!(exporter.compression_level, 3);
914
915        // Test invalid env var falls back to options
916        std::env::set_var(env_vars::COMPRESSION_LEVEL, "invalid");
917        let exporter = OtlpStdoutSpanExporter::builder()
918            .compression_level(7)
919            .build();
920        assert_eq!(exporter.compression_level, 7);
921
922        // Test no env var uses options
923        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
924        let exporter = OtlpStdoutSpanExporter::builder()
925            .compression_level(7)
926            .build();
927        assert_eq!(exporter.compression_level, 7);
928
929        // Test fallback to default
930        let exporter = OtlpStdoutSpanExporter::builder()
931            .compression_level(defaults::COMPRESSION_LEVEL)
932            .build();
933        assert_eq!(exporter.compression_level, defaults::COMPRESSION_LEVEL);
934    }
935
936    #[test]
937    fn test_new_uses_env_compression_level() {
938        // Set environment variable
939        std::env::set_var(env_vars::COMPRESSION_LEVEL, "3");
940        let exporter = OtlpStdoutSpanExporter::default();
941        assert_eq!(exporter.compression_level, 3);
942
943        // Test with unset variable (should use default)
944        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
945        let exporter = OtlpStdoutSpanExporter::default();
946        assert_eq!(exporter.compression_level, defaults::COMPRESSION_LEVEL);
947    }
948
949    #[tokio::test]
950    #[serial]
951    async fn test_compression_level_affects_output_size() {
952        // Create a large span batch to make compression differences more noticeable
953        let mut spans = Vec::new();
954        for i in 0..100 {
955            let mut span = create_test_span();
956            // Add unique attributes to each span to increase data size
957            span.attributes.push(KeyValue::new("index", i));
958            // Add a large attribute to make compression more effective
959            span.attributes
960                .push(KeyValue::new("data", "a".repeat(1000)));
961            spans.push(span);
962        }
963
964        // Make sure environment variables don't interfere with our test
965        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
966
967        // Create exporter with no compression (level 0)
968        let no_compression_output = Arc::new(TestOutput::new());
969        let no_compression_exporter = OtlpStdoutSpanExporter {
970            compression_level: 0,
971            resource: None,
972            output: no_compression_output.clone() as Arc<dyn Output>,
973            headers: None,
974            level: None,
975        };
976        let _ = no_compression_exporter.export(spans.clone()).await;
977        let no_compression_size = extract_payload_size(&no_compression_output.get_output()[0]);
978
979        // Create exporter with max compression (level 9)
980        let max_compression_output = Arc::new(TestOutput::new());
981        let max_compression_exporter = OtlpStdoutSpanExporter {
982            compression_level: 9,
983            resource: None,
984            output: max_compression_output.clone() as Arc<dyn Output>,
985            headers: None,
986            level: None,
987        };
988        let _ = max_compression_exporter.export(spans.clone()).await;
989        let max_compression_size = extract_payload_size(&max_compression_output.get_output()[0]);
990
991        // Verify that higher compression levels result in smaller payloads
992        assert!(no_compression_size > max_compression_size,
993            "Maximum compression (level 9) should produce output no larger than no compression (level 0). Got {} vs {}",
994            max_compression_size, no_compression_size);
995
996        // Verify that all outputs can be properly decoded and contain the same data
997        let no_compression_spans = decode_and_count_spans(&no_compression_output.get_output()[0]);
998        let max_compression_spans = decode_and_count_spans(&max_compression_output.get_output()[0]);
999
1000        assert_eq!(
1001            no_compression_spans,
1002            spans.len(),
1003            "No compression output should contain all spans"
1004        );
1005        assert_eq!(
1006            max_compression_spans,
1007            spans.len(),
1008            "Maximum compression output should contain all spans"
1009        );
1010    }
1011
1012    // Helper function to extract the size of the base64-decoded payload
1013    fn extract_payload_size(json_str: &str) -> usize {
1014        let json: Value = serde_json::from_str(json_str).unwrap();
1015        let payload = json["payload"].as_str().unwrap();
1016        base64_engine.decode(payload).unwrap().len()
1017    }
1018
1019    // Helper function to decode the payload and count the number of spans
1020    fn decode_and_count_spans(json_str: &str) -> usize {
1021        let json: Value = serde_json::from_str(json_str).unwrap();
1022        let payload = json["payload"].as_str().unwrap();
1023        let decoded = base64_engine.decode(payload).unwrap();
1024
1025        let mut decoder = flate2::read::GzDecoder::new(&decoded[..]);
1026        let mut decompressed = Vec::new();
1027        std::io::Read::read_to_end(&mut decoder, &mut decompressed).unwrap();
1028
1029        let request = ExportTraceServiceRequest::decode(&*decompressed).unwrap();
1030
1031        // Count total spans across all resource spans
1032        let mut span_count = 0;
1033        for resource_span in &request.resource_spans {
1034            for scope_span in &resource_span.scope_spans {
1035                span_count += scope_span.spans.len();
1036            }
1037        }
1038
1039        span_count
1040    }
1041
1042    #[tokio::test]
1043    async fn test_export_single_span() {
1044        let (exporter, output) = OtlpStdoutSpanExporter::with_test_output();
1045        let span = create_test_span();
1046
1047        let result = exporter.export(vec![span]).await;
1048        assert!(result.is_ok());
1049
1050        let output = output.get_output();
1051        assert_eq!(output.len(), 1);
1052
1053        // Parse and verify the output
1054        let json: Value = serde_json::from_str(&output[0]).unwrap();
1055        assert_eq!(json["__otel_otlp_stdout"], VERSION);
1056        assert_eq!(json["method"], "POST");
1057        assert_eq!(json["content-type"], "application/x-protobuf");
1058        assert_eq!(json["content-encoding"], "gzip");
1059        assert_eq!(json["base64"], true);
1060
1061        // Verify payload is valid base64 and can be decoded
1062        let payload = json["payload"].as_str().unwrap();
1063        let decoded = base64_engine.decode(payload).unwrap();
1064
1065        // Verify it can be decompressed
1066        let mut decoder = flate2::read::GzDecoder::new(&decoded[..]);
1067        let mut decompressed = Vec::new();
1068        std::io::Read::read_to_end(&mut decoder, &mut decompressed).unwrap();
1069
1070        // Verify it's valid OTLP protobuf
1071        let request = ExportTraceServiceRequest::decode(&*decompressed).unwrap();
1072        assert_eq!(request.resource_spans.len(), 1);
1073    }
1074
1075    #[tokio::test]
1076    async fn test_export_empty_batch() {
1077        let exporter = OtlpStdoutSpanExporter::default();
1078        let result = exporter.export(vec![]).await;
1079        assert!(result.is_ok());
1080    }
1081
1082    #[test]
1083    #[serial]
1084    fn test_gzip_level_configuration() {
1085        // Ensure all environment variables are removed first
1086        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
1087
1088        // Now test the constructor parameter
1089        let exporter = OtlpStdoutSpanExporter::builder()
1090            .compression_level(9)
1091            .build();
1092        assert_eq!(exporter.compression_level, 9);
1093    }
1094
1095    #[tokio::test]
1096    #[serial]
1097    async fn test_env_var_affects_export_compression() {
1098        // Create more test data with repeated content to make compression differences noticeable
1099        let span = create_test_span();
1100        let mut spans = Vec::new();
1101        // Create 100 spans with large attributes to make compression differences noticeable
1102        for i in 0..100 {
1103            let mut span = span.clone();
1104            // Add unique attribute with large value to make compression more effective
1105            span.attributes
1106                .push(KeyValue::new(format!("test-key-{}", i), "a".repeat(1000)));
1107            spans.push(span);
1108        }
1109
1110        // First, create data with no compression
1111        std::env::set_var(env_vars::COMPRESSION_LEVEL, "0");
1112        let no_compression_output = Arc::new(TestOutput::new());
1113        let mut no_compression_exporter = OtlpStdoutSpanExporter::builder()
1114            .compression_level(0)
1115            .build();
1116        no_compression_exporter.output = no_compression_output.clone() as Arc<dyn Output>;
1117        let _ = no_compression_exporter.export(spans.clone()).await;
1118        let no_compression_size = extract_payload_size(&no_compression_output.get_output()[0]);
1119
1120        // Now with max compression
1121        std::env::set_var(env_vars::COMPRESSION_LEVEL, "9");
1122        let max_compression_output = Arc::new(TestOutput::new());
1123        let mut max_compression_exporter = OtlpStdoutSpanExporter::builder()
1124            .compression_level(9)
1125            .build();
1126        max_compression_exporter.output = max_compression_output.clone() as Arc<dyn Output>;
1127        let _ = max_compression_exporter.export(spans.clone()).await;
1128        let max_compression_size = extract_payload_size(&max_compression_output.get_output()[0]);
1129
1130        // Verify that the environment variable affected the compression level
1131        assert!(no_compression_size > max_compression_size,
1132            "Environment variable COMPRESSION_LEVEL=9 should produce smaller output than COMPRESSION_LEVEL=0. Got {} vs {}",
1133            max_compression_size, no_compression_size);
1134
1135        // Test with explicit level when env var is set (env var should take precedence)
1136        std::env::set_var(env_vars::COMPRESSION_LEVEL, "0");
1137        let explicit_output = Arc::new(TestOutput::new());
1138
1139        // Create an exporter with the default() method which will use the environment variable
1140        let explicit_exporter = OtlpStdoutSpanExporter::builder()
1141            .output(explicit_output.clone())
1142            .build();
1143
1144        // The environment variable should make it use compression level 0
1145        let _ = explicit_exporter.export(spans.clone()).await;
1146        let explicit_size = extract_payload_size(&explicit_output.get_output()[0]);
1147
1148        // Should be approximately the same size as the no_compression_size since
1149        // the environment variable (level 0) should take precedence
1150        assert!(explicit_size > max_compression_size,
1151            "Environment variable should take precedence over explicitly set level. Expected size closer to {} but got {}",
1152            no_compression_size, explicit_size);
1153
1154        // Clean up
1155        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
1156    }
1157
1158    #[tokio::test]
1159    #[serial]
1160    async fn test_environment_variable_precedence() {
1161        // Set environment variable
1162        std::env::set_var(env_vars::COMPRESSION_LEVEL, "3");
1163
1164        // With the new precedence rules, environment variables take precedence
1165        // over constructor parameters
1166        let exporter = OtlpStdoutSpanExporter::builder()
1167            .compression_level(9)
1168            .build();
1169        assert_eq!(exporter.compression_level, 3);
1170
1171        // When environment variable is removed, constructor parameter should be used
1172        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
1173        let exporter = OtlpStdoutSpanExporter::builder()
1174            .compression_level(9)
1175            .build();
1176        assert_eq!(exporter.compression_level, 9);
1177    }
1178
1179    #[test]
1180    fn test_exporter_output_deserialization() {
1181        // Create a sample JSON string that would be produced by the exporter
1182        let json_str = r#"{
1183            "__otel_otlp_stdout": "0.11.1",
1184            "source": "test-service",
1185            "endpoint": "http://localhost:4318/v1/traces",
1186            "method": "POST",
1187            "content-type": "application/x-protobuf",
1188            "content-encoding": "gzip",
1189            "headers": {
1190                "api-key": "test-key",
1191                "custom-header": "test-value"
1192            },
1193            "payload": "SGVsbG8gd29ybGQ=",
1194            "base64": true
1195        }"#;
1196
1197        // Deserialize the JSON string into an ExporterOutput
1198        let output: ExporterOutput = serde_json::from_str(json_str).unwrap();
1199
1200        // Verify that all fields are correctly deserialized
1201        assert_eq!(output.version, "0.11.1");
1202        assert_eq!(output.source, "test-service");
1203        assert_eq!(output.endpoint, "http://localhost:4318/v1/traces");
1204        assert_eq!(output.method, "POST");
1205        assert_eq!(output.content_type, "application/x-protobuf");
1206        assert_eq!(output.content_encoding, "gzip");
1207        assert_eq!(output.headers.as_ref().unwrap().len(), 2);
1208        assert_eq!(
1209            output.headers.as_ref().unwrap().get("api-key").unwrap(),
1210            "test-key"
1211        );
1212        assert_eq!(
1213            output
1214                .headers
1215                .as_ref()
1216                .unwrap()
1217                .get("custom-header")
1218                .unwrap(),
1219            "test-value"
1220        );
1221        assert_eq!(output.payload, "SGVsbG8gd29ybGQ=");
1222        assert!(output.base64);
1223
1224        // Verify that we can decode the base64 payload (if it's valid base64)
1225        let decoded = base64_engine.decode(&output.payload).unwrap();
1226        let payload_text = String::from_utf8(decoded).unwrap();
1227        assert_eq!(payload_text, "Hello world");
1228    }
1229
1230    #[test]
1231    fn test_exporter_output_deserialization_dynamic() {
1232        // Create a dynamic JSON string using String operations
1233        let version = "0.11.1".to_string();
1234        let service = "dynamic-service".to_string();
1235        let payload = base64_engine.encode("Dynamic payload");
1236
1237        // Build the JSON dynamically
1238        let json_str = format!(
1239            r#"{{
1240                "__otel_otlp_stdout": "{}",
1241                "source": "{}",
1242                "endpoint": "http://localhost:4318/v1/traces",
1243                "method": "POST",
1244                "content-type": "application/x-protobuf",
1245                "content-encoding": "gzip",
1246                "headers": {{
1247                    "dynamic-key": "dynamic-value"
1248                }},
1249                "payload": "{}",
1250                "base64": true
1251            }}"#,
1252            version, service, payload
1253        );
1254
1255        // Deserialize the dynamic JSON string
1256        let output: ExporterOutput = serde_json::from_str(&json_str).unwrap();
1257
1258        // Verify fields
1259        assert_eq!(output.version, version);
1260        assert_eq!(output.source, service);
1261        assert_eq!(output.endpoint, "http://localhost:4318/v1/traces");
1262        assert_eq!(output.method, "POST");
1263        assert_eq!(output.content_type, "application/x-protobuf");
1264        assert_eq!(output.content_encoding, "gzip");
1265        assert_eq!(output.headers.as_ref().unwrap().len(), 1);
1266        assert_eq!(
1267            output.headers.as_ref().unwrap().get("dynamic-key").unwrap(),
1268            "dynamic-value"
1269        );
1270        assert_eq!(output.payload, payload);
1271        assert!(output.base64);
1272
1273        // Verify payload decoding
1274        let decoded = base64_engine.decode(&output.payload).unwrap();
1275        let payload_text = String::from_utf8(decoded).unwrap();
1276        assert_eq!(payload_text, "Dynamic payload");
1277    }
1278
1279    #[test]
1280    fn test_log_level_from_str() {
1281        assert_eq!(LogLevel::from_str("debug").unwrap(), LogLevel::Debug);
1282        assert_eq!(LogLevel::from_str("DEBUG").unwrap(), LogLevel::Debug);
1283        assert_eq!(LogLevel::from_str("info").unwrap(), LogLevel::Info);
1284        assert_eq!(LogLevel::from_str("INFO").unwrap(), LogLevel::Info);
1285        assert_eq!(LogLevel::from_str("warn").unwrap(), LogLevel::Warn);
1286        assert_eq!(LogLevel::from_str("warning").unwrap(), LogLevel::Warn);
1287        assert_eq!(LogLevel::from_str("WARN").unwrap(), LogLevel::Warn);
1288        assert_eq!(LogLevel::from_str("error").unwrap(), LogLevel::Error);
1289        assert_eq!(LogLevel::from_str("ERROR").unwrap(), LogLevel::Error);
1290
1291        assert!(LogLevel::from_str("invalid").is_err());
1292    }
1293
1294    #[test]
1295    fn test_log_level_display() {
1296        assert_eq!(LogLevel::Debug.to_string(), "DEBUG");
1297        assert_eq!(LogLevel::Info.to_string(), "INFO");
1298        assert_eq!(LogLevel::Warn.to_string(), "WARN");
1299        assert_eq!(LogLevel::Error.to_string(), "ERROR");
1300    }
1301
1302    #[test]
1303    #[serial]
1304    fn test_log_level_from_env() {
1305        // Set environment variable
1306        std::env::set_var(env_vars::LOG_LEVEL, "debug");
1307        let exporter = OtlpStdoutSpanExporter::default();
1308        assert_eq!(exporter.level, Some(LogLevel::Debug));
1309
1310        // Test with invalid level
1311        std::env::set_var(env_vars::LOG_LEVEL, "invalid");
1312        let exporter = OtlpStdoutSpanExporter::default();
1313        assert_eq!(exporter.level, None);
1314
1315        // Test with constructor parameter
1316        std::env::remove_var(env_vars::LOG_LEVEL);
1317        let exporter = OtlpStdoutSpanExporter::builder()
1318            .level(LogLevel::Error)
1319            .build();
1320        assert_eq!(exporter.level, Some(LogLevel::Error));
1321
1322        // Test env var takes precedence over constructor
1323        std::env::set_var(env_vars::LOG_LEVEL, "warn");
1324        let exporter = OtlpStdoutSpanExporter::builder()
1325            .level(LogLevel::Error)
1326            .build();
1327        assert_eq!(exporter.level, Some(LogLevel::Warn));
1328
1329        // Clean up
1330        std::env::remove_var(env_vars::LOG_LEVEL);
1331    }
1332
1333    #[tokio::test]
1334    #[serial]
1335    async fn test_log_level_in_output() {
1336        // Create a test exporter with a specific log level
1337        let (mut exporter, output) = OtlpStdoutSpanExporter::with_test_output();
1338        exporter.level = Some(LogLevel::Debug);
1339        let span = create_test_span();
1340
1341        let result = exporter.export(vec![span]).await;
1342        assert!(result.is_ok());
1343
1344        let output_lines = output.get_output();
1345        assert_eq!(output_lines.len(), 1);
1346
1347        // Parse the JSON to check the level field
1348        let json: Value = serde_json::from_str(&output_lines[0]).unwrap();
1349        assert_eq!(json["level"], "DEBUG");
1350
1351        // Test with no level set
1352        let (mut exporter, output) = OtlpStdoutSpanExporter::with_test_output();
1353        exporter.level = None;
1354        let span = create_test_span();
1355
1356        let result = exporter.export(vec![span]).await;
1357        assert!(result.is_ok());
1358
1359        let output_lines = output.get_output();
1360        assert_eq!(output_lines.len(), 1);
1361
1362        // Parse the JSON to check level field is omitted
1363        let json: Value = serde_json::from_str(&output_lines[0]).unwrap();
1364        assert!(!json.as_object().unwrap().contains_key("level"));
1365    }
1366
1367    #[test]
1368    fn test_stdout_output() {
1369        let output = create_output(false);
1370        // We can't easily test stdout directly, but we can verify the type is created
1371        assert!(format!("{:?}", output).contains("StdOutput"));
1372    }
1373
1374    #[test]
1375    fn test_pipe_output() {
1376        let output = create_output(true);
1377        // Even if pipe doesn't exist, we should get a NamedPipeOutput or StdOutput fallback
1378        let debug_str = format!("{:?}", output);
1379        assert!(debug_str.contains("NamedPipeOutput") || debug_str.contains("StdOutput"));
1380    }
1381
1382    #[test]
1383    fn test_env_var_precedence() {
1384        // Create a temporary directory for testing
1385        let temp_dir = std::env::temp_dir();
1386        let path = temp_dir.join("test_pipe");
1387
1388        // Make sure no other environment variables interfere
1389        std::env::remove_var(env_vars::OUTPUT_TYPE);
1390
1391        // Set the environment variable to use pipe
1392        std::env::set_var(env_vars::OUTPUT_TYPE, "pipe");
1393
1394        // Create the exporter
1395        let exporter = OtlpStdoutSpanExporter::default();
1396
1397        // Verify the output type
1398        let debug_str = format!("{:?}", exporter.output);
1399        assert!(debug_str.contains("NamedPipeOutput") || debug_str.contains("StdOutput"));
1400
1401        // Clean up
1402        std::env::remove_var(env_vars::OUTPUT_TYPE);
1403        if path.exists() {
1404            let _ = std::fs::remove_file(path);
1405        }
1406    }
1407
1408    #[test]
1409    fn test_constructor_precedence() {
1410        // Create a temporary directory for testing
1411        let temp_dir = std::env::temp_dir();
1412        let path = temp_dir.join("test_pipe");
1413
1414        // Make sure the environment variable is not set
1415        std::env::remove_var(env_vars::OUTPUT_TYPE);
1416
1417        // Create the exporter with pipe output
1418        let exporter = OtlpStdoutSpanExporter::builder().pipe(true).build();
1419
1420        // Verify the output type
1421        let debug_str = format!("{:?}", exporter.output);
1422        assert!(debug_str.contains("NamedPipeOutput") || debug_str.contains("StdOutput"));
1423
1424        // Clean up
1425        if path.exists() {
1426            let _ = std::fs::remove_file(path);
1427        }
1428    }
1429
1430    #[test]
1431    fn test_env_var_overrides_constructor() {
1432        // Create temporary directory for testing
1433        let temp_dir = std::env::temp_dir();
1434        let path = temp_dir.join("test_pipe");
1435
1436        // Set the environment variable to use pipe
1437        std::env::set_var(env_vars::OUTPUT_TYPE, "pipe");
1438
1439        // Create the exporter with stdout in constructor
1440        let exporter = OtlpStdoutSpanExporter::builder().pipe(false).build();
1441
1442        // Verify that env var took precedence (pipe output)
1443        let debug_str = format!("{:?}", exporter.output);
1444        assert!(debug_str.contains("NamedPipeOutput") || debug_str.contains("StdOutput"));
1445
1446        // Clean up
1447        std::env::remove_var(env_vars::OUTPUT_TYPE);
1448        if path.exists() {
1449            let _ = std::fs::remove_file(path);
1450        }
1451    }
1452}