@@ -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+
421487fn 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}
0 commit comments