3
3
//! This module hooks up the [metrics](https://docs.rs/metrics) facade to a metrics sink that
4
4
//! currently just emits them to a tracing log entry.
5
5
6
+ use crate :: metrics_otel:: { OtlpConfig , OtlpMetricsExporter } ;
7
+ use opentelemetry:: KeyValue ;
8
+
6
9
use std:: thread:: { self , JoinHandle } ;
7
10
use std:: time:: Duration ;
8
11
@@ -30,8 +33,8 @@ pub const TARGET_NAME: &str = "mountpoint_s3_fs::metrics";
30
33
/// done with their work; metrics generated after shutting down the sink will be lost.
31
34
///
32
35
/// Panics if a sink has already been installed.
33
- pub fn install ( ) -> MetricsSinkHandle {
34
- let sink = Arc :: new ( MetricsSink :: new ( ) ) ;
36
+ pub fn install ( otlp_config : Option < OtlpConfig > ) -> anyhow :: Result < MetricsSinkHandle > {
37
+ let sink = Arc :: new ( MetricsSink :: new ( otlp_config ) ? ) ;
35
38
let mut sys = System :: new ( ) ;
36
39
37
40
let ( tx, rx) = channel ( ) ;
@@ -62,9 +65,10 @@ pub fn install() -> MetricsSinkHandle {
62
65
} ;
63
66
64
67
let recorder = MetricsRecorder { sink } ;
65
- metrics:: set_global_recorder ( recorder) . unwrap ( ) ;
68
+ metrics:: set_global_recorder ( recorder)
69
+ . map_err ( |e| anyhow:: anyhow!( "Failed to set global metrics recorder: {}" , e) ) ?;
66
70
67
- handle
71
+ Ok ( handle)
68
72
}
69
73
70
74
/// Report process level metrics
@@ -90,13 +94,46 @@ fn poll_process_metrics(sys: &mut System) {
90
94
#[ derive( Debug ) ]
91
95
struct MetricsSink {
92
96
metrics : DashMap < Key , Metric > ,
97
+ otlp_exporter : Option < OtlpMetricsExporter > ,
93
98
}
94
99
95
100
impl MetricsSink {
96
- fn new ( ) -> Self {
97
- Self {
101
+ fn new ( otlp_config : Option < OtlpConfig > ) -> anyhow:: Result < Self > {
102
+ // Initialise the OTLP exporter if a config is provided
103
+ let otlp_exporter = if let Some ( config) = otlp_config {
104
+ // Basic validation of the endpoint URL
105
+ if !config. endpoint . starts_with ( "http://" ) && !config. endpoint . starts_with ( "https://" ) {
106
+ return Err ( anyhow:: anyhow!(
107
+ "Invalid OTLP endpoint configuration: endpoint must start with http:// or https://"
108
+ ) ) ;
109
+ }
110
+
111
+ match OtlpMetricsExporter :: new ( & config) {
112
+ Ok ( exporter) => {
113
+ tracing:: info!( "OpenTelemetry metrics export enabled to {}" , config. endpoint) ;
114
+ Some ( exporter)
115
+ }
116
+ Err ( e) => {
117
+ tracing:: error!( "Failed to initialise OTLP exporter: {}" , e) ;
118
+
119
+ // If the user explicitly requested metrics export but it failed,
120
+ // we should return an error rather than silently continuing without metrics
121
+ return Err ( anyhow:: anyhow!(
122
+ "Failed to initialize OTLP metrics exporter: {}. If metrics export is not required, omit the OTLP configuration." ,
123
+ e
124
+ ) ) ;
125
+ }
126
+ }
127
+ } else {
128
+ // No OTLP config provided, running without OpenTelemetry metrics export
129
+ tracing:: debug!( "Running without OpenTelemetry metrics export" ) ;
130
+ None
131
+ } ;
132
+
133
+ Ok ( Self {
98
134
metrics : DashMap :: with_capacity ( 64 ) ,
99
- }
135
+ otlp_exporter,
136
+ } )
100
137
}
101
138
102
139
fn counter ( & self , key : & Key ) -> metrics:: Counter {
@@ -115,12 +152,43 @@ impl MetricsSink {
115
152
}
116
153
117
154
/// Publish all this sink's metrics to `tracing` log messages
155
+ /// Send metrics to OTLP if enabled
118
156
fn publish ( & self ) {
119
157
// Collect the output lines so we can sort them to make reading easier
120
158
let mut metrics = vec ! [ ] ;
121
159
122
160
for mut entry in self . metrics . iter_mut ( ) {
123
161
let ( key, metric) = entry. pair_mut ( ) ;
162
+
163
+ // If OTLP export is enabled, also send metrics to OpenTelemetry
164
+ if let Some ( exporter) = & self . otlp_exporter {
165
+ // Convert labels to OpenTelemetry KeyValue pairs
166
+ let attributes: Vec < KeyValue > = key
167
+ . labels ( )
168
+ . map ( |label| KeyValue :: new ( label. key ( ) . to_string ( ) , label. value ( ) . to_string ( ) ) )
169
+ . collect ( ) ;
170
+
171
+ // Record the metric based on its type
172
+ match metric {
173
+ Metric :: Counter ( counter) => {
174
+ if let Some ( ( value, _) ) = counter. load_and_reset ( ) {
175
+ exporter. record_counter ( key, value, & attributes) ;
176
+ }
177
+ }
178
+ Metric :: Gauge ( gauge) => {
179
+ if let Some ( value) = gauge. load_if_changed ( ) {
180
+ exporter. record_gauge ( key, value, & attributes) ;
181
+ }
182
+ }
183
+ Metric :: Histogram ( histogram) => {
184
+ histogram. run_and_reset ( |h| {
185
+ let value = h. mean ( ) ;
186
+ exporter. record_histogram ( key, value, & attributes) ;
187
+ } ) ;
188
+ }
189
+ }
190
+ }
191
+
124
192
let Some ( metric) = metric. fmt_and_reset ( ) else {
125
193
continue ;
126
194
} ;
@@ -219,7 +287,7 @@ mod tests {
219
287
220
288
#[ test]
221
289
fn basic_metrics ( ) {
222
- let sink = Arc :: new ( MetricsSink :: new ( ) ) ;
290
+ let sink = Arc :: new ( MetricsSink :: new ( None ) . unwrap ( ) ) ;
223
291
let recorder = MetricsRecorder { sink : sink. clone ( ) } ;
224
292
with_local_recorder ( & recorder, || {
225
293
// Run twice to check reset works
@@ -310,4 +378,135 @@ mod tests {
310
378
}
311
379
} ) ;
312
380
}
381
+
382
+ /// This is a manual test for verifying the integration of the metrics system with OpenTelemetry.
383
+ /// It provides end-to-end verification of the metrics pipeline without needing to run the full mountpoint application.
384
+ ///
385
+ /// # Requirements
386
+ /// - An OpenTelemetry collector running at the specified endpoint (default: http://localhost:4318/v1/metrics)
387
+ ///
388
+ /// # How to run
389
+ /// ```bash
390
+ /// # Start the OpenTelemetry collector (e.g., using Docker)
391
+ /// docker run -p 4317:4317 -p 4318:4318 -v $(pwd)/collector-config.yaml:/etc/otel-collector-config.yaml \
392
+ /// otel/opentelemetry-collector:latest --config=/etc/otel-collector-config.yaml
393
+ ///
394
+ /// # Run the test with default endpoint (ignored by default)
395
+ /// cargo test --package mountpoint-s3-fs --lib -- metrics::tests::otlp_metrics --exact --ignored
396
+ ///
397
+ /// # Or run with a custom endpoint by setting the MOUNTPOINT_TEST_OTLP_ENDPOINT environment variable
398
+ /// MOUNTPOINT_TEST_OTLP_ENDPOINT="http://custom-server:4318/v1/metrics" cargo test --package mountpoint-s3-fs --lib -- metrics::tests::otlp_metrics --exact --ignored
399
+ ///
400
+ /// # Verify metrics in collector logs
401
+ /// ```
402
+ #[ test]
403
+ #[ ignore]
404
+ fn otlp_metrics ( ) {
405
+ use tracing:: info;
406
+ use tracing_subscriber:: fmt:: format:: FmtSpan ;
407
+ use tracing_subscriber:: util:: SubscriberInitExt ;
408
+
409
+ // Initialize tracing for better test output
410
+ tracing_subscriber:: fmt ( )
411
+ . with_span_events ( FmtSpan :: CLOSE )
412
+ . with_target ( false )
413
+ . with_thread_ids ( true )
414
+ . with_level ( true )
415
+ . with_file ( true )
416
+ . with_line_number ( true )
417
+ . with_test_writer ( )
418
+ . set_default ( ) ;
419
+
420
+ info ! ( "Starting OTLP metrics test..." ) ;
421
+
422
+ // Get OTLP endpoint from environment variable or use default
423
+ let endpoint = std:: env:: var ( "MOUNTPOINT_TEST_OTLP_ENDPOINT" )
424
+ . unwrap_or_else ( |_| "http://localhost:4318/v1/metrics" . to_string ( ) ) ;
425
+
426
+ info ! ( "Using OTLP endpoint: {}" , endpoint) ;
427
+
428
+ // Initialize metrics with an OTLP config
429
+ let config = OtlpConfig :: new ( & endpoint) . with_interval_secs ( 1 ) ;
430
+ let sink = Arc :: new ( MetricsSink :: new ( Some ( config) ) . unwrap ( ) ) ;
431
+ let recorder = MetricsRecorder { sink : sink. clone ( ) } ;
432
+
433
+ with_local_recorder ( & recorder, || {
434
+ // Test counter with multiple labels
435
+ let counter = metrics:: counter!(
436
+ "mountpoint_test_counter" ,
437
+ "operation" => "write" ,
438
+ "status" => "success" ,
439
+ "test" => "true"
440
+ ) ;
441
+ counter. increment ( 100 ) ;
442
+ counter. increment ( 50 ) ;
443
+ info ! ( "Recorded counter with total value 150" ) ;
444
+
445
+ // Test gauge with updates
446
+ let gauge = metrics:: gauge!(
447
+ "mountpoint_test_gauge" ,
448
+ "component" => "cache" ,
449
+ "test" => "true"
450
+ ) ;
451
+ gauge. set ( 1000.0 ) ;
452
+ info ! ( "Set gauge to 1000.0" ) ;
453
+ gauge. set ( 500.0 ) ;
454
+ info ! ( "Updated gauge to 500.0" ) ;
455
+
456
+ // Test histogram with multiple records
457
+ let histogram = metrics:: histogram!(
458
+ "mountpoint_test_histogram" ,
459
+ "operation" => "read" ,
460
+ "test" => "true"
461
+ ) ;
462
+ histogram. record ( 10.0 ) ;
463
+ histogram. record ( 20.0 ) ;
464
+ histogram. record ( 30.0 ) ;
465
+ info ! ( "Recorded histogram values: 10.0, 20.0, 30.0" ) ;
466
+
467
+ // Publish metrics immediately to verify initial values
468
+ info ! ( "Publishing initial metrics..." ) ;
469
+ sink. publish ( ) ;
470
+
471
+ // Sleep to allow metrics to be exported
472
+ std:: thread:: sleep ( std:: time:: Duration :: from_secs ( 2 ) ) ;
473
+
474
+ // Update metrics to verify changes are tracked
475
+ counter. increment ( 200 ) ;
476
+ gauge. set ( 750.0 ) ;
477
+ histogram. record ( 40.0 ) ;
478
+ info ! ( "Updated all metrics with new values" ) ;
479
+
480
+ // Publish again to verify updates
481
+ info ! ( "Publishing updated metrics..." ) ;
482
+ sink. publish ( ) ;
483
+
484
+ // Wait for final export
485
+ std:: thread:: sleep ( std:: time:: Duration :: from_secs ( 5 ) ) ;
486
+ info ! ( "Test complete. Metrics should show in collector logs." ) ;
487
+ } ) ;
488
+ }
489
+
490
+ #[ test]
491
+ fn test_otlp_endpoint_validation ( ) {
492
+ // Test with an invalid URI - we need to directly test the MetricsSink::new function
493
+ // since install() will try to set up a global recorder which can only be done once
494
+ let config = OtlpConfig :: new ( "not-a-valid-uri" ) ;
495
+ let result = MetricsSink :: new ( Some ( config) ) ;
496
+ assert ! ( result. is_err( ) ) ;
497
+ let error = result. unwrap_err ( ) . to_string ( ) ;
498
+ assert ! (
499
+ error. contains( "Invalid OTLP endpoint configuration" ) ,
500
+ "Error message should indicate invalid configuration: {error}"
501
+ ) ;
502
+
503
+ // Test with no OTLP config (should succeed)
504
+ let result = MetricsSink :: new ( None ) ;
505
+ assert ! ( result. is_ok( ) ) ;
506
+
507
+ // Test with a syntactically valid endpoint (should succeed)
508
+ let config = OtlpConfig :: new ( "http://example.com:4318/v1/metrics" ) ;
509
+ let result = MetricsSink :: new ( Some ( config) ) ;
510
+ assert ! ( result. is_ok( ) ) ;
511
+ }
313
512
}
0 commit comments