Skip to content

Commit 144d6f0

Browse files
committed
fix(otlp): prevent auth tokens from leaking in export error messages
1 parent 3c41f29 commit 144d6f0

6 files changed

Lines changed: 222 additions & 22 deletions

File tree

opentelemetry-otlp/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## vNext
44

5+
- Prevent auth tokens from leaking in export error messages. gRPC and HTTP
6+
exporter errors no longer include potentially sensitive server responses
7+
(e.g., authentication tokens echoed back). Error messages returned to SDK
8+
processors contain only the gRPC status code or HTTP status code. Full
9+
details are logged at DEBUG level only.
10+
[#3021](https://github.com/open-telemetry/opentelemetry-rust/issues/3021)
511
- Add support for `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` environment variable
612
to configure metrics temporality. Accepted values: `cumulative` (default), `delta`,
713
`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
@@ -488,7 +488,17 @@ impl OtlpHttpClient {
488488

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

494504
let status_code = response.status().as_u16();
@@ -499,12 +509,18 @@ impl OtlpHttpClient {
499509
.map(|s| s.to_string());
500510

501511
if !response.status().is_success() {
502-
let message = format!(
503-
"HTTP export failed. Url: {}, Status: {}, Response: {:?}",
504-
request_uri,
505-
status_code,
506-
response.body()
512+
// We don't log at WARN here because SDK processors (BatchLogProcessor,
513+
// BatchSpanProcessor, PeriodicReader) already log the returned error
514+
// via otel_error!. Response body may contain sensitive information
515+
// (e.g., auth tokens echoed back by the server), so log it at DEBUG
516+
// level only.
517+
otel_debug!(
518+
name: "HttpClient.StatusError",
519+
status_code = status_code,
520+
url = request_uri.as_str(),
521+
response_body = format!("{:?}", response.body())
507522
);
523+
let message = format!("HTTP export failed with status code: {status_code}");
508524
return Err(match retry_after {
509525
Some(retry_after) => {
510526
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: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,92 @@ where
398398
operation().await
399399
}
400400

401+
/// Log and convert a `tonic::Status` from a failed export into an `OTelSdkError`.
402+
///
403+
/// For connection-related errors (`Unavailable`, `DeadlineExceeded`,
404+
/// `ResourceExhausted`, `Aborted`, `Cancelled`), the message typically contains
405+
/// safe, actionable information (e.g., "Connection refused"), so it is included
406+
/// alongside the gRPC code in the debug log.
407+
///
408+
/// For all other errors — including `Unknown`, `Unauthenticated`, and
409+
/// `PermissionDenied` — the message and details are logged at DEBUG level only,
410+
/// since they may contain sensitive information such as authentication tokens
411+
/// echoed back by the server.
412+
///
413+
/// The returned `OTelSdkError` never contains the gRPC message, only the code.
414+
/// We don't log at WARN here because SDK processors (BatchLogProcessor,
415+
/// BatchSpanProcessor, PeriodicReader) already log the returned error via
416+
/// `otel_error!`.
417+
///
418+
/// `$client_name` must be a string literal so that `concat!` can produce
419+
/// compile-time event names consistent with the codebase naming convention.
420+
macro_rules! handle_tonic_export_error {
421+
($client_name:literal, $tonic_status:expr) => {{
422+
let status = &$tonic_status;
423+
let code = status.code();
424+
let is_connection_error = matches!(
425+
code,
426+
tonic::Code::Unavailable
427+
| tonic::Code::DeadlineExceeded
428+
| tonic::Code::ResourceExhausted
429+
| tonic::Code::Aborted
430+
| tonic::Code::Cancelled
431+
);
432+
if is_connection_error {
433+
otel_debug!(
434+
name: concat!($client_name, ".ExportFailed"),
435+
grpc_code = format!("{:?}", code),
436+
grpc_message = status.message()
437+
);
438+
} else {
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+
}
446+
Err(opentelemetry_sdk::error::OTelSdkError::InternalFailure(
447+
format!(
448+
concat!($client_name, " export failed with gRPC code: {:?}"),
449+
code
450+
),
451+
))
452+
}};
453+
}
454+
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+
401487
fn merge_metadata_with_headers_from_env(
402488
metadata: MetadataMap,
403489
headers_from_env: HeaderMap,
@@ -884,4 +970,100 @@ mod tests {
884970
let builder = TonicExporterBuilder::default();
885971
assert!(builder.tonic_config.retry_policy.is_none());
886972
}
973+
974+
#[cfg(any(feature = "trace", feature = "metrics", feature = "logs"))]
975+
mod error_handling_tests {
976+
use opentelemetry::otel_debug;
977+
use opentelemetry_sdk::error::OTelSdkError;
978+
979+
#[test]
980+
fn export_error_includes_grpc_code_but_not_sensitive_message() {
981+
let status = tonic::Status::unauthenticated("Bearer secret-token-123");
982+
let result: Result<(), OTelSdkError> =
983+
super::super::handle_tonic_export_error!("TestExporter", status);
984+
let msg = format!("{}", result.unwrap_err());
985+
986+
assert!(
987+
msg.contains("Unauthenticated"),
988+
"Error should contain the gRPC code, got: {msg}"
989+
);
990+
assert!(
991+
!msg.contains("secret-token-123"),
992+
"Error must not contain the sensitive token, got: {msg}"
993+
);
994+
}
995+
996+
#[test]
997+
fn export_error_includes_exporter_name() {
998+
let status = tonic::Status::unavailable("connection refused");
999+
let result: Result<(), OTelSdkError> =
1000+
super::super::handle_tonic_export_error!("TonicLogsClient", status);
1001+
let msg = format!("{}", result.unwrap_err());
1002+
1003+
assert!(
1004+
msg.contains("TonicLogsClient"),
1005+
"Error should identify the exporter, got: {msg}"
1006+
);
1007+
}
1008+
1009+
#[test]
1010+
fn export_error_never_includes_grpc_message() {
1011+
// Neither connection nor sensitive codes should leak the message
1012+
// into the returned error (messages are only logged, not returned)
1013+
let statuses = [
1014+
tonic::Status::unavailable("safe connection info"),
1015+
tonic::Status::unknown("safe connection info"),
1016+
tonic::Status::deadline_exceeded("safe connection info"),
1017+
tonic::Status::resource_exhausted("safe connection info"),
1018+
tonic::Status::aborted("safe connection info"),
1019+
tonic::Status::cancelled("safe connection info"),
1020+
tonic::Status::unauthenticated("Bearer my-secret-token"),
1021+
tonic::Status::permission_denied("Bearer my-secret-token"),
1022+
tonic::Status::internal("Bearer my-secret-token"),
1023+
];
1024+
for status in &statuses {
1025+
let result: Result<(), OTelSdkError> =
1026+
super::super::handle_tonic_export_error!("TestExporter", status);
1027+
let msg = format!("{}", result.unwrap_err());
1028+
assert!(
1029+
msg.contains("TestExporter export failed with gRPC code"),
1030+
"Expected structured error message, got: {msg}"
1031+
);
1032+
assert!(
1033+
!msg.contains("safe connection info") && !msg.contains("my-secret-token"),
1034+
"Error message should not include the gRPC message, got: {msg}"
1035+
);
1036+
}
1037+
}
1038+
1039+
#[test]
1040+
fn interceptor_error_returns_internal_status_without_sensitive_data() {
1041+
let original = tonic::Status::unauthenticated("Bearer secret");
1042+
let result = super::super::handle_interceptor_error!("TestExporter", original);
1043+
1044+
assert_eq!(result.code(), tonic::Code::Internal);
1045+
assert!(
1046+
result.message().contains("Unauthenticated"),
1047+
"Interceptor error should contain original gRPC code, got: {}",
1048+
result.message()
1049+
);
1050+
assert!(
1051+
!result.message().contains("secret"),
1052+
"Interceptor error must not leak sensitive data, got: {}",
1053+
result.message()
1054+
);
1055+
}
1056+
1057+
#[test]
1058+
fn interceptor_error_includes_exporter_name() {
1059+
let original = tonic::Status::internal("some error");
1060+
let result = super::super::handle_interceptor_error!("TonicTracesClient", original);
1061+
1062+
assert!(
1063+
result.message().contains("TonicTracesClient"),
1064+
"Interceptor error should identify the exporter, got: {}",
1065+
result.message()
1066+
);
1067+
}
1068+
}
8871069
}

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

0 commit comments

Comments
 (0)