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 futures_util::future::BoxFuture;
122use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
123use opentelemetry_proto::transform::common::tonic::ResourceAttributesWithSchema;
124use opentelemetry_proto::transform::trace::tonic::group_spans_by_resource_and_scope;
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::Display,
136    fs::OpenOptions,
137    io::{self, Write},
138    path::PathBuf,
139    result::Result,
140    str::FromStr,
141    sync::Arc,
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.
204trait 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
217/// Standard output implementation that writes to stdout
218#[derive(Debug, Default)]
219struct StdOutput;
220
221impl Output for StdOutput {
222    fn write_line(&self, line: &str) -> Result<(), OTelSdkError> {
223        // Get a locked stdout handle once
224        let stdout = io::stdout();
225        let mut handle = stdout.lock();
226
227        // Write the line and a newline in one operation
228        writeln!(handle, "{}", line).map_err(|e| OTelSdkError::InternalFailure(e.to_string()))?;
229
230        Ok(())
231    }
232}
233
234/// Output implementation that writes to a named pipe
235#[derive(Debug)]
236struct NamedPipeOutput {
237    path: PathBuf,
238}
239
240impl NamedPipeOutput {
241    fn new() -> Result<Self, OTelSdkError> {
242        let path_buf = PathBuf::from(defaults::PIPE_PATH);
243        if !path_buf.exists() {
244            log::warn!("Named pipe does not exist: {}", defaults::PIPE_PATH);
245            // On Unix systems we could create it with mkfifo but this would need cfg platform specifics
246        }
247
248        Ok(Self { path: path_buf })
249    }
250}
251
252impl Output for NamedPipeOutput {
253    fn write_line(&self, line: &str) -> Result<(), OTelSdkError> {
254        // Open the pipe for writing
255        let mut file = OpenOptions::new()
256            .write(true)
257            .open(&self.path)
258            .map_err(|e| OTelSdkError::InternalFailure(format!("Failed to open pipe: {}", e)))?;
259
260        // Write line with newline
261        writeln!(file, "{}", line).map_err(|e| {
262            OTelSdkError::InternalFailure(format!("Failed to write to pipe: {}", e))
263        })?;
264
265        Ok(())
266    }
267}
268
269/// Helper function to create output based on type
270fn create_output(use_pipe: bool) -> Arc<dyn Output> {
271    if use_pipe {
272        match NamedPipeOutput::new() {
273            Ok(output) => Arc::new(output),
274            Err(e) => {
275                log::warn!(
276                    "Failed to create named pipe output: {}, falling back to stdout",
277                    e
278                );
279                Arc::new(StdOutput)
280            }
281        }
282    } else {
283        Arc::new(StdOutput)
284    }
285}
286
287/// Output format for the OTLP stdout exporter
288///
289/// This struct defines the JSON structure that will be written to stdout
290/// for each batch of spans.
291#[derive(Debug, Serialize, Deserialize)]
292pub struct ExporterOutput {
293    /// Version identifier for the output format
294    #[serde(rename = "__otel_otlp_stdout")]
295    pub version: String,
296    /// Service name that generated the spans
297    pub source: String,
298    /// OTLP endpoint (always http://localhost:4318/v1/traces)
299    pub endpoint: String,
300    /// HTTP method (always POST)
301    pub method: String,
302    /// Content type (always application/x-protobuf)
303    #[serde(rename = "content-type")]
304    pub content_type: String,
305    /// Content encoding (always gzip)
306    #[serde(rename = "content-encoding")]
307    pub content_encoding: String,
308    /// Custom headers from environment variables
309    #[serde(skip_serializing_if = "ExporterOutput::is_headers_empty")]
310    pub headers: Option<HashMap<String, String>>,
311    /// Base64-encoded, gzipped, protobuf-serialized span data
312    pub payload: String,
313    /// Whether the payload is base64 encoded (always true)
314    pub base64: bool,
315    /// Log level for filtering (optional)
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub level: Option<String>,
318}
319
320impl ExporterOutput {
321    /// Helper function for serde to skip serializing empty headers
322    fn is_headers_empty(headers: &Option<HashMap<String, String>>) -> bool {
323        headers.as_ref().map_or(true, |h| h.is_empty())
324    }
325}
326
327/// A span exporter that writes spans to stdout in OTLP format
328///
329/// This exporter implements the OpenTelemetry [`SpanExporter`] trait and writes spans
330/// to stdout in OTLP format with Protobuf serialization and GZIP compression.
331///
332/// # Features
333///
334/// - Configurable GZIP compression level (0-9)
335/// - Environment variable support for service name and headers
336/// - Efficient batching of spans
337/// - Base64 encoding of compressed data
338///
339/// # Example
340///
341/// ```rust,no_run
342/// use opentelemetry_sdk::runtime;
343/// use otlp_stdout_span_exporter::OtlpStdoutSpanExporter;
344///
345/// // Create an exporter with maximum compression
346/// let exporter = OtlpStdoutSpanExporter::builder()
347///     .compression_level(9)
348///     .build();
349/// ```
350#[derive(Debug)]
351pub struct OtlpStdoutSpanExporter {
352    /// GZIP compression level (0-9)
353    compression_level: u8,
354    /// Optional resource to be included with all spans
355    resource: Option<Resource>,
356    // Optional headers
357    headers: Option<HashMap<String, String>>,
358    /// Output implementation (stdout or named pipe)
359    output: Arc<dyn Output>,
360    /// Optional log level for the exported spans
361    level: Option<LogLevel>,
362}
363
364impl Default for OtlpStdoutSpanExporter {
365    fn default() -> Self {
366        Self::builder().build()
367    }
368}
369#[bon]
370impl OtlpStdoutSpanExporter {
371    /// Create a new `OtlpStdoutSpanExporter` with default configuration.
372    ///
373    /// This uses a GZIP compression level of 6 unless overridden by an environment variable.
374    ///
375    /// # Output Type
376    ///
377    /// The output type is determined in the following order:
378    ///
379    /// 1. The `OTLP_STDOUT_SPAN_EXPORTER_OUTPUT_TYPE` environment variable if set ("pipe" or "stdout")
380    /// 2. Constructor parameter (pipe)
381    /// 3. Default (stdout)
382    ///
383    /// # Example
384    ///
385    /// ```
386    /// use otlp_stdout_span_exporter::OtlpStdoutSpanExporter;
387    ///
388    /// let exporter = OtlpStdoutSpanExporter::default();
389    /// ```
390    #[builder]
391    pub fn new(
392        compression_level: Option<u8>,
393        resource: Option<Resource>,
394        headers: Option<HashMap<String, String>>,
395        output: Option<Arc<dyn Output>>,
396        level: Option<LogLevel>,
397        pipe: Option<bool>,
398    ) -> Self {
399        // Set gzip_level with proper precedence (env var > constructor param > default)
400        let compression_level = match env::var(env_vars::COMPRESSION_LEVEL) {
401            Ok(value) => match value.parse::<u8>() {
402                Ok(level) if level <= 9 => level,
403                Ok(level) => {
404                    log::warn!(
405                        "Invalid value in {}: {} (must be 0-9), using fallback",
406                        env_vars::COMPRESSION_LEVEL,
407                        level
408                    );
409                    compression_level.unwrap_or(defaults::COMPRESSION_LEVEL)
410                }
411                Err(_) => {
412                    log::warn!(
413                        "Failed to parse {}: {}, using fallback",
414                        env_vars::COMPRESSION_LEVEL,
415                        value
416                    );
417                    compression_level.unwrap_or(defaults::COMPRESSION_LEVEL)
418                }
419            },
420            Err(_) => {
421                // No environment variable, use parameter or default
422                compression_level.unwrap_or(defaults::COMPRESSION_LEVEL)
423            }
424        };
425
426        // Combine constructor headers with environment headers, giving priority to env vars
427        let headers = match headers {
428            Some(constructor_headers) => {
429                if let Some(env_headers) = Self::parse_headers() {
430                    // Merge, with env headers taking precedence
431                    let mut merged = constructor_headers;
432                    merged.extend(env_headers);
433                    Some(merged)
434                } else {
435                    // No env headers, use constructor headers
436                    Some(constructor_headers)
437                }
438            }
439            None => Self::parse_headers(), // Use env headers only
440        };
441
442        // Set log level with proper precedence (env var > constructor param > default)
443        let level = match env::var(env_vars::LOG_LEVEL) {
444            Ok(value) => match LogLevel::from_str(&value) {
445                Ok(log_level) => Some(log_level),
446                Err(e) => {
447                    log::warn!(
448                        "Invalid log level in {}: {}, using fallback",
449                        env_vars::LOG_LEVEL,
450                        e
451                    );
452                    level
453                }
454            },
455            Err(_) => {
456                // No environment variable, use parameter
457                level
458            }
459        };
460
461        // Determine output type with proper precedence (env var > constructor > default)
462        let use_pipe = match env::var(env_vars::OUTPUT_TYPE) {
463            Ok(value) => value.to_lowercase() == "pipe",
464            Err(_) => pipe.unwrap_or(false),
465        };
466
467        // Create output implementation
468        let output = output.unwrap_or_else(|| create_output(use_pipe));
469
470        Self {
471            compression_level,
472            resource,
473            headers,
474            output,
475            level,
476        }
477    }
478
479    /// Get the service name from environment variables.
480    ///
481    /// The service name is determined in the following order:
482    ///
483    /// 1. OTEL_SERVICE_NAME
484    /// 2. AWS_LAMBDA_FUNCTION_NAME
485    /// 3. "unknown-service" (fallback)
486    fn get_service_name() -> String {
487        env::var(env_vars::SERVICE_NAME)
488            .or_else(|_| env::var(env_vars::AWS_LAMBDA_FUNCTION_NAME))
489            .unwrap_or_else(|_| defaults::SERVICE_NAME.to_string())
490    }
491
492    #[cfg(test)]
493    fn with_test_output() -> (Self, Arc<TestOutput>) {
494        let output = Arc::new(TestOutput::new());
495
496        // Use the standard builder() method and explicitly set the output
497        let exporter = Self::builder().output(output.clone()).build();
498
499        (exporter, output)
500    }
501
502    /// Parse headers from environment variables
503    ///
504    /// This function reads headers from both global and trace-specific
505    /// environment variables, with trace-specific headers taking precedence.
506    fn parse_headers() -> Option<HashMap<String, String>> {
507        // Function to get and parse headers from an env var
508        let get_headers = |var_name: &str| -> Option<HashMap<String, String>> {
509            env::var(var_name).ok().map(|header_str| {
510                let mut map = HashMap::new();
511                Self::parse_header_string(&header_str, &mut map);
512                map
513            })
514        };
515
516        // Try to get headers from both env vars
517        let global_headers = get_headers("OTEL_EXPORTER_OTLP_HEADERS");
518        let trace_headers = get_headers("OTEL_EXPORTER_OTLP_TRACES_HEADERS");
519
520        // If no headers were found in either env var, return None
521        if global_headers.is_none() && trace_headers.is_none() {
522            return None;
523        }
524
525        // Create a merged map, with trace headers taking precedence
526        let mut result = HashMap::new();
527
528        // Add global headers first (if any)
529        if let Some(headers) = global_headers {
530            result.extend(headers);
531        }
532
533        // Add trace-specific headers (if any) - these will override any duplicates
534        if let Some(headers) = trace_headers {
535            result.extend(headers);
536        }
537
538        // Return None for empty map, otherwise Some
539        if result.is_empty() {
540            None
541        } else {
542            Some(result)
543        }
544    }
545
546    /// Parse a header string in the format key1=value1,key2=value2
547    ///
548    /// # Arguments
549    ///
550    /// * `header_str` - The header string to parse
551    /// * `headers` - The map to store parsed headers in
552    fn parse_header_string(header_str: &str, headers: &mut HashMap<String, String>) {
553        for pair in header_str.split(',') {
554            if let Some((key, value)) = pair.split_once('=') {
555                let key = key.trim().to_lowercase();
556                // Skip content-type and content-encoding as they are fixed
557                if key != "content-type" && key != "content-encoding" {
558                    headers.insert(key, value.trim().to_string());
559                }
560            }
561        }
562    }
563}
564
565#[async_trait]
566impl SpanExporter for OtlpStdoutSpanExporter {
567    /// Export spans to stdout in OTLP format
568    ///
569    /// This function:
570    /// 1. Converts spans to OTLP format
571    /// 2. Serializes them to protobuf
572    /// 3. Compresses the data with GZIP
573    /// 4. Base64 encodes the result
574    /// 5. Writes a JSON object to stdout
575    ///
576    /// # Arguments
577    ///
578    /// * `batch` - A vector of spans to export
579    ///
580    /// # Returns
581    ///
582    /// Returns a resolved future with `Ok(())` if the export was successful, or a `TraceError` if it failed
583    fn export(&mut self, batch: Vec<SpanData>) -> BoxFuture<'static, Result<(), OTelSdkError>> {
584        // Do all work synchronously
585        let result = (|| {
586            // Convert spans to OTLP format
587            let resource = self
588                .resource
589                .clone()
590                .unwrap_or_else(|| opentelemetry_sdk::Resource::builder_empty().build());
591            let resource_attrs = ResourceAttributesWithSchema::from(&resource);
592            let resource_spans = group_spans_by_resource_and_scope(batch, &resource_attrs);
593            let request = ExportTraceServiceRequest { resource_spans };
594
595            // Serialize to protobuf
596            let proto_bytes = request.encode_to_vec();
597
598            // Compress with GZIP
599            let mut encoder =
600                GzEncoder::new(Vec::new(), Compression::new(self.compression_level as u32));
601            encoder
602                .write_all(&proto_bytes)
603                .map_err(|e| OTelSdkError::InternalFailure(e.to_string()))?;
604            let compressed_bytes = encoder
605                .finish()
606                .map_err(|e| OTelSdkError::InternalFailure(e.to_string()))?;
607
608            // Base64 encode
609            let payload = base64_engine.encode(compressed_bytes);
610
611            // Prepare the output
612            let output_data = ExporterOutput {
613                version: VERSION.to_string(),
614                source: Self::get_service_name(),
615                endpoint: defaults::ENDPOINT.to_string(),
616                method: "POST".to_string(),
617                content_type: "application/x-protobuf".to_string(),
618                content_encoding: "gzip".to_string(),
619                headers: self.headers.clone(),
620                payload,
621                base64: true,
622                level: self.level.map(|l| l.to_string()),
623            };
624
625            // Write using the output implementation
626            self.output.write_line(
627                &serde_json::to_string(&output_data)
628                    .map_err(|e| OTelSdkError::InternalFailure(e.to_string()))?,
629            )?;
630
631            Ok(())
632        })();
633
634        // Return a resolved future with the result
635        Box::pin(std::future::ready(result))
636    }
637
638    /// Shuts down the exporter
639    ///
640    /// This is a no-op for stdout export as no cleanup is needed.
641    ///
642    /// # Returns
643    ///
644    /// Returns `Ok(())` as there is nothing to clean up.
645    fn shutdown(&mut self) -> Result<(), OTelSdkError> {
646        Ok(())
647    }
648
649    /// Force flushes any pending spans
650    ///
651    /// This is a no-op for stdout export as spans are written immediately.
652    ///
653    /// # Returns
654    ///
655    /// Returns `Ok(())` as there is nothing to flush.
656    fn force_flush(&mut self) -> Result<(), OTelSdkError> {
657        Ok(())
658    }
659
660    /// Sets the resource for this exporter.
661    ///
662    /// This method stores a clone of the provided resource to be used when exporting spans.
663    /// The resource represents the entity producing telemetry and will be included in the
664    /// exported trace data.
665    ///
666    /// # Arguments
667    ///
668    /// * `resource` - The resource to associate with this exporter
669    fn set_resource(&mut self, resource: &opentelemetry_sdk::Resource) {
670        self.resource = Some(<opentelemetry_sdk::Resource as Into<Resource>>::into(
671            resource.clone(),
672        ));
673    }
674}
675
676#[cfg(doctest)]
677#[macro_use]
678extern crate doc_comment;
679
680#[cfg(doctest)]
681use doc_comment::doctest;
682
683#[cfg(doctest)]
684doctest!("../README.md", readme);
685
686#[cfg(test)]
687use std::sync::Mutex;
688
689/// Test output implementation that captures to a buffer
690#[cfg(test)]
691#[derive(Debug, Default)]
692struct TestOutput {
693    buffer: Arc<Mutex<Vec<String>>>,
694}
695
696#[cfg(test)]
697impl TestOutput {
698    fn new() -> Self {
699        Self {
700            buffer: Arc::new(Mutex::new(Vec::new())),
701        }
702    }
703
704    fn get_output(&self) -> Vec<String> {
705        self.buffer.lock().unwrap().clone()
706    }
707}
708
709#[cfg(test)]
710impl Output for TestOutput {
711    fn write_line(&self, line: &str) -> Result<(), OTelSdkError> {
712        self.buffer.lock().unwrap().push(line.to_string());
713        Ok(())
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720    use opentelemetry::{
721        trace::{SpanContext, SpanId, SpanKind, Status, TraceFlags, TraceId, TraceState},
722        InstrumentationScope, KeyValue,
723    };
724    use opentelemetry_sdk::trace::{SpanData, SpanEvents, SpanLinks};
725    use serde_json::Value;
726    use serial_test::serial;
727    use std::time::SystemTime;
728
729    fn create_test_span() -> SpanData {
730        let trace_id_bytes = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 42];
731        let span_id_bytes = [0, 0, 0, 0, 0, 0, 0, 123];
732        let parent_id_bytes = [0, 0, 0, 0, 0, 0, 0, 42];
733
734        let span_context = SpanContext::new(
735            TraceId::from_bytes(trace_id_bytes),
736            SpanId::from_bytes(span_id_bytes),
737            TraceFlags::default(),
738            false,
739            TraceState::default(),
740        );
741
742        SpanData {
743            span_context,
744            parent_span_id: SpanId::from_bytes(parent_id_bytes),
745            span_kind: SpanKind::Client,
746            name: "test-span".into(),
747            start_time: SystemTime::UNIX_EPOCH,
748            end_time: SystemTime::UNIX_EPOCH,
749            attributes: vec![KeyValue::new("test.key", "test-value")],
750            dropped_attributes_count: 0,
751            events: SpanEvents::default(),
752            links: SpanLinks::default(),
753            status: Status::Ok,
754            instrumentation_scope: InstrumentationScope::builder("test-library")
755                .with_version("1.0.0")
756                .with_schema_url("https://opentelemetry.io/schema/1.0.0")
757                .build(),
758        }
759    }
760
761    #[test]
762    fn test_parse_headers() {
763        std::env::set_var("OTEL_EXPORTER_OTLP_HEADERS", "key1=value1,key2=value2");
764        std::env::set_var(
765            "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
766            "key2=override,key3=value3",
767        );
768
769        let headers = OtlpStdoutSpanExporter::parse_headers();
770
771        // Headers should be Some since we set environment variables
772        assert!(headers.is_some());
773        let headers = headers.unwrap();
774
775        assert_eq!(headers.get("key1").unwrap(), "value1");
776        assert_eq!(headers.get("key2").unwrap(), "override");
777        assert_eq!(headers.get("key3").unwrap(), "value3");
778
779        // Clean up
780        std::env::remove_var("OTEL_EXPORTER_OTLP_HEADERS");
781        std::env::remove_var("OTEL_EXPORTER_OTLP_TRACES_HEADERS");
782    }
783
784    #[test]
785    fn test_service_name_resolution() {
786        // Test OTEL_SERVICE_NAME priority
787        std::env::set_var(env_vars::SERVICE_NAME, "otel-service");
788        std::env::set_var(env_vars::AWS_LAMBDA_FUNCTION_NAME, "lambda-function");
789        assert_eq!(OtlpStdoutSpanExporter::get_service_name(), "otel-service");
790
791        // Test AWS_LAMBDA_FUNCTION_NAME fallback
792        std::env::remove_var(env_vars::SERVICE_NAME);
793        assert_eq!(
794            OtlpStdoutSpanExporter::get_service_name(),
795            "lambda-function"
796        );
797
798        // Test default fallback
799        std::env::remove_var(env_vars::AWS_LAMBDA_FUNCTION_NAME);
800        assert_eq!(
801            OtlpStdoutSpanExporter::get_service_name(),
802            defaults::SERVICE_NAME
803        );
804    }
805
806    #[test]
807    fn test_compression_level_precedence() {
808        // Test env var takes precedence over options
809        std::env::set_var(env_vars::COMPRESSION_LEVEL, "3");
810        let exporter = OtlpStdoutSpanExporter::builder()
811            .compression_level(7)
812            .build();
813        assert_eq!(exporter.compression_level, 3);
814
815        // Test invalid env var falls back to options
816        std::env::set_var(env_vars::COMPRESSION_LEVEL, "invalid");
817        let exporter = OtlpStdoutSpanExporter::builder()
818            .compression_level(7)
819            .build();
820        assert_eq!(exporter.compression_level, 7);
821
822        // Test no env var uses options
823        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
824        let exporter = OtlpStdoutSpanExporter::builder()
825            .compression_level(7)
826            .build();
827        assert_eq!(exporter.compression_level, 7);
828
829        // Test fallback to default
830        let exporter = OtlpStdoutSpanExporter::builder()
831            .compression_level(defaults::COMPRESSION_LEVEL)
832            .build();
833        assert_eq!(exporter.compression_level, defaults::COMPRESSION_LEVEL);
834    }
835
836    #[test]
837    fn test_new_uses_env_compression_level() {
838        // Set environment variable
839        std::env::set_var(env_vars::COMPRESSION_LEVEL, "3");
840        let exporter = OtlpStdoutSpanExporter::default();
841        assert_eq!(exporter.compression_level, 3);
842
843        // Test with unset variable (should use default)
844        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
845        let exporter = OtlpStdoutSpanExporter::default();
846        assert_eq!(exporter.compression_level, defaults::COMPRESSION_LEVEL);
847    }
848
849    #[tokio::test]
850    #[serial]
851    async fn test_compression_level_affects_output_size() {
852        // Create a large span batch to make compression differences more noticeable
853        let mut spans = Vec::new();
854        for i in 0..100 {
855            let mut span = create_test_span();
856            // Add unique attributes to each span to increase data size
857            span.attributes.push(KeyValue::new("index", i));
858            // Add a large attribute to make compression more effective
859            span.attributes
860                .push(KeyValue::new("data", "a".repeat(1000)));
861            spans.push(span);
862        }
863
864        // Make sure environment variables don't interfere with our test
865        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
866
867        // Create exporter with no compression (level 0)
868        let no_compression_output = Arc::new(TestOutput::new());
869        let mut no_compression_exporter = OtlpStdoutSpanExporter {
870            compression_level: 0,
871            resource: None,
872            output: no_compression_output.clone() as Arc<dyn Output>,
873            headers: None,
874            level: None,
875        };
876        let _ = no_compression_exporter.export(spans.clone()).await;
877        let no_compression_size = extract_payload_size(&no_compression_output.get_output()[0]);
878
879        // Create exporter with max compression (level 9)
880        let max_compression_output = Arc::new(TestOutput::new());
881        let mut max_compression_exporter = OtlpStdoutSpanExporter {
882            compression_level: 9,
883            resource: None,
884            output: max_compression_output.clone() as Arc<dyn Output>,
885            headers: None,
886            level: None,
887        };
888        let _ = max_compression_exporter.export(spans.clone()).await;
889        let max_compression_size = extract_payload_size(&max_compression_output.get_output()[0]);
890
891        // Verify that higher compression levels result in smaller payloads
892        assert!(no_compression_size > max_compression_size,
893            "Maximum compression (level 9) should produce output no larger than no compression (level 0). Got {} vs {}",
894            max_compression_size, no_compression_size);
895
896        // Verify that all outputs can be properly decoded and contain the same data
897        let no_compression_spans = decode_and_count_spans(&no_compression_output.get_output()[0]);
898        let max_compression_spans = decode_and_count_spans(&max_compression_output.get_output()[0]);
899
900        assert_eq!(
901            no_compression_spans,
902            spans.len(),
903            "No compression output should contain all spans"
904        );
905        assert_eq!(
906            max_compression_spans,
907            spans.len(),
908            "Maximum compression output should contain all spans"
909        );
910    }
911
912    // Helper function to extract the size of the base64-decoded payload
913    fn extract_payload_size(json_str: &str) -> usize {
914        let json: Value = serde_json::from_str(json_str).unwrap();
915        let payload = json["payload"].as_str().unwrap();
916        base64_engine.decode(payload).unwrap().len()
917    }
918
919    // Helper function to decode the payload and count the number of spans
920    fn decode_and_count_spans(json_str: &str) -> usize {
921        let json: Value = serde_json::from_str(json_str).unwrap();
922        let payload = json["payload"].as_str().unwrap();
923        let decoded = base64_engine.decode(payload).unwrap();
924
925        let mut decoder = flate2::read::GzDecoder::new(&decoded[..]);
926        let mut decompressed = Vec::new();
927        std::io::Read::read_to_end(&mut decoder, &mut decompressed).unwrap();
928
929        let request = ExportTraceServiceRequest::decode(&*decompressed).unwrap();
930
931        // Count total spans across all resource spans
932        let mut span_count = 0;
933        for resource_span in &request.resource_spans {
934            for scope_span in &resource_span.scope_spans {
935                span_count += scope_span.spans.len();
936            }
937        }
938
939        span_count
940    }
941
942    #[tokio::test]
943    async fn test_export_single_span() {
944        let (mut exporter, output) = OtlpStdoutSpanExporter::with_test_output();
945        let span = create_test_span();
946
947        let result = exporter.export(vec![span]).await;
948        assert!(result.is_ok());
949
950        let output = output.get_output();
951        assert_eq!(output.len(), 1);
952
953        // Parse and verify the output
954        let json: Value = serde_json::from_str(&output[0]).unwrap();
955        assert_eq!(json["__otel_otlp_stdout"], VERSION);
956        assert_eq!(json["method"], "POST");
957        assert_eq!(json["content-type"], "application/x-protobuf");
958        assert_eq!(json["content-encoding"], "gzip");
959        assert_eq!(json["base64"], true);
960
961        // Verify payload is valid base64 and can be decoded
962        let payload = json["payload"].as_str().unwrap();
963        let decoded = base64_engine.decode(payload).unwrap();
964
965        // Verify it can be decompressed
966        let mut decoder = flate2::read::GzDecoder::new(&decoded[..]);
967        let mut decompressed = Vec::new();
968        std::io::Read::read_to_end(&mut decoder, &mut decompressed).unwrap();
969
970        // Verify it's valid OTLP protobuf
971        let request = ExportTraceServiceRequest::decode(&*decompressed).unwrap();
972        assert_eq!(request.resource_spans.len(), 1);
973    }
974
975    #[tokio::test]
976    async fn test_export_empty_batch() {
977        let mut exporter = OtlpStdoutSpanExporter::default();
978        let result = exporter.export(vec![]).await;
979        assert!(result.is_ok());
980    }
981
982    #[test]
983    #[serial]
984    fn test_gzip_level_configuration() {
985        // Ensure all environment variables are removed first
986        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
987
988        // Now test the constructor parameter
989        let exporter = OtlpStdoutSpanExporter::builder()
990            .compression_level(9)
991            .build();
992        assert_eq!(exporter.compression_level, 9);
993    }
994
995    #[tokio::test]
996    #[serial]
997    async fn test_env_var_affects_export_compression() {
998        // Create more test data with repeated content to make compression differences noticeable
999        let span = create_test_span();
1000        let mut spans = Vec::new();
1001        // Create 100 spans with large attributes to make compression differences noticeable
1002        for i in 0..100 {
1003            let mut span = span.clone();
1004            // Add unique attribute with large value to make compression more effective
1005            span.attributes
1006                .push(KeyValue::new(format!("test-key-{}", i), "a".repeat(1000)));
1007            spans.push(span);
1008        }
1009
1010        // First, create data with no compression
1011        std::env::set_var(env_vars::COMPRESSION_LEVEL, "0");
1012        let no_compression_output = Arc::new(TestOutput::new());
1013        let mut no_compression_exporter = OtlpStdoutSpanExporter::builder()
1014            .compression_level(0)
1015            .build();
1016        no_compression_exporter.output = no_compression_output.clone() as Arc<dyn Output>;
1017        let _ = no_compression_exporter.export(spans.clone()).await;
1018        let no_compression_size = extract_payload_size(&no_compression_output.get_output()[0]);
1019
1020        // Now with max compression
1021        std::env::set_var(env_vars::COMPRESSION_LEVEL, "9");
1022        let max_compression_output = Arc::new(TestOutput::new());
1023        let mut max_compression_exporter = OtlpStdoutSpanExporter::builder()
1024            .compression_level(9)
1025            .build();
1026        max_compression_exporter.output = max_compression_output.clone() as Arc<dyn Output>;
1027        let _ = max_compression_exporter.export(spans.clone()).await;
1028        let max_compression_size = extract_payload_size(&max_compression_output.get_output()[0]);
1029
1030        // Verify that the environment variable affected the compression level
1031        assert!(no_compression_size > max_compression_size,
1032            "Environment variable COMPRESSION_LEVEL=9 should produce smaller output than COMPRESSION_LEVEL=0. Got {} vs {}",
1033            max_compression_size, no_compression_size);
1034
1035        // Test with explicit level when env var is set (env var should take precedence)
1036        std::env::set_var(env_vars::COMPRESSION_LEVEL, "0");
1037        let explicit_output = Arc::new(TestOutput::new());
1038
1039        // Create an exporter with the default() method which will use the environment variable
1040        let mut explicit_exporter = OtlpStdoutSpanExporter::builder()
1041            .output(explicit_output.clone())
1042            .build();
1043
1044        // The environment variable should make it use compression level 0
1045        let _ = explicit_exporter.export(spans.clone()).await;
1046        let explicit_size = extract_payload_size(&explicit_output.get_output()[0]);
1047
1048        // Should be approximately the same size as the no_compression_size since
1049        // the environment variable (level 0) should take precedence
1050        assert!(explicit_size > max_compression_size,
1051            "Environment variable should take precedence over explicitly set level. Expected size closer to {} but got {}",
1052            no_compression_size, explicit_size);
1053
1054        // Clean up
1055        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
1056    }
1057
1058    #[tokio::test]
1059    #[serial]
1060    async fn test_environment_variable_precedence() {
1061        // Set environment variable
1062        std::env::set_var(env_vars::COMPRESSION_LEVEL, "3");
1063
1064        // With the new precedence rules, environment variables take precedence
1065        // over constructor parameters
1066        let exporter = OtlpStdoutSpanExporter::builder()
1067            .compression_level(9)
1068            .build();
1069        assert_eq!(exporter.compression_level, 3);
1070
1071        // When environment variable is removed, constructor parameter should be used
1072        std::env::remove_var(env_vars::COMPRESSION_LEVEL);
1073        let exporter = OtlpStdoutSpanExporter::builder()
1074            .compression_level(9)
1075            .build();
1076        assert_eq!(exporter.compression_level, 9);
1077    }
1078
1079    #[test]
1080    fn test_exporter_output_deserialization() {
1081        // Create a sample JSON string that would be produced by the exporter
1082        let json_str = r#"{
1083            "__otel_otlp_stdout": "0.11.1",
1084            "source": "test-service",
1085            "endpoint": "http://localhost:4318/v1/traces",
1086            "method": "POST",
1087            "content-type": "application/x-protobuf",
1088            "content-encoding": "gzip",
1089            "headers": {
1090                "api-key": "test-key",
1091                "custom-header": "test-value"
1092            },
1093            "payload": "SGVsbG8gd29ybGQ=",
1094            "base64": true
1095        }"#;
1096
1097        // Deserialize the JSON string into an ExporterOutput
1098        let output: ExporterOutput = serde_json::from_str(json_str).unwrap();
1099
1100        // Verify that all fields are correctly deserialized
1101        assert_eq!(output.version, "0.11.1");
1102        assert_eq!(output.source, "test-service");
1103        assert_eq!(output.endpoint, "http://localhost:4318/v1/traces");
1104        assert_eq!(output.method, "POST");
1105        assert_eq!(output.content_type, "application/x-protobuf");
1106        assert_eq!(output.content_encoding, "gzip");
1107        assert_eq!(output.headers.as_ref().unwrap().len(), 2);
1108        assert_eq!(
1109            output.headers.as_ref().unwrap().get("api-key").unwrap(),
1110            "test-key"
1111        );
1112        assert_eq!(
1113            output
1114                .headers
1115                .as_ref()
1116                .unwrap()
1117                .get("custom-header")
1118                .unwrap(),
1119            "test-value"
1120        );
1121        assert_eq!(output.payload, "SGVsbG8gd29ybGQ=");
1122        assert!(output.base64);
1123
1124        // Verify that we can decode the base64 payload (if it's valid base64)
1125        let decoded = base64_engine.decode(&output.payload).unwrap();
1126        let payload_text = String::from_utf8(decoded).unwrap();
1127        assert_eq!(payload_text, "Hello world");
1128    }
1129
1130    #[test]
1131    fn test_exporter_output_deserialization_dynamic() {
1132        // Create a dynamic JSON string using String operations
1133        let version = "0.11.1".to_string();
1134        let service = "dynamic-service".to_string();
1135        let payload = base64_engine.encode("Dynamic payload");
1136
1137        // Build the JSON dynamically
1138        let json_str = format!(
1139            r#"{{
1140                "__otel_otlp_stdout": "{}",
1141                "source": "{}",
1142                "endpoint": "http://localhost:4318/v1/traces",
1143                "method": "POST",
1144                "content-type": "application/x-protobuf",
1145                "content-encoding": "gzip",
1146                "headers": {{
1147                    "dynamic-key": "dynamic-value"
1148                }},
1149                "payload": "{}",
1150                "base64": true
1151            }}"#,
1152            version, service, payload
1153        );
1154
1155        // Deserialize the dynamic JSON string
1156        let output: ExporterOutput = serde_json::from_str(&json_str).unwrap();
1157
1158        // Verify fields
1159        assert_eq!(output.version, version);
1160        assert_eq!(output.source, service);
1161        assert_eq!(output.endpoint, "http://localhost:4318/v1/traces");
1162        assert_eq!(output.method, "POST");
1163        assert_eq!(output.content_type, "application/x-protobuf");
1164        assert_eq!(output.content_encoding, "gzip");
1165        assert_eq!(output.headers.as_ref().unwrap().len(), 1);
1166        assert_eq!(
1167            output.headers.as_ref().unwrap().get("dynamic-key").unwrap(),
1168            "dynamic-value"
1169        );
1170        assert_eq!(output.payload, payload);
1171        assert!(output.base64);
1172
1173        // Verify payload decoding
1174        let decoded = base64_engine.decode(&output.payload).unwrap();
1175        let payload_text = String::from_utf8(decoded).unwrap();
1176        assert_eq!(payload_text, "Dynamic payload");
1177    }
1178
1179    #[test]
1180    fn test_log_level_from_str() {
1181        assert_eq!(LogLevel::from_str("debug").unwrap(), LogLevel::Debug);
1182        assert_eq!(LogLevel::from_str("DEBUG").unwrap(), LogLevel::Debug);
1183        assert_eq!(LogLevel::from_str("info").unwrap(), LogLevel::Info);
1184        assert_eq!(LogLevel::from_str("INFO").unwrap(), LogLevel::Info);
1185        assert_eq!(LogLevel::from_str("warn").unwrap(), LogLevel::Warn);
1186        assert_eq!(LogLevel::from_str("warning").unwrap(), LogLevel::Warn);
1187        assert_eq!(LogLevel::from_str("WARN").unwrap(), LogLevel::Warn);
1188        assert_eq!(LogLevel::from_str("error").unwrap(), LogLevel::Error);
1189        assert_eq!(LogLevel::from_str("ERROR").unwrap(), LogLevel::Error);
1190
1191        assert!(LogLevel::from_str("invalid").is_err());
1192    }
1193
1194    #[test]
1195    fn test_log_level_display() {
1196        assert_eq!(LogLevel::Debug.to_string(), "DEBUG");
1197        assert_eq!(LogLevel::Info.to_string(), "INFO");
1198        assert_eq!(LogLevel::Warn.to_string(), "WARN");
1199        assert_eq!(LogLevel::Error.to_string(), "ERROR");
1200    }
1201
1202    #[test]
1203    #[serial]
1204    fn test_log_level_from_env() {
1205        // Set environment variable
1206        std::env::set_var(env_vars::LOG_LEVEL, "debug");
1207        let exporter = OtlpStdoutSpanExporter::default();
1208        assert_eq!(exporter.level, Some(LogLevel::Debug));
1209
1210        // Test with invalid level
1211        std::env::set_var(env_vars::LOG_LEVEL, "invalid");
1212        let exporter = OtlpStdoutSpanExporter::default();
1213        assert_eq!(exporter.level, None);
1214
1215        // Test with constructor parameter
1216        std::env::remove_var(env_vars::LOG_LEVEL);
1217        let exporter = OtlpStdoutSpanExporter::builder()
1218            .level(LogLevel::Error)
1219            .build();
1220        assert_eq!(exporter.level, Some(LogLevel::Error));
1221
1222        // Test env var takes precedence over constructor
1223        std::env::set_var(env_vars::LOG_LEVEL, "warn");
1224        let exporter = OtlpStdoutSpanExporter::builder()
1225            .level(LogLevel::Error)
1226            .build();
1227        assert_eq!(exporter.level, Some(LogLevel::Warn));
1228
1229        // Clean up
1230        std::env::remove_var(env_vars::LOG_LEVEL);
1231    }
1232
1233    #[tokio::test]
1234    #[serial]
1235    async fn test_log_level_in_output() {
1236        // Create a test exporter with a specific log level
1237        let (mut exporter, output) = OtlpStdoutSpanExporter::with_test_output();
1238        exporter.level = Some(LogLevel::Debug);
1239        let span = create_test_span();
1240
1241        let result = exporter.export(vec![span]).await;
1242        assert!(result.is_ok());
1243
1244        let output_lines = output.get_output();
1245        assert_eq!(output_lines.len(), 1);
1246
1247        // Parse the JSON to check the level field
1248        let json: Value = serde_json::from_str(&output_lines[0]).unwrap();
1249        assert_eq!(json["level"], "DEBUG");
1250
1251        // Test with no level set
1252        let (mut exporter, output) = OtlpStdoutSpanExporter::with_test_output();
1253        exporter.level = None;
1254        let span = create_test_span();
1255
1256        let result = exporter.export(vec![span]).await;
1257        assert!(result.is_ok());
1258
1259        let output_lines = output.get_output();
1260        assert_eq!(output_lines.len(), 1);
1261
1262        // Parse the JSON to check level field is omitted
1263        let json: Value = serde_json::from_str(&output_lines[0]).unwrap();
1264        assert!(!json.as_object().unwrap().contains_key("level"));
1265    }
1266
1267    #[test]
1268    fn test_stdout_output() {
1269        let output = create_output(false);
1270        // We can't easily test stdout directly, but we can verify the type is created
1271        assert!(format!("{:?}", output).contains("StdOutput"));
1272    }
1273
1274    #[test]
1275    fn test_pipe_output() {
1276        let output = create_output(true);
1277        // Even if pipe doesn't exist, we should get a NamedPipeOutput or StdOutput fallback
1278        let debug_str = format!("{:?}", output);
1279        assert!(debug_str.contains("NamedPipeOutput") || debug_str.contains("StdOutput"));
1280    }
1281
1282    #[test]
1283    fn test_env_var_precedence() {
1284        // Create a temporary directory for testing
1285        let temp_dir = std::env::temp_dir();
1286        let path = temp_dir.join("test_pipe");
1287
1288        // Make sure no other environment variables interfere
1289        std::env::remove_var(env_vars::OUTPUT_TYPE);
1290
1291        // Set the environment variable to use pipe
1292        std::env::set_var(env_vars::OUTPUT_TYPE, "pipe");
1293
1294        // Create the exporter
1295        let exporter = OtlpStdoutSpanExporter::default();
1296
1297        // Verify the output type
1298        let debug_str = format!("{:?}", exporter.output);
1299        assert!(debug_str.contains("NamedPipeOutput") || debug_str.contains("StdOutput"));
1300
1301        // Clean up
1302        std::env::remove_var(env_vars::OUTPUT_TYPE);
1303        if path.exists() {
1304            let _ = std::fs::remove_file(path);
1305        }
1306    }
1307
1308    #[test]
1309    fn test_constructor_precedence() {
1310        // Create a temporary directory for testing
1311        let temp_dir = std::env::temp_dir();
1312        let path = temp_dir.join("test_pipe");
1313
1314        // Make sure the environment variable is not set
1315        std::env::remove_var(env_vars::OUTPUT_TYPE);
1316
1317        // Create the exporter with pipe output
1318        let exporter = OtlpStdoutSpanExporter::builder().pipe(true).build();
1319
1320        // Verify the output type
1321        let debug_str = format!("{:?}", exporter.output);
1322        assert!(debug_str.contains("NamedPipeOutput") || debug_str.contains("StdOutput"));
1323
1324        // Clean up
1325        if path.exists() {
1326            let _ = std::fs::remove_file(path);
1327        }
1328    }
1329
1330    #[test]
1331    fn test_env_var_overrides_constructor() {
1332        // Create temporary directory for testing
1333        let temp_dir = std::env::temp_dir();
1334        let path = temp_dir.join("test_pipe");
1335
1336        // Set the environment variable to use pipe
1337        std::env::set_var(env_vars::OUTPUT_TYPE, "pipe");
1338
1339        // Create the exporter with stdout in constructor
1340        let exporter = OtlpStdoutSpanExporter::builder().pipe(false).build();
1341
1342        // Verify that env var took precedence (pipe output)
1343        let debug_str = format!("{:?}", exporter.output);
1344        assert!(debug_str.contains("NamedPipeOutput") || debug_str.contains("StdOutput"));
1345
1346        // Clean up
1347        std::env::remove_var(env_vars::OUTPUT_TYPE);
1348        if path.exists() {
1349            let _ = std::fs::remove_file(path);
1350        }
1351    }
1352}