Skip to content

Commit 9e2e3db

Browse files
authored
OTLP tonic metadata from env variable (#1377)
Fixes #1336 As per the [specs](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.25.0/specification/protocol/exporter.md#specifying-headers-via-environment-variables), the custom headers for OTLP exporter can be specified through env variables - `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_EXPORTER_OTLP_TRACES_HEADERS`, `OTEL_EXPORTER_OTLP_METRICS_HEADERS`. This PR completes the work already done in PR #1290 adding support for tonic metadata To reproduce the same behavior as http exporter, the env-variable takes precedence (as discussed in open-telemetry/opentelemetry-rust-contrib#10) * Move common code for http and tonic exporters in `exporter/mod.rs` (function to parse header from string and test helper to run tests with isolated env variables) I wanted to minimize the changes but maybe it should be a good idea to use a crate like https://crates.io/crates/temp-env for environment related testing
1 parent a70bb74 commit 9e2e3db

File tree

5 files changed

+191
-41
lines changed

5 files changed

+191
-41
lines changed

opentelemetry-otlp/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- Add `grpcio` metrics exporter (#1202)
1111
- Allow specifying OTLP HTTP headers from env variable (#1290)
1212
- Support custom channels in topic exporters [#1335](https://github.com/open-telemetry/opentelemetry-rust/pull/1335)
13+
- Allow specifying OTLP Tonic metadata from env variable (#1377)
1314

1415
### Changed
1516

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

+8-36
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::str::FromStr;
1010
use std::sync::{Arc, Mutex};
1111
use std::time::Duration;
1212

13-
use super::default_headers;
13+
use super::{default_headers, parse_header_string};
1414

1515
#[cfg(feature = "metrics")]
1616
mod metrics;
@@ -316,46 +316,18 @@ fn resolve_endpoint(
316316

317317
#[allow(clippy::mutable_key_type)] // http headers are not mutated
318318
fn add_header_from_string(input: &str, headers: &mut HashMap<HeaderName, HeaderValue>) {
319-
for pair in input.split_terminator(',') {
320-
if pair.trim().is_empty() {
321-
continue;
322-
}
323-
if let Some((k, v)) = pair.trim().split_once('=') {
324-
if !k.trim().is_empty() && !v.trim().is_empty() {
325-
if let (Ok(key), Ok(value)) = (
326-
HeaderName::from_str(k.trim()),
327-
HeaderValue::from_str(v.trim()),
328-
) {
329-
headers.insert(key, value);
330-
}
331-
}
332-
}
333-
}
319+
headers.extend(parse_header_string(input).filter_map(|(key, value)| {
320+
Some((
321+
HeaderName::from_str(key).ok()?,
322+
HeaderValue::from_str(value).ok()?,
323+
))
324+
}));
334325
}
335326

336327
#[cfg(test)]
337328
mod tests {
329+
use crate::exporter::tests::run_env_test;
338330
use crate::{OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT};
339-
use std::sync::Mutex;
340-
341-
// Make sure env tests are not running concurrently
342-
static ENV_LOCK: Mutex<()> = Mutex::new(());
343-
344-
fn run_env_test<T, F>(env_vars: T, f: F)
345-
where
346-
F: FnOnce(),
347-
T: Into<Vec<(&'static str, &'static str)>>,
348-
{
349-
let _env_lock = ENV_LOCK.lock().expect("env test lock poisoned");
350-
let env_vars = env_vars.into();
351-
for (k, v) in env_vars.iter() {
352-
std::env::set_var(k, v);
353-
}
354-
f();
355-
for (k, _) in env_vars {
356-
std::env::remove_var(k);
357-
}
358-
}
359331

360332
#[test]
361333
fn test_append_signal_path_to_generic_env() {

opentelemetry-otlp/src/exporter/mod.rs

+75
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,78 @@ impl<B: HasExportConfig> WithExportConfig for B {
223223
self
224224
}
225225
}
226+
227+
#[cfg(any(feature = "grpc-tonic", feature = "http-proto"))]
228+
fn parse_header_string(value: &str) -> impl Iterator<Item = (&str, &str)> {
229+
value
230+
.split_terminator(',')
231+
.map(str::trim)
232+
.filter_map(parse_header_key_value_string)
233+
}
234+
235+
#[cfg(any(feature = "grpc-tonic", feature = "http-proto"))]
236+
fn parse_header_key_value_string(key_value_string: &str) -> Option<(&str, &str)> {
237+
key_value_string
238+
.split_once('=')
239+
.map(|(key, value)| (key.trim(), value.trim()))
240+
.filter(|(key, value)| !key.is_empty() && !value.is_empty())
241+
}
242+
243+
#[cfg(test)]
244+
#[cfg(any(feature = "grpc-tonic", feature = "http-proto"))]
245+
mod tests {
246+
// Make sure env tests are not running concurrently
247+
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
248+
249+
pub(crate) fn run_env_test<T, F>(env_vars: T, f: F)
250+
where
251+
F: FnOnce(),
252+
T: Into<Vec<(&'static str, &'static str)>>,
253+
{
254+
let _env_lock = ENV_LOCK.lock().expect("env test lock poisoned");
255+
let env_vars = env_vars.into();
256+
for (k, v) in env_vars.iter() {
257+
std::env::set_var(k, v);
258+
}
259+
f();
260+
for (k, _) in env_vars {
261+
std::env::remove_var(k);
262+
}
263+
}
264+
265+
#[test]
266+
fn test_parse_header_string() {
267+
let test_cases = vec![
268+
// Format: (input_str, expected_headers)
269+
("k1=v1", vec![("k1", "v1")]),
270+
("k1=v1,k2=v2", vec![("k1", "v1"), ("k2", "v2")]),
271+
("k1=v1=10,k2,k3", vec![("k1", "v1=10")]),
272+
("k1=v1,,,k2,k3=10", vec![("k1", "v1"), ("k3", "10")]),
273+
];
274+
275+
for (input_str, expected_headers) in test_cases {
276+
assert_eq!(
277+
super::parse_header_string(input_str).collect::<Vec<_>>(),
278+
expected_headers,
279+
)
280+
}
281+
}
282+
283+
#[test]
284+
fn test_parse_header_key_value_string() {
285+
let test_cases = vec![
286+
// Format: (input_str, expected_header)
287+
("k1=v1", Some(("k1", "v1"))),
288+
("", None),
289+
("=v1", None),
290+
("k1=", None),
291+
];
292+
293+
for (input_str, expected_headers) in test_cases {
294+
assert_eq!(
295+
super::parse_header_key_value_string(input_str),
296+
expected_headers,
297+
)
298+
}
299+
}
300+
}

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

+105-3
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
use std::env;
22
use std::fmt::{Debug, Formatter};
3+
use std::str::FromStr;
34
use std::time::Duration;
45

6+
use http::{HeaderMap, HeaderName, HeaderValue};
57
use tonic::codec::CompressionEncoding;
68
use tonic::metadata::{KeyAndValueRef, MetadataMap};
79
use tonic::service::Interceptor;
810
use tonic::transport::Channel;
911
#[cfg(feature = "tls")]
1012
use tonic::transport::ClientTlsConfig;
1113

12-
use super::default_headers;
14+
use super::{default_headers, parse_header_string};
1315
use crate::exporter::Compression;
1416
use crate::{
1517
ExportConfig, OTEL_EXPORTER_OTLP_COMPRESSION, OTEL_EXPORTER_OTLP_ENDPOINT,
16-
OTEL_EXPORTER_OTLP_TIMEOUT,
18+
OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_TIMEOUT,
1719
};
1820

1921
#[cfg(feature = "logs")]
@@ -213,11 +215,17 @@ impl TonicExporterBuilder {
213215
signal_endpoint_path: &str,
214216
signal_timeout_var: &str,
215217
signal_compression_var: &str,
218+
signal_headers_var: &str,
216219
) -> Result<(Channel, BoxInterceptor, Option<CompressionEncoding>), crate::Error> {
217220
let tonic_config = self.tonic_config;
218221
let compression = resolve_compression(&tonic_config, signal_compression_var)?;
219222

220-
let metadata = tonic_config.metadata.unwrap_or_default();
223+
let headers_from_env = parse_headers_from_env(signal_headers_var);
224+
let metadata = merge_metadata_with_headers_from_env(
225+
tonic_config.metadata.unwrap_or_default(),
226+
headers_from_env,
227+
);
228+
221229
let add_metadata = move |mut req: tonic::Request<()>| {
222230
for key_and_value in metadata.iter() {
223231
match key_and_value {
@@ -294,6 +302,7 @@ impl TonicExporterBuilder {
294302
"/v1/logs",
295303
crate::logs::OTEL_EXPORTER_OTLP_LOGS_TIMEOUT,
296304
crate::logs::OTEL_EXPORTER_OTLP_LOGS_COMPRESSION,
305+
crate::logs::OTEL_EXPORTER_OTLP_LOGS_HEADERS,
297306
)?;
298307

299308
let client = TonicLogsClient::new(channel, interceptor, compression);
@@ -316,6 +325,7 @@ impl TonicExporterBuilder {
316325
"/v1/metrics",
317326
crate::metric::OTEL_EXPORTER_OTLP_METRICS_TIMEOUT,
318327
crate::metric::OTEL_EXPORTER_OTLP_METRICS_COMPRESSION,
328+
crate::metric::OTEL_EXPORTER_OTLP_METRICS_HEADERS,
319329
)?;
320330

321331
let client = TonicMetricsClient::new(channel, interceptor, compression);
@@ -339,6 +349,7 @@ impl TonicExporterBuilder {
339349
"/v1/traces",
340350
crate::span::OTEL_EXPORTER_OTLP_TRACES_TIMEOUT,
341351
crate::span::OTEL_EXPORTER_OTLP_TRACES_COMPRESSION,
352+
crate::span::OTEL_EXPORTER_OTLP_TRACES_HEADERS,
342353
)?;
343354

344355
let client = TonicTracesClient::new(channel, interceptor, compression);
@@ -347,11 +358,44 @@ impl TonicExporterBuilder {
347358
}
348359
}
349360

361+
fn merge_metadata_with_headers_from_env(
362+
metadata: MetadataMap,
363+
headers_from_env: HeaderMap,
364+
) -> MetadataMap {
365+
if headers_from_env.is_empty() {
366+
metadata
367+
} else {
368+
let mut existing_headers: HeaderMap = metadata.into_headers();
369+
existing_headers.extend(headers_from_env);
370+
371+
MetadataMap::from_headers(existing_headers)
372+
}
373+
}
374+
375+
fn parse_headers_from_env(signal_headers_var: &str) -> HeaderMap {
376+
env::var(signal_headers_var)
377+
.or_else(|_| env::var(OTEL_EXPORTER_OTLP_HEADERS))
378+
.map(|input| {
379+
parse_header_string(&input)
380+
.filter_map(|(key, value)| {
381+
Some((
382+
HeaderName::from_str(key).ok()?,
383+
HeaderValue::from_str(value).ok()?,
384+
))
385+
})
386+
.collect::<HeaderMap>()
387+
})
388+
.unwrap_or_default()
389+
}
390+
350391
#[cfg(test)]
351392
mod tests {
393+
use crate::exporter::tests::run_env_test;
352394
#[cfg(feature = "gzip-tonic")]
353395
use crate::exporter::Compression;
354396
use crate::TonicExporterBuilder;
397+
use crate::{OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_TRACES_HEADERS};
398+
use http::{HeaderMap, HeaderName, HeaderValue};
355399
use tonic::metadata::{MetadataMap, MetadataValue};
356400

357401
#[test]
@@ -393,4 +437,62 @@ mod tests {
393437
let builder = TonicExporterBuilder::default().with_compression(Compression::Gzip);
394438
assert_eq!(builder.tonic_config.compression.unwrap(), Compression::Gzip);
395439
}
440+
441+
#[test]
442+
fn test_parse_headers_from_env() {
443+
run_env_test(
444+
vec![
445+
(OTEL_EXPORTER_OTLP_TRACES_HEADERS, "k1=v1,k2=v2"),
446+
(OTEL_EXPORTER_OTLP_HEADERS, "k3=v3"),
447+
],
448+
|| {
449+
assert_eq!(
450+
super::parse_headers_from_env(OTEL_EXPORTER_OTLP_TRACES_HEADERS),
451+
HeaderMap::from_iter([
452+
(
453+
HeaderName::from_static("k1"),
454+
HeaderValue::from_static("v1")
455+
),
456+
(
457+
HeaderName::from_static("k2"),
458+
HeaderValue::from_static("v2")
459+
),
460+
])
461+
);
462+
463+
assert_eq!(
464+
super::parse_headers_from_env("EMPTY_ENV"),
465+
HeaderMap::from_iter([(
466+
HeaderName::from_static("k3"),
467+
HeaderValue::from_static("v3")
468+
)])
469+
);
470+
},
471+
)
472+
}
473+
474+
#[test]
475+
fn test_merge_metadata_with_headers_from_env() {
476+
run_env_test(
477+
vec![(OTEL_EXPORTER_OTLP_TRACES_HEADERS, "k1=v1,k2=v2")],
478+
|| {
479+
let headers_from_env =
480+
super::parse_headers_from_env(OTEL_EXPORTER_OTLP_TRACES_HEADERS);
481+
482+
let mut metadata = MetadataMap::new();
483+
metadata.insert("foo", "bar".parse().unwrap());
484+
metadata.insert("k1", "v0".parse().unwrap());
485+
486+
let result =
487+
super::merge_metadata_with_headers_from_env(metadata, headers_from_env);
488+
489+
assert_eq!(
490+
result.get("foo").unwrap(),
491+
MetadataValue::from_static("bar")
492+
);
493+
assert_eq!(result.get("k1").unwrap(), MetadataValue::from_static("v1"));
494+
assert_eq!(result.get("k2").unwrap(), MetadataValue::from_static("v2"));
495+
},
496+
);
497+
}
396498
}

opentelemetry-otlp/src/lib.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -349,12 +349,12 @@ pub enum Error {
349349
RequestFailed(#[from] opentelemetry_http::HttpError),
350350

351351
/// The provided value is invalid in HTTP headers.
352-
#[cfg(feature = "http-proto")]
352+
#[cfg(any(feature = "grpc-tonic", feature = "http-proto"))]
353353
#[error("http header value error {0}")]
354354
InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
355355

356356
/// The provided name is invalid in HTTP headers.
357-
#[cfg(feature = "http-proto")]
357+
#[cfg(any(feature = "grpc-tonic", feature = "http-proto"))]
358358
#[error("http header name error {0}")]
359359
InvalidHeaderName(#[from] http::header::InvalidHeaderName),
360360

0 commit comments

Comments
 (0)