LCOV - code coverage report
Current view: top level - lib/src/trace/export/otlp - otlp_grpc_span_exporter.dart (source / functions) Coverage Total Hit
Test: lcov.info Lines: 80.9 % 262 212
Test Date: 2026-08-27 23:42:02 Functions: - 0 0

            Line data    Source code
       1              : // Copyright The OpenTelemetry Authors
       2              : // SPDX-License-Identifier: Apache-2.0
       3              : 
       4              : import 'dart:async';
       5              : import 'dart:math';
       6              : 
       7              : import 'package:dartastic_opentelemetry_api/dartastic_opentelemetry_api.dart'
       8              :     show OTelLog;
       9              : import 'package:grpc/grpc.dart';
      10              : 
      11              : import '../../../../proto/opentelemetry_proto_dart.dart' as proto;
      12              : import '../../../export/otlp_user_agent.dart';
      13              : import '../../span.dart';
      14              : import '../../span_logger.dart';
      15              : import '../span_exporter.dart';
      16              : import 'certificate_utils_io.dart';
      17              : import 'otlp_grpc_span_exporter_config.dart';
      18              : import 'span_transformer.dart';
      19              : 
      20              : /// An OpenTelemetry span exporter that exports spans using OTLP over gRPC.
      21              : ///
      22              : /// This exporter sends trace data to an OpenTelemetry collector or compatible backend
      23              : /// using the OpenTelemetry Protocol (OTLP) over gRPC. It supports features such as:
      24              : /// - Retrying failed exports with exponential backoff
      25              : /// - Secure and insecure connections
      26              : /// - Custom headers and timeouts
      27              : /// - Compression
      28              : class OtlpGrpcSpanExporter implements SpanExporter {
      29              :   static const _retryableStatusCodes = [
      30              :     // Note: Don't retry on deadline exceeded as it indicates a timeout
      31              :     StatusCode.resourceExhausted, // Maps to HTTP 429
      32              :     StatusCode.unavailable, // Maps to HTTP 503
      33              :   ];
      34              : 
      35              :   final OtlpGrpcExporterConfig _config;
      36              :   ClientChannel? _channel;
      37              :   proto.TraceServiceClient? _traceService;
      38              :   bool _isShutdown = false;
      39              :   final Random _random = Random();
      40              :   final List<Future<void>> _pendingExports = [];
      41              : 
      42              :   /// Creates a new OtlpGrpcSpanExporter with the specified configuration.
      43              :   ///
      44              :   /// If no configuration is provided, default values will be used.
      45              :   ///
      46              :   /// @param config Optional configuration for the exporter
      47            9 :   OtlpGrpcSpanExporter([OtlpGrpcExporterConfig? config])
      48            1 :       : _config = config ?? OtlpGrpcExporterConfig();
      49              :   bool _initialized = false;
      50              : 
      51              :   /// Creates channel credentials based on configuration.
      52              :   ///
      53              :   /// If insecure is true, returns insecure credentials.
      54              :   /// Otherwise, creates secure credentials with optional custom certificates for mTLS.
      55            5 :   ChannelCredentials _createChannelCredentials() {
      56           10 :     if (_config.insecure) {
      57              :       return const ChannelCredentials.insecure();
      58              :     }
      59              : 
      60              :     // If no custom certificates are provided, use default secure credentials
      61            4 :     if (_config.certificate == null &&
      62            4 :         _config.clientKey == null &&
      63            4 :         _config.clientCertificate == null) {
      64              :       return const ChannelCredentials.secure();
      65              :     }
      66              : 
      67              :     try {
      68            0 :       final context = CertificateUtils.createSecurityContext(
      69            0 :         certificate: _config.certificate,
      70            0 :         clientKey: _config.clientKey,
      71            0 :         clientCertificate: _config.clientCertificate,
      72              :       );
      73              : 
      74              :       if (context == null) {
      75              :         return const ChannelCredentials.secure();
      76              :       }
      77              : 
      78              :       return const ChannelCredentials.secure(
      79              :         certificates: null, // We're using SecurityContext instead
      80              :         authority: null,
      81              :         onBadCertificate: null,
      82              :       );
      83              :     } catch (e) {
      84            0 :       if (OTelLog.isError()) {
      85            0 :         OTelLog.error('OtlpGrpcSpanExporter: Failed to load certificates: $e');
      86              :       }
      87              :       // Fall back to default secure credentials on error
      88              :       return const ChannelCredentials.secure();
      89              :     }
      90              :   }
      91              : 
      92              :   /// Cleanup the gRPC channel and release resources
      93            9 :   Future<void> _cleanupChannel() async {
      94            9 :     if (_channel != null) {
      95            5 :       if (OTelLog.isDebug()) {
      96            4 :         OTelLog.debug('OtlpGrpcSpanExporter: Shutting down existing channel');
      97              :       }
      98              : 
      99              :       try {
     100              :         // First try a graceful shutdown
     101              :         try {
     102            5 :           if (OTelLog.isDebug()) {
     103            4 :             OTelLog.debug(
     104              :               'OtlpGrpcSpanExporter: Attempting graceful channel shutdown',
     105              :             );
     106              :           }
     107           10 :           await _channel!.shutdown();
     108            5 :           await Future<void>.delayed(
     109              :             const Duration(milliseconds: 100),
     110              :           ); // Brief delay for shutdown to complete
     111              :         } catch (e) {
     112            0 :           if (OTelLog.isDebug()) {
     113            0 :             OTelLog.debug(
     114            0 :               'OtlpGrpcSpanExporter: Error during graceful shutdown: $e',
     115              :             );
     116              :           }
     117              :         }
     118              : 
     119              :         // Then try to terminate to ensure cleanup
     120              :         try {
     121            5 :           if (OTelLog.isDebug()) {
     122            4 :             OTelLog.debug('OtlpGrpcSpanExporter: Terminating channel');
     123              :           }
     124           15 :           unawaited(_channel!.terminate());
     125            5 :           await Future<void>.delayed(
     126              :             const Duration(milliseconds: 100),
     127              :           ); // Brief delay for termination to complete
     128              :         } catch (e) {
     129            0 :           if (OTelLog.isDebug()) {
     130            0 :             OTelLog.debug(
     131            0 :               'OtlpGrpcSpanExporter: Error terminating channel: $e',
     132              :             );
     133              :           }
     134              :         }
     135              :       } catch (e) {
     136            0 :         if (OTelLog.isError()) {
     137            0 :           OTelLog.error(
     138            0 :             'OtlpGrpcSpanExporter: Error shutting down existing channel: $e',
     139              :           );
     140              :         }
     141              :       }
     142              : 
     143              :       // Set to null to allow garbage collection
     144            5 :       _channel = null;
     145            5 :       _traceService = null;
     146              : 
     147              :       // Force garbage collection if possible
     148              :       try {
     149              :         // In Dart, we can't directly force garbage collection,
     150              :         // but we can suggest it by setting variables to null and
     151              :         // creating some memory pressure
     152            5 :         final temp = <int>[];
     153           10 :         for (var i = 0; i < 1000; i++) {
     154            5 :           temp.add(i);
     155              :         }
     156            5 :         temp.clear();
     157              :       } catch (e) {
     158              :         // Ignore any errors
     159              :       }
     160              :     }
     161              :   }
     162              : 
     163            5 :   Future<void> _setupChannel() async {
     164            5 :     if (_isShutdown) {
     165            0 :       if (OTelLog.isDebug()) {
     166            0 :         OTelLog.debug(
     167              :           'OtlpGrpcSpanExporter: Not setting up channel - exporter is shut down',
     168              :         );
     169              :       }
     170              :       return;
     171              :     }
     172              : 
     173            5 :     if (OTelLog.isDebug()) {
     174            4 :       OTelLog.debug(
     175           12 :         'OtlpGrpcSpanExporter: Setting up gRPC channel with endpoint ${_config.endpoint}',
     176              :       );
     177              :     }
     178              : 
     179              :     // First, clean up any existing channel
     180            5 :     await _cleanupChannel();
     181              : 
     182              :     String host;
     183              :     int port;
     184              : 
     185              :     try {
     186           20 :       final endpoint = _config.endpoint.trim().replaceAll(
     187            5 :             RegExp(r'^(http://|https://)'),
     188              :             '',
     189              :           );
     190            5 :       final parts = endpoint.split(':');
     191           15 :       host = parts[0].isEmpty ? '127.0.0.1' : parts[0];
     192           20 :       port = parts.length > 1 ? int.parse(parts[1]) : 4317;
     193              : 
     194              :       // Replace localhost with 127.0.0.1 for more reliable connections
     195            5 :       if (host == 'localhost') {
     196              :         host = '127.0.0.1';
     197              :       }
     198              : 
     199            5 :       if (OTelLog.isDebug()) {
     200            4 :         OTelLog.debug(
     201            4 :           'OtlpGrpcSpanExporter: Setting up gRPC channel to $host:$port',
     202              :         );
     203              :       }
     204              : 
     205              :       // Create a channel
     206           10 :       _channel ??= ClientChannel(
     207              :         host,
     208              :         port: port,
     209            5 :         options: ChannelOptions(
     210            5 :           credentials: _createChannelCredentials(),
     211              :           userAgent: otlpUserAgent,
     212              :           connectTimeout: const Duration(seconds: 5),
     213              :           // Keep connection alive better
     214              :           idleTimeout: const Duration(seconds: 30),
     215            5 :           codecRegistry: CodecRegistry(
     216              :             codecs: const [GzipCodec(), IdentityCodec()],
     217              :           ),
     218              :         ),
     219              :       );
     220              : 
     221              :       try {
     222           15 :         _traceService = proto.TraceServiceClient(_channel!);
     223            5 :         if (OTelLog.isDebug()) {
     224            4 :           OTelLog.debug(
     225              :             'OtlpGrpcSpanExporter: Successfully created TraceServiceClient',
     226              :           );
     227              :         }
     228              :       } catch (e) {
     229            0 :         if (OTelLog.isError()) {
     230            0 :           OTelLog.error(
     231            0 :             'OtlpGrpcSpanExporter: Failed to create TraceServiceClient: $e',
     232              :           );
     233              :         }
     234              :         rethrow;
     235              :       }
     236            5 :       if (OTelLog.isDebug()) {
     237            4 :         OTelLog.debug(
     238              :           'OtlpGrpcSpanExporter: Successfully created gRPC channel and trace service',
     239              :         );
     240              :       }
     241              :     } catch (e, stackTrace) {
     242            0 :       if (OTelLog.isError()) {
     243            0 :         OTelLog.error(
     244            0 :           'OtlpGrpcSpanExporter: Failed to setup gRPC channel: $e',
     245              :         );
     246              :       }
     247            0 :       if (OTelLog.isError()) OTelLog.error('Stack trace: $stackTrace');
     248              :       rethrow;
     249              :     }
     250              :   }
     251              : 
     252            5 :   Future<void> _ensureChannel() async {
     253            5 :     if (_isShutdown) {
     254            0 :       if (OTelLog.isDebug()) {
     255            0 :         OTelLog.debug(
     256              :           'OtlpGrpcSpanExporter: Not ensuring channel - exporter is shut down',
     257              :         );
     258              :       }
     259            0 :       throw StateError('Exporter is shutdown');
     260              :     }
     261              : 
     262            8 :     if (_initialized && _channel != null && _traceService != null) {
     263              :       return;
     264              :     }
     265              : 
     266            5 :     _initialized = true;
     267            8 :     if (_channel == null || _traceService == null) {
     268            5 :       await _setupChannel();
     269              :     }
     270              :   }
     271              : 
     272            3 :   Duration _calculateJitteredDelay(int retries) {
     273            9 :     final baseMs = _config.baseDelay.inMilliseconds;
     274            6 :     final delay = baseMs * pow(2, retries);
     275            9 :     final jitter = _random.nextDouble() * delay;
     276            9 :     return Duration(milliseconds: (delay + jitter).toInt());
     277              :   }
     278              : 
     279            5 :   Future<void> _tryExport(List<Span> spans) async {
     280            5 :     await _ensureChannel();
     281            5 :     if (_isShutdown) {
     282            0 :       throw StateError('Exporter is shutdown');
     283              :     }
     284            5 :     if (OTelLog.isLogSpans()) {
     285            3 :       logSpans(spans, 'Exporting spans.');
     286              :     }
     287              : 
     288            5 :     if (OTelLog.isDebug()) {
     289            4 :       OTelLog.debug(
     290            8 :         'OtlpGrpcSpanExporter: Preparing to export ${spans.length} spans',
     291              :       );
     292            8 :       for (var span in spans) {
     293            4 :         OTelLog.debug(
     294           24 :           '  Span: ${span.name}, spanId: ${span.spanContext.spanId}, traceId: ${span.spanContext.traceId}',
     295              :         );
     296              :       }
     297              :     }
     298              : 
     299            5 :     if (OTelLog.isDebug()) {
     300           12 :       OTelLog.debug('OtlpGrpcSpanExporter: Transforming ${spans.length} spans');
     301              :     }
     302            5 :     final request = OtlpSpanTransformer.transformSpans(spans);
     303            5 :     if (OTelLog.isDebug()) {
     304            4 :       OTelLog.debug('OtlpGrpcSpanExporter: Successfully transformed spans');
     305              :     }
     306              : 
     307            5 :     if (OTelLog.isDebug()) {
     308            8 :       for (var rs in request.resourceSpans) {
     309            4 :         OTelLog.debug('  ResourceSpan:');
     310            4 :         if (rs.hasResource()) {
     311            4 :           OTelLog.debug('    Resource attributes:');
     312           12 :           for (var attr in rs.resource.attributes) {
     313           16 :             OTelLog.debug('      ${attr.key}: ${attr.value}');
     314              :           }
     315              :         }
     316            8 :         for (var ss in rs.scopeSpans) {
     317            4 :           OTelLog.debug('    ScopeSpan:');
     318            8 :           for (var span in ss.spans) {
     319           12 :             OTelLog.debug('      Span: ${span.name}');
     320           12 :             OTelLog.debug('        TraceId: ${span.traceId}');
     321           12 :             OTelLog.debug('        SpanId: ${span.spanId}');
     322              :           }
     323              :         }
     324              :       }
     325              :     }
     326              : 
     327              :     // Add compression header if configured
     328           15 :     final headers = Map<String, String>.from(_config.headers);
     329           10 :     if (_config.compression) {
     330            1 :       headers['grpc-encoding'] = 'gzip';
     331              :     }
     332              : 
     333            5 :     final options = CallOptions(
     334           10 :       timeout: _config.timeout,
     335              :       metadata: headers,
     336              :     );
     337              : 
     338            5 :     if (OTelLog.isDebug()) {
     339            4 :       OTelLog.debug(
     340           12 :         'OtlpGrpcSpanExporter: Sending export request to ${_config.endpoint}',
     341              :       );
     342              :     }
     343              :     try {
     344            5 :       if (_traceService == null) {
     345            0 :         throw StateError(
     346              :           'Trace service is null, channel may not be properly initialized',
     347              :         );
     348              :       }
     349              : 
     350              :       // gRPC's CallOptions deadline (set above) can fail to terminate a call
     351              :       // when the connection is torn down mid-flight — e.g. a half-open or
     352              :       // broken collector that accepts then drops the TCP connection — leaving
     353              :       // this await hung well past _config.timeout. This Dart-level backstop
     354              :       // guarantees export() is bounded by the configured timeout regardless of
     355              :       // the gRPC client's connection-state behavior. On timeout we tear the
     356              :       // channel down so the leaked RPC is aborted and the next attempt (if any)
     357              :       // reconnects cleanly.
     358              :       final response =
     359           15 :           await _traceService!.export(request, options: options).timeout(
     360           10 :         _config.timeout,
     361            0 :         onTimeout: () {
     362            0 :           unawaited(_cleanupChannel());
     363            0 :           throw TimeoutException(
     364              :             'OtlpGrpcSpanExporter: export exceeded configured timeout',
     365            0 :             _config.timeout,
     366              :           );
     367              :         },
     368              :       );
     369            4 :       if (OTelLog.isDebug()) {
     370            3 :         OTelLog.debug(
     371              :           'OtlpGrpcSpanExporter: Export request completed successfully',
     372              :         );
     373              :       }
     374            4 :       if (OTelLog.isDebug()) {
     375            6 :         OTelLog.debug('OtlpGrpcSpanExporter: Response: $response');
     376              :       }
     377              :     } catch (e, stackTrace) {
     378            3 :       if (OTelLog.isError()) {
     379            6 :         OTelLog.error('OtlpGrpcSpanExporter: Export request failed: $e');
     380            6 :         OTelLog.error('Stack trace: $stackTrace');
     381              :       }
     382              : 
     383              :       // If we have a channel error, try to recreate it
     384            3 :       if (e is GrpcError &&
     385            6 :           (e.code == StatusCode.unavailable ||
     386            6 :               e.code == StatusCode.unknown ||
     387            4 :               e.code == StatusCode.internal)) {
     388            3 :         if (OTelLog.isDebug()) {
     389            3 :           OTelLog.debug(
     390              :             'OtlpGrpcSpanExporter: Channel error detected, recreating channel',
     391              :           );
     392              :         }
     393              :         // Force channel recreation
     394            3 :         await _cleanupChannel();
     395            3 :         _initialized = false;
     396              :       }
     397              : 
     398              :       rethrow;
     399              :     }
     400              :   }
     401              : 
     402            6 :   @override
     403              :   Future<void> export(List<Span> spans) async {
     404            6 :     if (_isShutdown) {
     405            4 :       throw StateError('Exporter is shutdown');
     406              :     }
     407              : 
     408            6 :     if (spans.isEmpty) {
     409            3 :       if (OTelLog.isDebug()) {
     410            3 :         OTelLog.debug('OtlpGrpcSpanExporter: No spans to export');
     411              :       }
     412              :       return;
     413              :     }
     414              : 
     415            5 :     if (OTelLog.isDebug()) {
     416            4 :       OTelLog.debug(
     417            8 :         'OtlpGrpcSpanExporter: Beginning export of ${spans.length} spans',
     418              :       );
     419              :     }
     420            5 :     final exportFuture = _export(spans);
     421              : 
     422              :     // Track the pending export but don't throw if it fails during shutdown
     423           10 :     _pendingExports.add(exportFuture);
     424              :     try {
     425              :       await exportFuture;
     426            4 :       if (OTelLog.isDebug()) {
     427            3 :         OTelLog.debug('OtlpGrpcSpanExporter: Export completed successfully');
     428              :       }
     429              :     } catch (e) {
     430            3 :       if (_isShutdown &&
     431            1 :           e is StateError &&
     432            2 :           e.message.contains('shut down during')) {
     433              :         // Gracefully handle the case where shutdown interrupted the export
     434            1 :         if (OTelLog.isDebug()) {
     435            1 :           OTelLog.debug(
     436              :             'OtlpGrpcSpanExporter: Export was interrupted by shutdown, suppressing error',
     437              :           );
     438              :         }
     439              :       } else {
     440              :         // Re-throw other errors
     441              :         rethrow;
     442              :       }
     443              :     } finally {
     444           10 :       _pendingExports.remove(exportFuture);
     445              :     }
     446              :   }
     447              : 
     448            5 :   Future<void> _export(List<Span> spans) async {
     449            5 :     if (_isShutdown) {
     450            0 :       throw StateError('Exporter was shut down during export');
     451              :     }
     452              : 
     453            5 :     if (OTelLog.isDebug()) {
     454            4 :       OTelLog.debug(
     455           16 :         'OtlpGrpcSpanExporter: Attempting to export ${spans.length} spans to ${_config.endpoint}',
     456              :       );
     457              :     }
     458              : 
     459              :     var attempts = 0;
     460           15 :     final maxAttempts = _config.maxRetries + 1; // Initial attempt + retries
     461              : 
     462            5 :     while (attempts < maxAttempts) {
     463              :       // Allow the export to continue even during shutdown, so we complete in-flight requests
     464            5 :       final wasShutdownDuringRetry = _isShutdown;
     465              : 
     466              :       try {
     467              :         // Only check for shutdown on retry attempts to ensure in-progress exports can complete
     468            1 :         if (wasShutdownDuringRetry && attempts > 0) {
     469            1 :           if (OTelLog.isDebug()) {
     470            1 :             OTelLog.debug(
     471              :               'OtlpGrpcSpanExporter: Export interrupted by shutdown',
     472              :             );
     473              :           }
     474            1 :           throw StateError('Exporter was shut down during export');
     475              :         }
     476              : 
     477            5 :         await _tryExport(spans);
     478            4 :         if (OTelLog.isDebug()) {
     479            3 :           OTelLog.debug('OtlpGrpcSpanExporter: Successfully exported spans');
     480              :         }
     481              :         return;
     482            3 :       } on GrpcError catch (e, stackTrace) {
     483            3 :         if (OTelLog.isError()) {
     484            3 :           OTelLog.error(
     485            9 :             'OtlpGrpcSpanExporter: gRPC error during export: ${e.code} - ${e.message}',
     486              :           );
     487              :         }
     488            9 :         if (OTelLog.isError()) OTelLog.error('Stack trace: $stackTrace');
     489              : 
     490              :         // Check if the exporter was shut down while we were waiting
     491              :         if (wasShutdownDuringRetry) {
     492            0 :           if (OTelLog.isError()) {
     493            0 :             OTelLog.error(
     494              :               'OtlpGrpcSpanExporter: Export interrupted by shutdown',
     495              :             );
     496              :           }
     497            0 :           throw StateError('Exporter was shut down during export');
     498              :         }
     499              : 
     500            6 :         if (!_retryableStatusCodes.contains(e.code)) {
     501            3 :           if (OTelLog.isError()) {
     502            3 :             OTelLog.error(
     503            6 :               'OtlpGrpcSpanExporter: Non-retryable gRPC error (${e.code}), stopping retry attempts',
     504              :             );
     505              :           }
     506              :           rethrow;
     507              :         }
     508              : 
     509            6 :         if (attempts >= maxAttempts - 1) {
     510            3 :           if (OTelLog.isError()) {
     511            3 :             OTelLog.error(
     512            3 :               'OtlpGrpcSpanExporter: Max attempts reached ($attempts out of $maxAttempts), giving up',
     513              :             );
     514              :           }
     515              :           rethrow;
     516              :         }
     517              : 
     518            3 :         final delay = _calculateJitteredDelay(attempts);
     519            3 :         if (OTelLog.isDebug()) {
     520            3 :           OTelLog.debug(
     521            6 :             'OtlpGrpcSpanExporter: Retrying export after ${delay.inMilliseconds}ms...',
     522              :           );
     523              :         }
     524            3 :         await Future<void>.delayed(delay);
     525            3 :         if (!_isShutdown) {
     526              :           // Only recreate channel if not shut down
     527            3 :           await _setupChannel();
     528              :         }
     529            3 :         attempts++;
     530              :       } catch (e, stackTrace) {
     531            1 :         if (OTelLog.isError()) {
     532            1 :           OTelLog.error(
     533            1 :             'OtlpGrpcSpanExporter: Unexpected error during export: $e',
     534              :           );
     535              :         }
     536            3 :         if (OTelLog.isError()) OTelLog.error('Stack trace: $stackTrace');
     537              : 
     538              :         // Check if we should stop retrying due to shutdown
     539              :         if (wasShutdownDuringRetry) {
     540            1 :           throw StateError('Exporter was shut down during export');
     541              :         }
     542              : 
     543            0 :         if (attempts >= maxAttempts - 1) {
     544              :           rethrow;
     545              :         }
     546              : 
     547            0 :         final delay = _calculateJitteredDelay(attempts);
     548            0 :         if (OTelLog.isDebug()) {
     549            0 :           OTelLog.debug(
     550            0 :             'OtlpGrpcSpanExporter: Retrying export after ${delay.inMilliseconds}ms...',
     551              :           );
     552              :         }
     553            0 :         await Future<void>.delayed(delay);
     554            0 :         if (!_isShutdown) {
     555              :           // Only recreate channel if not shut down
     556            0 :           await _setupChannel();
     557              :         }
     558            0 :         attempts++;
     559              :       }
     560              :     }
     561              :   }
     562              : 
     563              :   /// Force flush any pending spans
     564            5 :   @override
     565              :   Future<void> forceFlush() async {
     566            5 :     if (OTelLog.isDebug()) {
     567            5 :       OTelLog.debug('OtlpGrpcSpanExporter: Force flush requested');
     568              :     }
     569            5 :     if (_isShutdown) {
     570            3 :       if (OTelLog.isDebug()) {
     571            3 :         OTelLog.debug(
     572              :           'OtlpGrpcSpanExporter: Exporter is already shut down, nothing to flush',
     573              :         );
     574              :       }
     575              :       return;
     576              :     }
     577              : 
     578              :     // Wait for any pending export operations to complete
     579            8 :     if (_pendingExports.isNotEmpty) {
     580            1 :       if (OTelLog.isDebug()) {
     581            1 :         OTelLog.debug(
     582            3 :           'OtlpGrpcSpanExporter: Waiting for ${_pendingExports.length} pending exports to complete',
     583              :         );
     584              :       }
     585              :       try {
     586            2 :         await Future.wait(_pendingExports);
     587            1 :         if (OTelLog.isDebug()) {
     588            1 :           OTelLog.debug('OtlpGrpcSpanExporter: All pending exports completed');
     589              :         }
     590              :       } catch (e) {
     591            1 :         if (OTelLog.isError()) {
     592            2 :           OTelLog.error('OtlpGrpcSpanExporter: Error during force flush: $e');
     593              :         }
     594              :       }
     595              :     } else {
     596            3 :       if (OTelLog.isDebug()) {
     597            3 :         OTelLog.debug('OtlpGrpcSpanExporter: No pending exports to flush');
     598              :       }
     599              :     }
     600              :   }
     601              : 
     602            9 :   @override
     603              :   Future<void> shutdown() async {
     604            9 :     if (OTelLog.isDebug()) {
     605            6 :       OTelLog.debug('OtlpGrpcSpanExporter: Shutdown requested');
     606              :     }
     607            9 :     if (_isShutdown) {
     608              :       return;
     609              :     }
     610            9 :     if (OTelLog.isDebug()) {
     611            6 :       OTelLog.debug(
     612           18 :         'OtlpGrpcSpanExporter: Shutting down - waiting for ${_pendingExports.length} pending exports',
     613              :       );
     614              :     }
     615              : 
     616              :     // Set shutdown flag first
     617            9 :     _isShutdown = true;
     618              : 
     619              :     // Create a safe copy of pending exports to avoid concurrent modification
     620           18 :     final pendingExportsCopy = List<Future<void>>.of(_pendingExports);
     621              : 
     622              :     // Wait for pending exports but don't start any new ones
     623              :     // Use a timeout to prevent hanging if exports take too long
     624            9 :     if (pendingExportsCopy.isNotEmpty) {
     625            1 :       if (OTelLog.isDebug()) {
     626            1 :         OTelLog.debug(
     627            2 :           'OtlpGrpcSpanExporter: Waiting for ${pendingExportsCopy.length} pending exports with timeout',
     628              :         );
     629              :       }
     630              :       try {
     631              :         // Use a generous timeout but don't wait forever
     632            2 :         await Future.wait(pendingExportsCopy).timeout(
     633              :           const Duration(seconds: 10),
     634            0 :           onTimeout: () {
     635            0 :             if (OTelLog.isDebug()) {
     636            0 :               OTelLog.debug(
     637              :                 'OtlpGrpcSpanExporter: Timeout waiting for exports to complete',
     638              :               );
     639              :             }
     640            0 :             return Future.value([]);
     641              :           },
     642              :         );
     643              :       } catch (e) {
     644            1 :         if (OTelLog.isDebug()) {
     645            1 :           OTelLog.debug(
     646            1 :             'OtlpGrpcSpanExporter: Error during shutdown while waiting for exports: $e',
     647              :           );
     648              :         }
     649              :       }
     650              :     }
     651              : 
     652              :     // Clean up channel resources
     653            9 :     await _cleanupChannel();
     654              : 
     655            9 :     if (OTelLog.isDebug()) {
     656            6 :       OTelLog.debug('OtlpGrpcSpanExporter: Shutdown complete');
     657              :     }
     658              :   }
     659              : }
        

Generated by: LCOV version 2.0-1