Skip to content

Commit 6ec878f

Browse files
authored
feat(otlp): support per-signal protocol environment variables (#3363)
1 parent 146376f commit 6ec878f

8 files changed

Lines changed: 393 additions & 74 deletions

File tree

opentelemetry-otlp/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@
2929
DEBUG-only to preserve the auth-token leak safeguards from
3030
[#3021](https://github.com/open-telemetry/opentelemetry-rust/issues/3021).
3131
[#3331](https://github.com/open-telemetry/opentelemetry-rust/issues/3331)
32+
- Add support for per-signal protocol environment variables:
33+
`OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`, `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL`,
34+
`OTEL_EXPORTER_OTLP_LOGS_PROTOCOL`. These allow configuring different transport protocols
35+
per signal type. Signal-specific vars take precedence over generic `OTEL_EXPORTER_OTLP_PROTOCOL`.
36+
The auto-select `build()` method on each exporter builder now respects the full priority chain:
37+
signal-specific env var > generic env var > feature-based default.
38+
- Transport/protocol mismatch validation: HTTP transport returns `InvalidConfig` when gRPC protocol
39+
is requested; gRPC transport returns `InvalidConfig` when an HTTP protocol is requested.
40+
- **Breaking**: `Protocol::default()` no longer consults the `OTEL_EXPORTER_OTLP_PROTOCOL`
41+
environment variable. It now returns only the feature-based default (http-json > http-proto >
42+
grpc-tonic). Protocol resolution from environment variables is handled internally by the
43+
exporter builders. Users who relied on `Protocol::default()` to read env vars should use
44+
`Protocol::from_env()` instead.
3245
- Add support for `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` environment variable
3346
to configure metrics temporality. Accepted values: `cumulative` (default), `delta`,
3447
`lowmemory` (case-insensitive). Programmatic `.with_temporality()` overrides the env var.

opentelemetry-otlp/src/exporter/http/mod.rs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,7 @@ pub struct HttpExporterBuilder {
156156
impl Default for HttpExporterBuilder {
157157
fn default() -> Self {
158158
HttpExporterBuilder {
159-
exporter_config: ExportConfig {
160-
protocol: Protocol::default(),
161-
..ExportConfig::default()
162-
},
159+
exporter_config: ExportConfig::default(),
163160
http_config: HttpConfig {
164161
headers: Some(default_headers()),
165162
..HttpConfig::default()
@@ -176,7 +173,19 @@ impl HttpExporterBuilder {
176173
signal_timeout_var: &str,
177174
signal_http_headers_var: &str,
178175
signal_compression_var: &str,
176+
signal_protocol_var: &str,
179177
) -> Result<OtlpHttpClient, ExporterBuildError> {
178+
let protocol = super::resolve_protocol(signal_protocol_var, self.exporter_config.protocol);
179+
180+
// Validate protocol is compatible with HTTP transport
181+
#[cfg(feature = "grpc-tonic")]
182+
if matches!(protocol, Protocol::Grpc) {
183+
return Err(ExporterBuildError::InvalidConfig {
184+
name: "protocol".to_string(),
185+
reason: "gRPC protocol is not compatible with HTTP transport. Use `.with_tonic()` instead.".to_string(),
186+
});
187+
}
188+
180189
let endpoint = resolve_http_endpoint(
181190
signal_endpoint_var,
182191
signal_endpoint_path,
@@ -284,7 +293,7 @@ impl HttpExporterBuilder {
284293
http_client,
285294
endpoint,
286295
headers,
287-
self.exporter_config.protocol,
296+
protocol,
288297
timeout,
289298
compression,
290299
#[cfg(feature = "experimental-http-retry")]
@@ -299,12 +308,13 @@ impl HttpExporterBuilder {
299308
super::resolve_compression_from_env(self.http_config.compression, env_override)
300309
}
301310

302-
/// Create a log exporter with the current configuration
311+
/// Create a span exporter with the current configuration
303312
#[cfg(feature = "trace")]
304313
pub fn build_span_exporter(mut self) -> Result<crate::SpanExporter, ExporterBuildError> {
305314
use crate::{
306315
OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
307-
OTEL_EXPORTER_OTLP_TRACES_HEADERS, OTEL_EXPORTER_OTLP_TRACES_TIMEOUT,
316+
OTEL_EXPORTER_OTLP_TRACES_HEADERS, OTEL_EXPORTER_OTLP_TRACES_PROTOCOL,
317+
OTEL_EXPORTER_OTLP_TRACES_TIMEOUT,
308318
};
309319

310320
let client = self.build_client(
@@ -313,6 +323,7 @@ impl HttpExporterBuilder {
313323
OTEL_EXPORTER_OTLP_TRACES_TIMEOUT,
314324
OTEL_EXPORTER_OTLP_TRACES_HEADERS,
315325
OTEL_EXPORTER_OTLP_TRACES_COMPRESSION,
326+
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL,
316327
)?;
317328

318329
Ok(crate::SpanExporter::from_http(client))
@@ -323,7 +334,8 @@ impl HttpExporterBuilder {
323334
pub fn build_log_exporter(mut self) -> Result<crate::LogExporter, ExporterBuildError> {
324335
use crate::{
325336
OTEL_EXPORTER_OTLP_LOGS_COMPRESSION, OTEL_EXPORTER_OTLP_LOGS_ENDPOINT,
326-
OTEL_EXPORTER_OTLP_LOGS_HEADERS, OTEL_EXPORTER_OTLP_LOGS_TIMEOUT,
337+
OTEL_EXPORTER_OTLP_LOGS_HEADERS, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL,
338+
OTEL_EXPORTER_OTLP_LOGS_TIMEOUT,
327339
};
328340

329341
let client = self.build_client(
@@ -332,6 +344,7 @@ impl HttpExporterBuilder {
332344
OTEL_EXPORTER_OTLP_LOGS_TIMEOUT,
333345
OTEL_EXPORTER_OTLP_LOGS_HEADERS,
334346
OTEL_EXPORTER_OTLP_LOGS_COMPRESSION,
347+
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL,
335348
)?;
336349

337350
Ok(crate::LogExporter::from_http(client))
@@ -345,7 +358,8 @@ impl HttpExporterBuilder {
345358
) -> Result<crate::MetricExporter, ExporterBuildError> {
346359
use crate::{
347360
OTEL_EXPORTER_OTLP_METRICS_COMPRESSION, OTEL_EXPORTER_OTLP_METRICS_ENDPOINT,
348-
OTEL_EXPORTER_OTLP_METRICS_HEADERS, OTEL_EXPORTER_OTLP_METRICS_TIMEOUT,
361+
OTEL_EXPORTER_OTLP_METRICS_HEADERS, OTEL_EXPORTER_OTLP_METRICS_PROTOCOL,
362+
OTEL_EXPORTER_OTLP_METRICS_TIMEOUT,
349363
};
350364

351365
let client = self.build_client(
@@ -354,6 +368,7 @@ impl HttpExporterBuilder {
354368
OTEL_EXPORTER_OTLP_METRICS_TIMEOUT,
355369
OTEL_EXPORTER_OTLP_METRICS_HEADERS,
356370
OTEL_EXPORTER_OTLP_METRICS_COMPRESSION,
371+
OTEL_EXPORTER_OTLP_METRICS_PROTOCOL,
357372
)?;
358373

359374
Ok(crate::MetricExporter::from_http(client, temporality))

opentelemetry-otlp/src/exporter/mod.rs

Lines changed: 138 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ pub(crate) mod http;
5252
pub(crate) mod tonic;
5353

5454
/// Configuration for the OTLP exporter.
55-
#[derive(Debug)]
55+
#[derive(Debug, Default)]
5656
pub(crate) struct ExportConfig {
5757
/// The address of the OTLP collector.
5858
/// Default address will be used based on the protocol.
@@ -61,7 +61,9 @@ pub(crate) struct ExportConfig {
6161
pub endpoint: Option<String>,
6262

6363
/// The protocol to use when communicating with the collector.
64-
pub protocol: Protocol,
64+
/// `None` means the protocol will be resolved from environment variables
65+
/// or feature defaults at build time.
66+
pub protocol: Option<Protocol>,
6567

6668
/// The timeout to the collector.
6769
/// The default value is 10 seconds.
@@ -70,19 +72,26 @@ pub(crate) struct ExportConfig {
7072
pub timeout: Option<Duration>,
7173
}
7274

75+
/// Resolve protocol with priority:
76+
/// 1. Programmatic configuration (provided value)
77+
/// 2. Signal-specific environment variable
78+
/// 3. Generic OTEL_EXPORTER_OTLP_PROTOCOL environment variable
79+
/// 4. Feature-based default
7380
#[cfg(any(feature = "grpc-tonic", feature = "http-proto", feature = "http-json"))]
74-
impl Default for ExportConfig {
75-
fn default() -> Self {
76-
let protocol = Protocol::default();
77-
78-
Self {
79-
endpoint: None,
80-
// don't use default_endpoint(protocol) here otherwise we
81-
// won't know if user provided a value
82-
protocol,
83-
timeout: None,
84-
}
81+
pub(crate) fn resolve_protocol(
82+
signal_protocol_var: &str,
83+
provided_protocol: Option<Protocol>,
84+
) -> Protocol {
85+
if let Some(protocol) = provided_protocol {
86+
return protocol;
87+
}
88+
if let Some(protocol) = Protocol::parse_from_env_var(signal_protocol_var) {
89+
return protocol;
90+
}
91+
if let Some(protocol) = Protocol::from_env() {
92+
return protocol;
8593
}
94+
Protocol::feature_default()
8695
}
8796

8897
#[derive(Error, Debug)]
@@ -186,33 +195,19 @@ fn resolve_compression_from_env(
186195
}
187196
}
188197

189-
/// Returns the default protocol based on environment variable or enabled features.
198+
/// Returns the default protocol based on enabled features.
199+
///
200+
/// Note: This does not consult environment variables. Protocol resolution
201+
/// from environment variables is handled internally by the exporter builders.
190202
///
191203
/// Priority order (first available wins):
192-
/// 1. OTEL_EXPORTER_OTLP_PROTOCOL environment variable (if set and feature is enabled)
193-
/// 2. http-json (if enabled)
194-
/// 3. http-proto (if enabled)
195-
/// 4. grpc-tonic (if enabled)
204+
/// 1. http-json (if enabled)
205+
/// 2. http-proto (if enabled)
206+
/// 3. grpc-tonic (if enabled)
196207
#[cfg(any(feature = "grpc-tonic", feature = "http-proto", feature = "http-json"))]
197208
impl Default for Protocol {
198209
fn default() -> Self {
199-
// Check environment variable first
200-
if let Some(protocol) = Protocol::from_env() {
201-
return protocol;
202-
}
203-
204-
// Fall back to feature-based defaults
205-
#[cfg(feature = "http-json")]
206-
return Protocol::HttpJson;
207-
208-
#[cfg(all(feature = "http-proto", not(feature = "http-json")))]
209-
return Protocol::HttpBinary;
210-
211-
#[cfg(all(
212-
feature = "grpc-tonic",
213-
not(any(feature = "http-proto", feature = "http-json"))
214-
))]
215-
return Protocol::Grpc;
210+
Protocol::feature_default()
216211
}
217212
}
218213

@@ -287,7 +282,7 @@ impl<B: HasExportConfig> WithExportConfig for B {
287282
}
288283

289284
fn with_protocol(mut self, protocol: Protocol) -> Self {
290-
self.export_config().protocol = protocol;
285+
self.export_config().protocol = Some(protocol);
291286
self
292287
}
293288

@@ -503,21 +498,26 @@ mod tests {
503498
}
504499

505500
#[test]
506-
fn test_default_protocol_respects_env() {
507-
// Test that env var takes precedence over feature-based defaults
508-
#[cfg(all(feature = "http-json", feature = "http-proto"))]
509-
run_env_test(
510-
vec![(crate::OTEL_EXPORTER_OTLP_PROTOCOL, "http/protobuf")],
511-
|| {
512-
// Even though http-json would be the default, env var should override
513-
assert_eq!(crate::Protocol::default(), crate::Protocol::HttpBinary);
514-
},
515-
);
501+
fn test_default_protocol_ignores_env() {
502+
// Protocol::default() should always return the feature-based default,
503+
// NOT consult environment variables. Env var resolution is handled
504+
// by resolve_protocol().
516505

506+
// Without any env vars, default() equals feature_default()
507+
run_env_test(vec![], || {
508+
assert_eq!(
509+
crate::Protocol::default(),
510+
crate::Protocol::feature_default()
511+
);
512+
});
513+
514+
// Even with a valid env var set, default() still equals feature_default()
517515
#[cfg(all(feature = "grpc-tonic", feature = "http-json"))]
518516
run_env_test(vec![(crate::OTEL_EXPORTER_OTLP_PROTOCOL, "grpc")], || {
519-
// Even though http-json would be the default, env var should override
520-
assert_eq!(crate::Protocol::default(), crate::Protocol::Grpc);
517+
assert_eq!(
518+
crate::Protocol::default(),
519+
crate::Protocol::feature_default()
520+
);
521521
});
522522
}
523523

@@ -626,4 +626,94 @@ mod tests {
626626
assert_eq!(timeout.as_millis(), 10_000);
627627
});
628628
}
629+
630+
#[test]
631+
fn test_protocol_parse_from_env_var() {
632+
use crate::Protocol;
633+
634+
// Test with custom env var name
635+
temp_env::with_var_unset("MY_CUSTOM_PROTOCOL_VAR", || {
636+
assert_eq!(Protocol::parse_from_env_var("MY_CUSTOM_PROTOCOL_VAR"), None);
637+
});
638+
639+
#[cfg(feature = "http-proto")]
640+
run_env_test(vec![("MY_CUSTOM_PROTOCOL_VAR", "http/protobuf")], || {
641+
assert_eq!(
642+
Protocol::parse_from_env_var("MY_CUSTOM_PROTOCOL_VAR"),
643+
Some(Protocol::HttpBinary)
644+
);
645+
});
646+
647+
#[cfg(feature = "grpc-tonic")]
648+
run_env_test(vec![("MY_CUSTOM_PROTOCOL_VAR", "grpc")], || {
649+
assert_eq!(
650+
Protocol::parse_from_env_var("MY_CUSTOM_PROTOCOL_VAR"),
651+
Some(Protocol::Grpc)
652+
);
653+
});
654+
655+
// Invalid value returns None
656+
run_env_test(vec![("MY_CUSTOM_PROTOCOL_VAR", "invalid")], || {
657+
assert_eq!(Protocol::parse_from_env_var("MY_CUSTOM_PROTOCOL_VAR"), None);
658+
});
659+
}
660+
661+
#[cfg(feature = "http-proto")]
662+
#[test]
663+
fn test_resolve_protocol_signal_env_overrides_generic() {
664+
use crate::Protocol;
665+
666+
run_env_test(
667+
vec![
668+
(crate::OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, "http/protobuf"),
669+
(crate::OTEL_EXPORTER_OTLP_PROTOCOL, "grpc"),
670+
],
671+
|| {
672+
let protocol =
673+
super::resolve_protocol(crate::OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, None);
674+
assert_eq!(protocol, Protocol::HttpBinary);
675+
},
676+
);
677+
}
678+
679+
#[cfg(feature = "http-proto")]
680+
#[test]
681+
fn test_resolve_protocol_code_overrides_all_envs() {
682+
use crate::Protocol;
683+
684+
run_env_test(
685+
vec![
686+
(crate::OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, "grpc"),
687+
(crate::OTEL_EXPORTER_OTLP_PROTOCOL, "grpc"),
688+
],
689+
|| {
690+
let protocol = super::resolve_protocol(
691+
crate::OTEL_EXPORTER_OTLP_TRACES_PROTOCOL,
692+
Some(Protocol::HttpBinary),
693+
);
694+
assert_eq!(protocol, Protocol::HttpBinary);
695+
},
696+
);
697+
}
698+
699+
#[cfg(all(feature = "grpc-tonic", feature = "http-proto"))]
700+
#[test]
701+
fn test_resolve_protocol_falls_back_to_generic_env() {
702+
use crate::Protocol;
703+
704+
run_env_test(vec![(crate::OTEL_EXPORTER_OTLP_PROTOCOL, "grpc")], || {
705+
let protocol = super::resolve_protocol(crate::OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, None);
706+
assert_eq!(protocol, Protocol::Grpc);
707+
});
708+
}
709+
710+
#[test]
711+
fn test_resolve_protocol_falls_back_to_feature_default() {
712+
use crate::Protocol;
713+
714+
run_env_test(vec![], || {
715+
let protocol = super::resolve_protocol(crate::OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, None);
716+
assert_eq!(protocol, Protocol::feature_default());
717+
});
718+
}
629719
}

0 commit comments

Comments
 (0)