Line data Source code
1 : // Licensed under the Apache License, Version 2.0
2 : // Copyright 2025, Michael Bushe, All rights reserved.
3 :
4 : import 'certificate_utils.dart';
5 :
6 : /// Configuration for the OtlpGrpcSpanExporter.
7 : ///
8 : /// This class configures how the OTLP gRPC span exporter connects to and communicates
9 : /// with the OpenTelemetry collector or backend. It allows customization of connection
10 : /// parameters such as endpoint, security settings, timeouts, and retry behavior.
11 : class OtlpGrpcExporterConfig {
12 : /// The endpoint to which the exporter will send spans, in the format 'host:port'.
13 : final String endpoint;
14 :
15 : /// Custom headers to include in each gRPC request, for authentication or metadata.
16 : final Map<String, String> headers;
17 :
18 : /// Timeout for gRPC operations, after which they'll fail.
19 : final Duration timeout;
20 :
21 : /// Whether to enable gRPC compression for requests.
22 : final bool compression;
23 :
24 : /// Whether to use an insecure connection (true) or TLS (false).
25 : final bool insecure;
26 :
27 : /// Maximum number of retry attempts for failed exports.
28 : final int maxRetries;
29 :
30 : /// Base delay for retry backoff calculation.
31 : final Duration baseDelay;
32 :
33 : /// Maximum delay between retry attempts.
34 : final Duration maxDelay;
35 :
36 : /// Path to the TLS certificate file for secure connections.
37 : final String? certificate;
38 :
39 : /// Path to the client key file for secure connections with client authentication.
40 : final String? clientKey;
41 :
42 : /// Path to the client certificate file for secure connections with client authentication.
43 : final String? clientCertificate;
44 :
45 : /// Creates a new OtlpGrpcExporterConfig with the specified parameters.
46 : ///
47 : /// This configuration controls the connection and behavior settings for the
48 : /// OTLP gRPC exporter.
49 : ///
50 : /// @param endpoint The endpoint to connect to (default: localhost:4317)
51 : /// @param headers Custom headers to include in the requests
52 : /// @param timeout Timeout for gRPC operations
53 : /// @param compression Whether to enable gRPC compression
54 : /// @param insecure Whether to use an insecure connection
55 : /// @param maxRetries Maximum number of retry attempts
56 : /// @param baseDelay Base delay for retry backoff
57 : /// @param maxDelay Maximum delay between retry attempts
58 : /// @param certificate Path to the TLS certificate file
59 : /// @param clientKey Path to the client key file
60 : /// @param clientCertificate Path to the client certificate file
61 2 : OtlpGrpcExporterConfig({
62 : String endpoint = 'localhost:4317',
63 : Map<String, String>? headers,
64 : Duration timeout = const Duration(seconds: 10),
65 : this.compression = false,
66 : this.insecure = false,
67 : int maxRetries = 3,
68 : Duration baseDelay = const Duration(milliseconds: 100),
69 : Duration maxDelay = const Duration(seconds: 1),
70 : this.certificate,
71 : this.clientKey,
72 : this.clientCertificate,
73 2 : }) : endpoint = _validateEndpoint(endpoint),
74 4 : headers = _validateHeaders(headers ?? {}),
75 2 : timeout = _validateTimeout(timeout),
76 2 : maxRetries = _validateRetries(maxRetries),
77 2 : baseDelay = _validateDelay(baseDelay, 'baseDelay'),
78 2 : maxDelay = _validateDelay(maxDelay, 'maxDelay') {
79 4 : if (baseDelay.compareTo(maxDelay) > 0) {
80 1 : throw ArgumentError('maxDelay cannot be less than baseDelay');
81 : }
82 8 : _validateCertificates(certificate, clientKey, clientCertificate);
83 : }
84 :
85 2 : static Map<String, String> _validateHeaders(Map<String, String> headers) {
86 2 : final normalized = <String, String>{};
87 3 : for (final entry in headers.entries) {
88 4 : if (entry.key.isEmpty || entry.value.isEmpty) {
89 1 : throw ArgumentError('Header keys and values cannot be empty');
90 : }
91 4 : normalized[entry.key.toLowerCase()] = entry.value;
92 : }
93 : return normalized;
94 : }
95 :
96 2 : static String _validateEndpoint(String endpoint) {
97 2 : if (endpoint.isEmpty) {
98 1 : throw ArgumentError('Endpoint cannot be empty');
99 : }
100 :
101 : // Handle common localhost variants and validate basic format
102 2 : endpoint = endpoint.trim();
103 :
104 : // First check for invalid formats
105 2 : if (endpoint.contains(' ')) {
106 2 : throw ArgumentError('Endpoint cannot contain spaces: $endpoint');
107 : }
108 :
109 : // Check for specific invalid formats that might parse but are invalid
110 4 : if (endpoint.contains(':port') || endpoint.contains('://port')) {
111 2 : throw ArgumentError('Invalid port specification in endpoint: $endpoint');
112 : }
113 :
114 2 : final lcEndpoint = endpoint.toLowerCase();
115 4 : if (lcEndpoint == 'localhost' || lcEndpoint == '127.0.0.1') {
116 0 : return '$endpoint:4317'; // Add default port if missing
117 : }
118 :
119 : // Handle URL format validation more carefully
120 3 : if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
121 : try {
122 2 : final uri = Uri.parse(endpoint);
123 4 : if (uri.host.isEmpty) {
124 2 : throw ArgumentError('Invalid host in endpoint: $endpoint');
125 : }
126 4 : if (uri.port == 0 && !endpoint.contains(':')) {
127 : // No port specified in URL format, add default
128 0 : return '${uri.scheme}://${uri.host}:4317${uri.path}';
129 : }
130 4 : if (uri.port == 0 &&
131 0 : endpoint.contains(':') &&
132 0 : !endpoint.contains('://:')) {
133 : // Port part exists but might be invalid
134 0 : final portStr = endpoint.split(':').last;
135 0 : if (int.tryParse(portStr) == null) {
136 0 : throw ArgumentError(
137 0 : 'Invalid port format in endpoint URL: $endpoint');
138 : }
139 : }
140 : return endpoint;
141 : } catch (e) {
142 1 : if (e is ArgumentError) rethrow;
143 0 : throw ArgumentError('Invalid URL format in endpoint: $endpoint');
144 : }
145 : }
146 :
147 : // Try to parse as URI or host:port
148 : try {
149 1 : final parts = endpoint.split(':');
150 2 : if (parts.length == 1 && parts[0].isNotEmpty) {
151 : // Only host provided, add default port
152 0 : return '${parts[0]}:4317';
153 4 : } else if (parts.length == 2 && parts[0].isNotEmpty) {
154 : // Validate port is a number if specified
155 2 : if (parts[1].isEmpty) {
156 2 : throw ArgumentError('Invalid port format in endpoint: $endpoint');
157 : }
158 2 : if (int.tryParse(parts[1]) == null) {
159 0 : throw ArgumentError('Invalid port format in endpoint: $endpoint');
160 : }
161 : // Host and port provided
162 : return endpoint;
163 : }
164 :
165 1 : throw ArgumentError(
166 1 : 'Invalid endpoint format: $endpoint. Expected format: "host:port" or a valid URI');
167 : } catch (e) {
168 1 : if (e is ArgumentError) rethrow; // Re-throw our own errors
169 :
170 : // Any other parsing error
171 0 : throw ArgumentError(
172 0 : 'Invalid endpoint format: $endpoint. Expected format: "host:port" or a valid URI');
173 : }
174 : }
175 :
176 2 : static Duration _validateTimeout(Duration timeout) {
177 2 : if (timeout < const Duration(milliseconds: 1) ||
178 2 : timeout > const Duration(minutes: 10)) {
179 1 : throw ArgumentError('Timeout must be between 1ms and 10 minutes');
180 : }
181 : return timeout;
182 : }
183 :
184 2 : static int _validateRetries(int retries) {
185 2 : if (retries < 0) {
186 1 : throw ArgumentError('maxRetries cannot be negative');
187 : }
188 : return retries;
189 : }
190 :
191 2 : static Duration _validateDelay(Duration delay, String name) {
192 2 : if (delay < const Duration(milliseconds: 1) ||
193 2 : delay > const Duration(minutes: 5)) {
194 2 : throw ArgumentError('$name must be between 1ms and 5 minutes');
195 : }
196 : return delay;
197 : }
198 :
199 2 : static void _validateCertificates(
200 : String? cert, String? key, String? clientCert) {
201 2 : CertificateUtils.validateCertificates(
202 : certificate: cert,
203 : clientKey: key,
204 : clientCertificate: clientCert,
205 : );
206 : }
207 : }
|