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