LCOV - code coverage report
Current view: top level - lib/src/trace/export/otlp/http - otlp_http_span_exporter.dart (source / functions) Coverage Total Hit
Test: lcov.info Lines: 91.4 % 185 169
Test Date: 2026-10-11 16:35:14 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:convert';
       6              : import 'dart:math';
       7              : import 'dart:typed_data';
       8              : 
       9              : import 'package:dartastic_opentelemetry_api/dartastic_opentelemetry_api.dart'
      10              :     show OTelLog;
      11              : import 'package:http/http.dart' as http;
      12              : 
      13              : import '../../../../export/otlp_http_exception.dart';
      14              : import '../../../../export/otlp_http_protocol.dart';
      15              : import '../../../../export/otlp_json.dart';
      16              : import '../../../../export/otlp_user_agent.dart';
      17              : import '../../../../util/header_redaction.dart';
      18              : import '../../../../util/zip/gzip.dart';
      19              : import '../../../span.dart';
      20              : import '../../../span_logger.dart';
      21              : import '../../span_exporter.dart';
      22              : import '../span_transformer.dart';
      23              : import 'http_client_factory.dart';
      24              : import 'otlp_http_span_exporter_config.dart';
      25              : 
      26              : /// An OpenTelemetry span exporter that exports spans using OTLP over HTTP/protobuf
      27              : class OtlpHttpSpanExporter implements SpanExporter {
      28              :   static const _retryableStatusCodes = [
      29              :     429, // Too Many Requests
      30              :     502, // Bad Gateway
      31              :     503, // Service Unavailable
      32              :     504, // Gateway Timeout
      33              :   ];
      34              : 
      35              :   final OtlpHttpExporterConfig _config;
      36              :   bool _isShutdown = false;
      37              :   final Random _random = Random();
      38              :   final List<Future<void>> _pendingExports = [];
      39              :   late final http.Client _client;
      40              : 
      41              :   /// Creates a new OTLP HTTP span exporter with the specified configuration.
      42              :   /// If no configuration is provided, default settings will be used.
      43              :   ///
      44              :   /// @param config Optional configuration for the exporter
      45          137 :   OtlpHttpSpanExporter([OtlpHttpExporterConfig? config])
      46            1 :       : _config = config ?? OtlpHttpExporterConfig() {
      47          137 :     if (OTelLog.isDebug()) {
      48          130 :       OTelLog.debug(
      49          390 :         'OtlpHttpSpanExporter: Created with endpoint: ${_config.endpoint}',
      50              :       );
      51          130 :       OTelLog.debug(
      52          520 :         'OtlpHttpSpanExporter: Configured headers count: ${_config.headers.length}',
      53              :       );
      54          393 :       _config.headers.forEach((key, value) {
      55            9 :         OTelLog.debug('  ${formatHeaderForLog(key, value)}');
      56              :       });
      57              :     }
      58          274 :     _client = _createHttpClient();
      59              :   }
      60              : 
      61              :   /// Creates an HTTP client with custom certificates if configured.
      62              :   ///
      63              :   /// Delegated to a platform-conditional factory: native gets an
      64              :   /// `IOClient` wrapping an `HttpClient` with a custom `SecurityContext`;
      65              :   /// web gets a `BrowserClient` (the browser handles TLS).
      66          274 :   http.Client _createHttpClient() => createOtlpHttpClient(
      67              :         exporterName: 'OtlpHttpSpanExporter',
      68          274 :         certificate: _config.certificate,
      69          274 :         clientKey: _config.clientKey,
      70          274 :         clientCertificate: _config.clientCertificate,
      71              :       );
      72              : 
      73            3 :   Duration _calculateJitteredDelay(int retries) {
      74            9 :     final baseMs = _config.baseDelay.inMilliseconds;
      75            6 :     final delay = baseMs * pow(2, retries);
      76            9 :     final jitter = _random.nextDouble() * delay;
      77            9 :     return Duration(milliseconds: (delay + jitter).toInt());
      78              :   }
      79              : 
      80           34 :   String _getEndpointUrl() {
      81              :     // Ensure the endpoint ends with /v1/traces
      82           68 :     var endpoint = _config.endpoint;
      83           34 :     if (!endpoint.endsWith('/v1/traces')) {
      84              :       // Ensure there's no trailing slash before adding path
      85           34 :       if (endpoint.endsWith('/')) {
      86            3 :         endpoint = endpoint.substring(0, endpoint.length - 1);
      87              :       }
      88           34 :       endpoint = '$endpoint/v1/traces';
      89              :     }
      90              :     return endpoint;
      91              :   }
      92              : 
      93           34 :   Future<void> _tryExport(List<Span> spans) async {
      94           34 :     if (_isShutdown) {
      95            0 :       throw StateError('Exporter is shutdown');
      96              :     }
      97              : 
      98           34 :     if (OTelLog.isLogSpans()) {
      99           33 :       logSpans(spans, 'Exporting spans via HTTP.');
     100              :     }
     101              : 
     102           34 :     if (OTelLog.isDebug()) {
     103           33 :       OTelLog.debug(
     104           66 :         'OtlpHttpSpanExporter: Preparing to export ${spans.length} spans',
     105              :       );
     106           66 :       for (var span in spans) {
     107           33 :         OTelLog.debug(
     108          198 :           '  Span: ${span.name}, spanId: ${span.spanContext.spanId}, traceId: ${span.spanContext.traceId}',
     109              :         );
     110              :       }
     111              :     }
     112              : 
     113           34 :     if (OTelLog.isDebug()) {
     114           99 :       OTelLog.debug('OtlpHttpSpanExporter: Transforming ${spans.length} spans');
     115              :     }
     116           34 :     final request = OtlpSpanTransformer.transformSpans(spans);
     117           34 :     if (OTelLog.isDebug()) {
     118           33 :       OTelLog.debug('OtlpHttpSpanExporter: Successfully transformed spans');
     119              :     }
     120              : 
     121              :     // Prepare headers + body. Wire format is selected by config.protocol:
     122              :     // protobuf (default) writes the message as its compact binary encoding
     123              :     // with Content-Type application/x-protobuf; JSON uses the OTLP/JSON
     124              :     // encoding — proto3-JSON PLUS the spec's deviations, the critical one
     125              :     // being hex-encoded (not base64) trace/span ids. Strict receivers
     126              :     // (OTel Collector ≥ ~0.15x) reject base64 ids with HTTP 400.
     127          102 :     final headers = Map<String, String>.from(_config.headers);
     128              :     // Default User-Agent per the OTLP exporter spec ("User agent"): the
     129              :     // exporter's default string is always present. A caller-supplied value
     130              :     // (typically a distribution identifier) is prepended to it, e.g.
     131              :     // "MyDistribution/1.0 OTel-OTLP-Exporter-Dart/1.1.0-beta.14-wip".
     132              :     // `headers` is a copy, and http's header map is case-insensitive, so the
     133              :     // User-Agent assignment below overrides the copied user-agent entry.
     134          102 :     final userAgent = _config.headers['user-agent'];
     135           34 :     headers['User-Agent'] =
     136            1 :         userAgent == null ? otlpUserAgent : '$userAgent $otlpUserAgent';
     137              :     Uint8List messageBytes;
     138          102 :     if (_config.protocol == OtlpHttpProtocol.httpJson) {
     139            1 :       headers['Content-Type'] = 'application/json';
     140            1 :       final jsonValue = otlpProto3JsonWithHexIds(request);
     141            3 :       messageBytes = Uint8List.fromList(utf8.encode(jsonEncode(jsonValue)));
     142              :     } else {
     143           34 :       headers['Content-Type'] = 'application/x-protobuf';
     144           34 :       messageBytes = request.writeToBuffer();
     145              :     }
     146              : 
     147           68 :     if (_config.compression) {
     148            3 :       headers['Content-Encoding'] = 'gzip';
     149              :     }
     150              : 
     151              :     var bodyBytes = messageBytes;
     152              : 
     153              :     // Apply gzip compression if configured
     154           68 :     if (_config.compression) {
     155            3 :       final gZip = GZip();
     156            3 :       final listInt = await gZip.compress(messageBytes);
     157            3 :       bodyBytes = Uint8List.fromList(listInt);
     158              :     }
     159              : 
     160              :     // Get the endpoint URL with the correct path
     161           34 :     final endpointUrl = _getEndpointUrl();
     162           34 :     if (OTelLog.isDebug()) {
     163           33 :       OTelLog.debug(
     164           33 :         'OtlpHttpSpanExporter: Sending export request to $endpointUrl',
     165              :       );
     166           33 :       OTelLog.debug('OtlpHttpSpanExporter: Request headers:');
     167           66 :       headers.forEach((key, value) {
     168              :         // Mask authorization header value for security, but show it exists
     169           99 :         OTelLog.debug('  ${formatHeaderForLog(key, value)}');
     170              :       });
     171              :     }
     172              : 
     173              :     try {
     174           34 :       final response = await _client
     175           68 :           .post(Uri.parse(endpointUrl), headers: headers, body: bodyBytes)
     176          102 :           .timeout(_config.timeout);
     177              : 
     178           20 :       if (response.statusCode >= 200 && response.statusCode < 300) {
     179            5 :         if (OTelLog.isDebug()) {
     180            4 :           OTelLog.debug(
     181              :             'OtlpHttpSpanExporter: Export request completed successfully',
     182              :           );
     183              :         }
     184              :       } else {
     185              :         final errorMessage =
     186            6 :             'OtlpHttpSpanExporter: Export request failed with status code ${response.statusCode}';
     187            6 :         if (OTelLog.isError()) OTelLog.error(errorMessage);
     188            6 :         throw OtlpHttpException(errorMessage, response.statusCode);
     189              :       }
     190              :     } catch (e, stackTrace) {
     191           33 :       if (OTelLog.isError()) {
     192           66 :         OTelLog.error('OtlpHttpSpanExporter: Export request failed: $e');
     193           66 :         OTelLog.error('Stack trace: $stackTrace');
     194              :       }
     195              :       rethrow;
     196              :     }
     197              :   }
     198              : 
     199           34 :   @override
     200              :   Future<void> export(List<Span> spans) async {
     201           34 :     if (_isShutdown) {
     202            4 :       throw StateError('Exporter is shutdown');
     203              :     }
     204              : 
     205           34 :     if (spans.isEmpty) {
     206            4 :       if (OTelLog.isDebug()) {
     207            4 :         OTelLog.debug('OtlpHttpSpanExporter: No spans to export');
     208              :       }
     209              :       return;
     210              :     }
     211              : 
     212           34 :     if (OTelLog.isDebug()) {
     213           33 :       OTelLog.debug(
     214           66 :         'OtlpHttpSpanExporter: Beginning export of ${spans.length} spans',
     215              :       );
     216              :     }
     217           34 :     final exportFuture = _export(spans);
     218              : 
     219              :     // Track the pending export but don't throw if it fails during shutdown
     220           68 :     _pendingExports.add(exportFuture);
     221              :     try {
     222              :       await exportFuture;
     223            5 :       if (OTelLog.isDebug()) {
     224            4 :         OTelLog.debug('OtlpHttpSpanExporter: Export completed successfully');
     225              :       }
     226              :     } catch (e) {
     227           33 :       if (_isShutdown &&
     228            2 :           e is StateError &&
     229            4 :           e.message.contains('shut down during')) {
     230              :         // Gracefully handle the case where shutdown interrupted the export
     231            2 :         if (OTelLog.isDebug()) {
     232            2 :           OTelLog.debug(
     233              :             'OtlpHttpSpanExporter: Export was interrupted by shutdown, suppressing error',
     234              :           );
     235              :         }
     236              :       } else {
     237              :         // Re-throw other errors
     238              :         rethrow;
     239              :       }
     240              :     } finally {
     241           68 :       _pendingExports.remove(exportFuture);
     242              :     }
     243              :   }
     244              : 
     245           34 :   Future<void> _export(List<Span> spans) async {
     246           34 :     if (_isShutdown) {
     247            0 :       throw StateError('Exporter was shut down during export');
     248              :     }
     249              : 
     250           34 :     if (OTelLog.isDebug()) {
     251           33 :       OTelLog.debug(
     252          132 :         'OtlpHttpSpanExporter: Attempting to export ${spans.length} spans to ${_config.endpoint}',
     253              :       );
     254              :     }
     255              : 
     256              :     var attempts = 0;
     257          102 :     final maxAttempts = _config.maxRetries + 1; // Initial attempt + retries
     258              : 
     259           34 :     while (attempts < maxAttempts) {
     260              :       // Allow the export to continue even during shutdown, so we complete in-flight requests
     261           34 :       final wasShutdownDuringRetry = _isShutdown;
     262              : 
     263              :       try {
     264              :         // Only check for shutdown on retry attempts to ensure in-progress exports can complete
     265            2 :         if (wasShutdownDuringRetry && attempts > 0) {
     266            2 :           if (OTelLog.isDebug()) {
     267            2 :             OTelLog.debug(
     268              :               'OtlpHttpSpanExporter: Export interrupted by shutdown',
     269              :             );
     270              :           }
     271            2 :           throw StateError('Exporter was shut down during export');
     272              :         }
     273              : 
     274           34 :         await _tryExport(spans);
     275            5 :         if (OTelLog.isDebug()) {
     276            4 :           OTelLog.debug('OtlpHttpSpanExporter: Successfully exported spans');
     277              :         }
     278              :         return;
     279           33 :       } on http.ClientException catch (e, stackTrace) {
     280           33 :         if (OTelLog.isError()) {
     281           66 :           OTelLog.error('OtlpHttpSpanExporter: HTTP error during export: $e');
     282              :         }
     283           99 :         if (OTelLog.isError()) OTelLog.error('Stack trace: $stackTrace');
     284              : 
     285              :         // Check if the exporter was shut down while we were waiting
     286              :         if (wasShutdownDuringRetry) {
     287            0 :           if (OTelLog.isError()) {
     288            0 :             OTelLog.error(
     289              :               'OtlpHttpSpanExporter: Export interrupted by shutdown',
     290              :             );
     291              :           }
     292            0 :           throw StateError('Exporter was shut down during export');
     293              :         }
     294              : 
     295              :         // Handle status code-based retries
     296           33 :         final shouldRetry = e is OtlpHttpException &&
     297            6 :             _retryableStatusCodes.contains(e.statusCode);
     298              : 
     299              :         if (!shouldRetry) {
     300           33 :           if (OTelLog.isError()) {
     301           33 :             OTelLog.error(
     302              :               'OtlpHttpSpanExporter: Non-retryable HTTP error, stopping retry attempts',
     303              :             );
     304              :           }
     305              :           rethrow;
     306              :         }
     307              : 
     308            6 :         if (attempts >= maxAttempts - 1) {
     309            1 :           if (OTelLog.isError()) {
     310            1 :             OTelLog.error(
     311            1 :               'OtlpHttpSpanExporter: Max attempts reached ($attempts out of $maxAttempts), giving up',
     312              :             );
     313              :           }
     314              :           rethrow;
     315              :         }
     316              : 
     317            3 :         final delay = _calculateJitteredDelay(attempts);
     318            3 :         if (OTelLog.isDebug()) {
     319            3 :           OTelLog.debug(
     320            6 :             'OtlpHttpSpanExporter: Retrying export after ${delay.inMilliseconds}ms...',
     321              :           );
     322              :         }
     323            3 :         await Future<void>.delayed(delay);
     324            3 :         attempts++;
     325              :       } catch (e, stackTrace) {
     326            2 :         if (OTelLog.isError()) {
     327            2 :           OTelLog.error(
     328            2 :             'OtlpHttpSpanExporter: Unexpected error during export: $e',
     329              :           );
     330              :         }
     331            6 :         if (OTelLog.isError()) OTelLog.error('Stack trace: $stackTrace');
     332              : 
     333              :         // Check if we should stop retrying due to shutdown
     334              :         if (wasShutdownDuringRetry) {
     335            2 :           throw StateError('Exporter was shut down during export');
     336              :         }
     337              : 
     338            0 :         if (attempts >= maxAttempts - 1) {
     339              :           rethrow;
     340              :         }
     341              : 
     342            0 :         final delay = _calculateJitteredDelay(attempts);
     343            0 :         if (OTelLog.isDebug()) {
     344            0 :           OTelLog.debug(
     345            0 :             'OtlpHttpSpanExporter: Retrying export after ${delay.inMilliseconds}ms...',
     346              :           );
     347              :         }
     348            0 :         await Future<void>.delayed(delay);
     349            0 :         attempts++;
     350              :       }
     351              :     }
     352              :   }
     353              : 
     354              :   /// Force flush any pending spans
     355            6 :   @override
     356              :   Future<void> forceFlush() async {
     357            6 :     if (OTelLog.isDebug()) {
     358            6 :       OTelLog.debug('OtlpHttpSpanExporter: Force flush requested');
     359              :     }
     360            6 :     if (_isShutdown) {
     361            4 :       if (OTelLog.isDebug()) {
     362            4 :         OTelLog.debug(
     363              :           'OtlpHttpSpanExporter: Exporter is already shut down, nothing to flush',
     364              :         );
     365              :       }
     366              :       return;
     367              :     }
     368              : 
     369              :     // Wait for any pending export operations to complete
     370           12 :     if (_pendingExports.isNotEmpty) {
     371            3 :       if (OTelLog.isDebug()) {
     372            3 :         OTelLog.debug(
     373            9 :           'OtlpHttpSpanExporter: Waiting for ${_pendingExports.length} pending exports to complete',
     374              :         );
     375              :       }
     376              :       try {
     377            6 :         await Future.wait(_pendingExports);
     378            2 :         if (OTelLog.isDebug()) {
     379            2 :           OTelLog.debug('OtlpHttpSpanExporter: All pending exports completed');
     380              :         }
     381              :       } catch (e) {
     382            1 :         if (OTelLog.isError()) {
     383            2 :           OTelLog.error('OtlpHttpSpanExporter: Error during force flush: $e');
     384              :         }
     385              :       }
     386              :     } else {
     387            4 :       if (OTelLog.isDebug()) {
     388            4 :         OTelLog.debug('OtlpHttpSpanExporter: No pending exports to flush');
     389              :       }
     390              :     }
     391              :   }
     392              : 
     393          136 :   @override
     394              :   Future<void> shutdown() async {
     395          136 :     if (OTelLog.isDebug()) {
     396          132 :       OTelLog.debug('OtlpHttpSpanExporter: Shutdown requested');
     397              :     }
     398          136 :     if (_isShutdown) {
     399              :       return;
     400              :     }
     401          136 :     if (OTelLog.isDebug()) {
     402          132 :       OTelLog.debug(
     403          396 :         'OtlpHttpSpanExporter: Shutting down - waiting for ${_pendingExports.length} pending exports',
     404              :       );
     405              :     }
     406              : 
     407              :     // Set shutdown flag first
     408          136 :     _isShutdown = true;
     409              : 
     410              :     // Create a safe copy of pending exports to avoid concurrent modification
     411          272 :     final pendingExportsCopy = List<Future<void>>.of(_pendingExports);
     412              : 
     413              :     // Wait for pending exports but don't start any new ones
     414              :     // Use a timeout to prevent hanging if exports take too long
     415          136 :     if (pendingExportsCopy.isNotEmpty) {
     416            3 :       if (OTelLog.isDebug()) {
     417            3 :         OTelLog.debug(
     418            6 :           'OtlpHttpSpanExporter: Waiting for ${pendingExportsCopy.length} pending exports with timeout',
     419              :         );
     420              :       }
     421              :       try {
     422              :         // Use a generous timeout but don't wait forever
     423            6 :         await Future.wait(pendingExportsCopy).timeout(
     424              :           const Duration(seconds: 10),
     425            0 :           onTimeout: () {
     426            0 :             if (OTelLog.isDebug()) {
     427            0 :               OTelLog.debug(
     428              :                 'OtlpHttpSpanExporter: Timeout waiting for exports to complete',
     429              :               );
     430              :             }
     431            0 :             return Future.value([]);
     432              :           },
     433              :         );
     434              :       } catch (e) {
     435            2 :         if (OTelLog.isDebug()) {
     436            2 :           OTelLog.debug(
     437            2 :             'OtlpHttpSpanExporter: Error during shutdown while waiting for exports: $e',
     438              :           );
     439              :         }
     440              :       }
     441              :     }
     442              : 
     443              :     // Close the HTTP client to release resources
     444          272 :     _client.close();
     445              : 
     446          136 :     if (OTelLog.isDebug()) {
     447          132 :       OTelLog.debug('OtlpHttpSpanExporter: Shutdown complete');
     448              :     }
     449              :   }
     450              : }
        

Generated by: LCOV version 2.0-1