Skip to content

Commit 4eec13b

Browse files
authored
fix(otlp): prevent auth tokens from leaking in export error messages (#3360)
1 parent 5797d54 commit 4eec13b

File tree

7 files changed

+206
-25
lines changed

7 files changed

+206
-25
lines changed

opentelemetry-otlp/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
silently sending unencrypted traffic. When a TLS feature is enabled and an `https://` endpoint is used without
1414
an explicit `.with_tls_config()`, a default `ClientTlsConfig` is automatically applied.
1515
[#3182](https://github.com/open-telemetry/opentelemetry-rust/issues/3182)
16+
- Prevent auth tokens from leaking in export error messages. gRPC and HTTP
17+
exporter errors no longer include potentially sensitive server responses
18+
(e.g., authentication tokens echoed back). Error messages returned to SDK
19+
processors contain only the gRPC status code or HTTP status code. Full
20+
details are logged at DEBUG level only.
21+
[#3021](https://github.com/open-telemetry/opentelemetry-rust/issues/3021)
1622
- Add support for `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` environment variable
1723
to configure metrics temporality. Accepted values: `cumulative` (default), `delta`,
1824
`lowmemory` (case-insensitive). Programmatic `.with_temporality()` overrides the env var.

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,17 @@ impl OtlpHttpClient {
490490

491491
// Send request
492492
let response = client.send_bytes(request).await.map_err(|e| {
493-
HttpExportError::new(0, format!("Network error: {e:?}")) // Network error
493+
// Connection errors (e.g., "Connection refused", DNS failures) typically
494+
// indicate user-side misconfigurations and don't contain sensitive data.
495+
// We don't log at WARN here because SDK processors (BatchLogProcessor,
496+
// BatchSpanProcessor, PeriodicReader) already log the returned error
497+
// via otel_error!.
498+
otel_debug!(
499+
name: "HttpClient.NetworkError",
500+
url = request_uri.as_str(),
501+
error = format!("{e}")
502+
);
503+
HttpExportError::new(0, "HTTP export failed: network error".to_string())
494504
})?;
495505

496506
let status_code = response.status().as_u16();
@@ -501,12 +511,18 @@ impl OtlpHttpClient {
501511
.map(|s| s.to_string());
502512

503513
if !response.status().is_success() {
504-
let message = format!(
505-
"HTTP export failed. Url: {}, Status: {}, Response: {:?}",
506-
request_uri,
507-
status_code,
508-
response.body()
514+
// We don't log at WARN here because SDK processors (BatchLogProcessor,
515+
// BatchSpanProcessor, PeriodicReader) already log the returned error
516+
// via otel_error!. Response body may contain sensitive information
517+
// (e.g., auth tokens echoed back by the server), so log it at DEBUG
518+
// level only.
519+
otel_debug!(
520+
name: "HttpClient.StatusError",
521+
status_code = status_code,
522+
url = request_uri.as_str(),
523+
response_body = format!("{:?}", response.body())
509524
);
525+
let message = format!("HTTP export failed with status code: {status_code}");
510526
return Err(match retry_after {
511527
Some(retry_after) => {
512528
HttpExportError::with_retry_after(status_code, retry_after, message)

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,7 @@ impl LogExporter for TonicLogsClient {
9494
.interceptor
9595
.call(Request::new(()))
9696
.map_err(|e| {
97-
// Convert interceptor errors to tonic::Status for retry classification
98-
tonic::Status::internal(format!("interceptor error: {e:?}"))
97+
super::handle_interceptor_error!("TonicLogsClient", e)
9998
})?
10099
.into_parts();
101100
Ok((inner.client.clone(), m, e))
@@ -137,9 +136,9 @@ impl LogExporter for TonicLogsClient {
137136
.await
138137
{
139138
Ok(_) => Ok(()),
140-
Err(tonic_status) => Err(OTelSdkError::InternalFailure(format!(
141-
"export error: {tonic_status:?}"
142-
))),
139+
Err(tonic_status) => {
140+
super::handle_tonic_export_error!("TonicLogsClient", tonic_status)
141+
}
143142
}
144143
}
145144

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,7 @@ impl MetricsClient for TonicMetricsClient {
8585
.interceptor
8686
.call(Request::new(()))
8787
.map_err(|e| {
88-
tonic::Status::internal(format!(
89-
"unexpected status while exporting {e:?}"
90-
))
88+
super::handle_interceptor_error!("TonicMetricsClient", e)
9189
})?
9290
.into_parts();
9391
Ok((inner.client.clone(), m, e))
@@ -127,9 +125,9 @@ impl MetricsClient for TonicMetricsClient {
127125
.await
128126
{
129127
Ok(_) => Ok(()),
130-
Err(tonic_status) => Err(OTelSdkError::InternalFailure(format!(
131-
"export error: {tonic_status:?}"
132-
))),
128+
Err(tonic_status) => {
129+
super::handle_tonic_export_error!("TonicMetricsClient", tonic_status)
130+
}
133131
}
134132
}
135133

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

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,72 @@ where
418418
operation().await
419419
}
420420

421+
#[cfg(any(feature = "trace", feature = "metrics", feature = "logs"))]
422+
/// Log and convert a `tonic::Status` from a failed export into an `OTelSdkError`.
423+
///
424+
/// The gRPC code, message, and details are logged at DEBUG level only, since
425+
/// the message may contain sensitive information such as authentication tokens
426+
/// echoed back by the server.
427+
///
428+
/// The returned `OTelSdkError` never contains the gRPC message, only the code.
429+
/// We don't log at WARN here because SDK processors (BatchLogProcessor,
430+
/// BatchSpanProcessor, PeriodicReader) already log the returned error via
431+
/// `otel_error!`.
432+
///
433+
/// `$client_name` must be a string literal so that `concat!` can produce
434+
/// compile-time event names consistent with the codebase naming convention.
435+
macro_rules! handle_tonic_export_error {
436+
($client_name:literal, $tonic_status:expr) => {{
437+
let status = &$tonic_status;
438+
let code = status.code();
439+
otel_debug!(
440+
name: concat!($client_name, ".ExportFailed"),
441+
grpc_code = format!("{:?}", code),
442+
grpc_message = status.message(),
443+
grpc_details = format!("{:?}", status.details())
444+
);
445+
Err(opentelemetry_sdk::error::OTelSdkError::InternalFailure(
446+
format!(
447+
concat!($client_name, " export failed with gRPC code: {:?}"),
448+
code
449+
),
450+
))
451+
}};
452+
}
453+
454+
#[cfg(any(feature = "trace", feature = "metrics", feature = "logs"))]
455+
/// Log and convert a `tonic::Status` from a failed interceptor into a new
456+
/// `tonic::Status` suitable for retry classification.
457+
///
458+
/// Interceptor errors are always treated as potentially sensitive since
459+
/// interceptors are the primary mechanism for adding auth tokens. Only the
460+
/// gRPC code is included in the returned status message; the original message
461+
/// and details are logged at DEBUG level only.
462+
macro_rules! handle_interceptor_error {
463+
($client_name:literal, $e:expr) => {{
464+
let status = &$e;
465+
otel_debug!(
466+
name: concat!($client_name, ".InterceptorFailed"),
467+
grpc_code = format!("{:?}", status.code()),
468+
grpc_message = status.message(),
469+
grpc_details = format!("{:?}", status.details())
470+
);
471+
tonic::Status::internal(format!(
472+
concat!(
473+
$client_name,
474+
" export failed in interceptor with gRPC code: {:?}"
475+
),
476+
status.code()
477+
))
478+
}};
479+
}
480+
481+
// Make macros available to submodules (logs, trace, metrics).
482+
#[cfg(any(feature = "trace", feature = "metrics", feature = "logs"))]
483+
pub(crate) use handle_interceptor_error;
484+
#[cfg(any(feature = "trace", feature = "metrics", feature = "logs"))]
485+
pub(crate) use handle_tonic_export_error;
486+
421487
fn merge_metadata_with_headers_from_env(
422488
metadata: MetadataMap,
423489
headers_from_env: HeaderMap,
@@ -1071,4 +1137,100 @@ mod tests {
10711137
result.unwrap_err()
10721138
);
10731139
}
1140+
1141+
#[cfg(any(feature = "trace", feature = "metrics", feature = "logs"))]
1142+
mod error_handling_tests {
1143+
use opentelemetry::otel_debug;
1144+
use opentelemetry_sdk::error::OTelSdkError;
1145+
1146+
#[test]
1147+
fn export_error_includes_grpc_code_but_not_sensitive_message() {
1148+
let status = tonic::Status::unauthenticated("Bearer secret-token-123");
1149+
let result: Result<(), OTelSdkError> =
1150+
super::super::handle_tonic_export_error!("TestExporter", status);
1151+
let msg = format!("{}", result.unwrap_err());
1152+
1153+
assert!(
1154+
msg.contains("Unauthenticated"),
1155+
"Error should contain the gRPC code, got: {msg}"
1156+
);
1157+
assert!(
1158+
!msg.contains("secret-token-123"),
1159+
"Error must not contain the sensitive token, got: {msg}"
1160+
);
1161+
}
1162+
1163+
#[test]
1164+
fn export_error_includes_exporter_name() {
1165+
let status = tonic::Status::unavailable("connection refused");
1166+
let result: Result<(), OTelSdkError> =
1167+
super::super::handle_tonic_export_error!("TonicLogsClient", status);
1168+
let msg = format!("{}", result.unwrap_err());
1169+
1170+
assert!(
1171+
msg.contains("TonicLogsClient"),
1172+
"Error should identify the exporter, got: {msg}"
1173+
);
1174+
}
1175+
1176+
#[test]
1177+
fn export_error_never_includes_grpc_message() {
1178+
// Neither connection nor sensitive codes should leak the message
1179+
// into the returned error (messages are only logged, not returned)
1180+
let statuses = [
1181+
tonic::Status::unavailable("safe connection info"),
1182+
tonic::Status::unknown("safe connection info"),
1183+
tonic::Status::deadline_exceeded("safe connection info"),
1184+
tonic::Status::resource_exhausted("safe connection info"),
1185+
tonic::Status::aborted("safe connection info"),
1186+
tonic::Status::cancelled("safe connection info"),
1187+
tonic::Status::unauthenticated("Bearer my-secret-token"),
1188+
tonic::Status::permission_denied("Bearer my-secret-token"),
1189+
tonic::Status::internal("Bearer my-secret-token"),
1190+
];
1191+
for status in &statuses {
1192+
let result: Result<(), OTelSdkError> =
1193+
super::super::handle_tonic_export_error!("TestExporter", status);
1194+
let msg = format!("{}", result.unwrap_err());
1195+
assert!(
1196+
msg.contains("TestExporter export failed with gRPC code"),
1197+
"Expected structured error message, got: {msg}"
1198+
);
1199+
assert!(
1200+
!msg.contains("safe connection info") && !msg.contains("my-secret-token"),
1201+
"Error message should not include the gRPC message, got: {msg}"
1202+
);
1203+
}
1204+
}
1205+
1206+
#[test]
1207+
fn interceptor_error_returns_internal_status_without_sensitive_data() {
1208+
let original = tonic::Status::unauthenticated("Bearer secret");
1209+
let result = super::super::handle_interceptor_error!("TestExporter", original);
1210+
1211+
assert_eq!(result.code(), tonic::Code::Internal);
1212+
assert!(
1213+
result.message().contains("Unauthenticated"),
1214+
"Interceptor error should contain original gRPC code, got: {}",
1215+
result.message()
1216+
);
1217+
assert!(
1218+
!result.message().contains("secret"),
1219+
"Interceptor error must not leak sensitive data, got: {}",
1220+
result.message()
1221+
);
1222+
}
1223+
1224+
#[test]
1225+
fn interceptor_error_includes_exporter_name() {
1226+
let original = tonic::Status::internal("some error");
1227+
let result = super::super::handle_interceptor_error!("TonicTracesClient", original);
1228+
1229+
assert!(
1230+
result.message().contains("TonicTracesClient"),
1231+
"Interceptor error should identify the exporter, got: {}",
1232+
result.message()
1233+
);
1234+
}
1235+
}
10741236
}

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,7 @@ impl SpanExporter for TonicTracesClient {
9696
.interceptor
9797
.call(Request::new(()))
9898
.map_err(|e| {
99-
// Convert interceptor errors to tonic::Status for retry classification
100-
tonic::Status::internal(format!("interceptor error: {e:?}"))
99+
super::handle_interceptor_error!("TonicTracesClient", e)
101100
})?
102101
.into_parts();
103102
Ok((inner.client.clone(), m, e))
@@ -140,9 +139,9 @@ impl SpanExporter for TonicTracesClient {
140139
.await
141140
{
142141
Ok(_) => Ok(()),
143-
Err(tonic_status) => Err(OTelSdkError::InternalFailure(format!(
144-
"export error: {tonic_status:?}"
145-
))),
142+
Err(tonic_status) => {
143+
super::handle_tonic_export_error!("TonicTracesClient", tonic_status)
144+
}
146145
}
147146
}
148147

opentelemetry-otlp/tests/integration_test/tests/metrics_roundtrip.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ mod metrictests_roundtrip {
2121
use integration_test_runner::metric_helpers::{
2222
self, validate_metrics_against_results, SLEEP_DURATION,
2323
};
24+
use opentelemetry::metrics::MeterProvider;
2425

2526
use super::*;
2627

@@ -54,7 +55,7 @@ mod metrictests_roundtrip {
5455
const METER_NAME: &str = "test_u64_counter_meter";
5556

5657
// Add data to u64_counter
57-
let meter = opentelemetry::global::meter_provider().meter(METER_NAME);
58+
let meter = meter_provider.meter(METER_NAME);
5859

5960
let counter = meter.u64_counter("counter_u64").build();
6061
counter.add(
@@ -80,7 +81,7 @@ mod metrictests_roundtrip {
8081
const METER_NAME: &str = "test_histogram_meter";
8182

8283
// Add data to histogram
83-
let meter = opentelemetry::global::meter_provider().meter(METER_NAME);
84+
let meter = meter_provider.meter(METER_NAME);
8485
let histogram = meter.u64_histogram("example_histogram").build();
8586
histogram.record(42, &[KeyValue::new("mykey3", "myvalue4")]);
8687

@@ -98,7 +99,7 @@ mod metrictests_roundtrip {
9899
const METER_NAME: &str = "test_up_down_meter";
99100

100101
// Add data to up_down_counter
101-
let meter = opentelemetry::global::meter_provider().meter(METER_NAME);
102+
let meter = meter_provider.meter(METER_NAME);
102103
let up_down_counter = meter.i64_up_down_counter("example_up_down_counter").build();
103104
up_down_counter.add(-1, &[KeyValue::new("mykey5", "myvalue5")]);
104105

0 commit comments

Comments
 (0)