1
+ using System . Collections . Generic ;
2
+ using System . Collections . Immutable ;
3
+ using System . Linq ;
4
+ using BenchmarkDotNet . Loggers ;
5
+ using BenchmarkDotNet . Parameters ;
6
+ using BenchmarkDotNet . Reports ;
7
+ using BenchmarkDotNet . Running ;
8
+ using System ;
9
+ using System . Text ;
10
+ using BenchmarkDotNet . Engines ;
11
+ using BenchmarkDotNet . Extensions ;
12
+ using BenchmarkDotNet . Mathematics ;
13
+
14
+ namespace BenchmarkDotNet . Exporters . OpenMetrics ;
15
+
16
+ public class OpenMetricsExporter : ExporterBase
17
+ {
18
+ private const string MetricPrefix = "benchmark_" ;
19
+ protected override string FileExtension => "metrics" ;
20
+ protected override string FileCaption => "openmetrics" ;
21
+
22
+ public static readonly IExporter Default = new OpenMetricsExporter ( ) ;
23
+
24
+ public override void ExportToLog ( Summary summary , ILogger logger )
25
+ {
26
+ var metricsSet = new HashSet < OpenMetric > ( ) ;
27
+
28
+ foreach ( var report in summary . Reports )
29
+ {
30
+ var benchmark = report . BenchmarkCase ;
31
+ var gcStats = report . GcStats ;
32
+ var descriptor = benchmark . Descriptor ;
33
+ var parameters = benchmark . Parameters ;
34
+
35
+ var stats = report . ResultStatistics ;
36
+ var metrics = report . Metrics ;
37
+ if ( stats == null )
38
+ continue ;
39
+
40
+ AddCommonMetrics ( metricsSet , descriptor , parameters , stats , gcStats ) ;
41
+ AddAdditionalMetrics ( metricsSet , metrics , descriptor , parameters ) ;
42
+ }
43
+
44
+ WriteMetricsToLogger ( logger , metricsSet ) ;
45
+ }
46
+
47
+ private static void AddCommonMetrics ( HashSet < OpenMetric > metricsSet , Descriptor descriptor , ParameterInstances parameters , Statistics stats , GcStats gcStats )
48
+ {
49
+ metricsSet . AddRange ( [
50
+ // Mean
51
+ OpenMetric . FromStatistics (
52
+ $ "{ MetricPrefix } execution_time_nanoseconds",
53
+ "Mean execution time in nanoseconds." ,
54
+ "gauge" ,
55
+ "nanoseconds" ,
56
+ descriptor ,
57
+ parameters ,
58
+ stats . Mean ) ,
59
+ // Error
60
+ OpenMetric . FromStatistics (
61
+ $ "{ MetricPrefix } error_nanoseconds",
62
+ "Standard error of the mean execution time in nanoseconds." ,
63
+ "gauge" ,
64
+ "nanoseconds" ,
65
+ descriptor ,
66
+ parameters ,
67
+ stats . StandardError ) ,
68
+ // Standard Deviation
69
+ OpenMetric . FromStatistics (
70
+ $ "{ MetricPrefix } stddev_nanoseconds",
71
+ "Standard deviation of execution time in nanoseconds." ,
72
+ "gauge" ,
73
+ "nanoseconds" ,
74
+ descriptor ,
75
+ parameters ,
76
+ stats . StandardDeviation ) ,
77
+ // GC Stats Gen0 - these are counters, not gauges
78
+ OpenMetric . FromStatistics (
79
+ $ "{ MetricPrefix } gc_gen0_collections_total",
80
+ "Total number of Gen 0 garbage collections during the benchmark execution." ,
81
+ "counter" ,
82
+ "" ,
83
+ descriptor ,
84
+ parameters ,
85
+ gcStats . Gen0Collections ) ,
86
+ // GC Stats Gen1
87
+ OpenMetric . FromStatistics (
88
+ $ "{ MetricPrefix } gc_gen1_collections_total",
89
+ "Total number of Gen 1 garbage collections during the benchmark execution." ,
90
+ "counter" ,
91
+ "" ,
92
+ descriptor ,
93
+ parameters ,
94
+ gcStats . Gen1Collections ) ,
95
+ // GC Stats Gen2
96
+ OpenMetric . FromStatistics (
97
+ $ "{ MetricPrefix } gc_gen2_collections_total",
98
+ "Total number of Gen 2 garbage collections during the benchmark execution." ,
99
+ "counter" ,
100
+ "" ,
101
+ descriptor ,
102
+ parameters ,
103
+ gcStats . Gen2Collections ) ,
104
+ // Total GC Operations
105
+ OpenMetric . FromStatistics (
106
+ $ "{ MetricPrefix } gc_total_operations_total",
107
+ "Total number of garbage collection operations during the benchmark execution." ,
108
+ "counter" ,
109
+ "" ,
110
+ descriptor ,
111
+ parameters ,
112
+ gcStats . TotalOperations ) ,
113
+ // P90 - in nanoseconds
114
+ OpenMetric . FromStatistics (
115
+ $ "{ MetricPrefix } p90_nanoseconds",
116
+ "90th percentile execution time in nanoseconds." ,
117
+ "gauge" ,
118
+ "nanoseconds" ,
119
+ descriptor ,
120
+ parameters ,
121
+ stats . Percentiles . P90 ) ,
122
+ // P95 - in nanoseconds
123
+ OpenMetric . FromStatistics (
124
+ $ "{ MetricPrefix } p95_nanoseconds",
125
+ "95th percentile execution time in nanoseconds." ,
126
+ "gauge" ,
127
+ "nanoseconds" ,
128
+ descriptor ,
129
+ parameters ,
130
+ stats . Percentiles . P95 )
131
+ ] ) ;
132
+ }
133
+
134
+ private static void AddAdditionalMetrics ( HashSet < OpenMetric > metricsSet , IReadOnlyDictionary < string , Metric > metrics , Descriptor descriptor , ParameterInstances parameters )
135
+ {
136
+ var reservedMetricNames = new HashSet < string >
137
+ {
138
+ $ "{ MetricPrefix } execution_time_nanoseconds",
139
+ $ "{ MetricPrefix } error_nanoseconds",
140
+ $ "{ MetricPrefix } stddev_nanoseconds",
141
+ $ "{ MetricPrefix } gc_gen0_collections_total",
142
+ $ "{ MetricPrefix } gc_gen1_collections_total",
143
+ $ "{ MetricPrefix } gc_gen2_collections_total",
144
+ $ "{ MetricPrefix } gc_total_operations_total",
145
+ $ "{ MetricPrefix } p90_nanoseconds",
146
+ $ "{ MetricPrefix } p95_nanoseconds"
147
+ } ;
148
+
149
+ foreach ( var metric in metrics )
150
+ {
151
+ string metricName = SanitizeMetricName ( metric . Key ) ;
152
+ string fullMetricName = $ "{ MetricPrefix } { metricName } ";
153
+
154
+ if ( reservedMetricNames . Contains ( fullMetricName ) )
155
+ continue ;
156
+
157
+ metricsSet . Add ( OpenMetric . FromMetric (
158
+ fullMetricName ,
159
+ metric ,
160
+ "gauge" , // Assuming all additional metrics are of type "gauge"
161
+ descriptor ,
162
+ parameters ) ) ;
163
+ }
164
+ }
165
+
166
+ private static void WriteMetricsToLogger ( ILogger logger , HashSet < OpenMetric > metricsSet )
167
+ {
168
+ var emittedHelpType = new HashSet < string > ( ) ;
169
+
170
+ foreach ( var metric in metricsSet . OrderBy ( m => m . Name ) )
171
+ {
172
+ if ( ! emittedHelpType . Contains ( metric . Name ) )
173
+ {
174
+ logger . WriteLine ( $ "# HELP { metric . Name } { metric . Help } ") ;
175
+ logger . WriteLine ( $ "# TYPE { metric . Name } { metric . Type } ") ;
176
+ if ( ! string . IsNullOrEmpty ( metric . Unit ) )
177
+ {
178
+ logger . WriteLine ( $ "# UNIT { metric . Name } { metric . Unit } ") ;
179
+ }
180
+ emittedHelpType . Add ( metric . Name ) ;
181
+ }
182
+
183
+ logger . WriteLine ( metric . ToString ( ) ) ;
184
+ }
185
+
186
+ logger . WriteLine ( "# EOF" ) ;
187
+ }
188
+
189
+ private static string SanitizeMetricName ( string name )
190
+ {
191
+ var builder = new StringBuilder ( ) ;
192
+ bool lastWasUnderscore = false ;
193
+
194
+ foreach ( char c in name . ToLowerInvariant ( ) )
195
+ {
196
+ if ( char . IsLetterOrDigit ( c ) || c == '_' )
197
+ {
198
+ builder . Append ( c ) ;
199
+ lastWasUnderscore = false ;
200
+ }
201
+ else if ( ! lastWasUnderscore )
202
+ {
203
+ builder . Append ( '_' ) ;
204
+ lastWasUnderscore = true ;
205
+ }
206
+ }
207
+
208
+ string ? result = builder . ToString ( ) . Trim ( '_' ) ; // <-- Trim here
209
+
210
+ if ( result . Length > 0 && char . IsDigit ( result [ 0 ] ) )
211
+ result = "_" + result ;
212
+
213
+ return result ;
214
+ }
215
+
216
+ private class OpenMetric : IEquatable < OpenMetric >
217
+ {
218
+ internal string Name { get ; }
219
+ internal string Help { get ; }
220
+ internal string Type { get ; }
221
+ internal string Unit { get ; }
222
+ private readonly ImmutableSortedDictionary < string , string > labels ;
223
+ private readonly double value ;
224
+
225
+ private OpenMetric ( string name , string help , string type , string unit , ImmutableSortedDictionary < string , string > labels , double value )
226
+ {
227
+ if ( string . IsNullOrWhiteSpace ( name ) ) throw new ArgumentException ( "Metric name cannot be null or empty." ) ;
228
+ if ( string . IsNullOrWhiteSpace ( type ) ) throw new ArgumentException ( "Metric type cannot be null or empty." ) ;
229
+
230
+ Name = name ;
231
+ Help = help ;
232
+ Type = type ;
233
+ Unit = unit ?? "" ;
234
+ this . labels = labels ?? throw new ArgumentNullException ( nameof ( labels ) ) ;
235
+ this . value = value ;
236
+ }
237
+
238
+ public static OpenMetric FromStatistics ( string name , string help , string type , string unit , Descriptor descriptor , ParameterInstances parameters , double value )
239
+ {
240
+ var labels = BuildLabelDict ( descriptor , parameters ) ;
241
+ return new OpenMetric ( name , help , type , unit , labels , value ) ;
242
+ }
243
+
244
+ public static OpenMetric FromMetric ( string fullMetricName , KeyValuePair < string , Metric > metric , string type , Descriptor descriptor , ParameterInstances parameters )
245
+ {
246
+ string help = $ "Additional metric { metric . Key } ";
247
+ var labels = BuildLabelDict ( descriptor , parameters ) ;
248
+ return new OpenMetric ( fullMetricName , help , type , "" , labels , metric . Value . Value ) ;
249
+ }
250
+
251
+ private static readonly Dictionary < string , string > NormalizedLabelKeyCache = new ( ) ;
252
+ private static string NormalizeLabelKey ( string key )
253
+ {
254
+ string normalized = new ( key
255
+ . ToLowerInvariant ( )
256
+ . Select ( c => char . IsLetterOrDigit ( c ) ? c : '_' )
257
+ . ToArray ( ) ) ;
258
+ return normalized ;
259
+ }
260
+
261
+ private static ImmutableSortedDictionary < string , string > BuildLabelDict ( Descriptor descriptor , ParameterInstances parameters )
262
+ {
263
+ var dict = new SortedDictionary < string , string >
264
+ {
265
+ [ "method" ] = descriptor . WorkloadMethod . Name ,
266
+ [ "type" ] = descriptor . TypeInfo
267
+ } ;
268
+ foreach ( var param in parameters . Items )
269
+ {
270
+ string key = NormalizeLabelKey ( param . Name ) ;
271
+ string value = EscapeLabelValue ( param . Value ? . ToString ( ) ?? "" ) ;
272
+ dict [ key ] = value ;
273
+ }
274
+ return dict . ToImmutableSortedDictionary ( ) ;
275
+ }
276
+
277
+ private static string EscapeLabelValue ( string value )
278
+ {
279
+ return value . Replace ( "\\ " , @"\\" )
280
+ . Replace ( "\" " , "\\ \" " )
281
+ . Replace ( "\n " , "\\ n" )
282
+ . Replace ( "\r " , "\\ r" )
283
+ . Replace ( "\t " , "\\ t" ) ;
284
+ }
285
+
286
+ public override bool Equals ( object ? obj ) => Equals ( obj as OpenMetric ) ;
287
+
288
+ public bool Equals ( OpenMetric ? other )
289
+ {
290
+ if ( other is null )
291
+ return false ;
292
+
293
+ return Name == other . Name
294
+ && value . Equals ( other . value )
295
+ && labels . Count == other . labels . Count
296
+ && labels . All ( kv => other . labels . TryGetValue ( kv . Key , out string ? otherValue ) && kv . Value == otherValue ) ;
297
+ }
298
+
299
+ public override int GetHashCode ( )
300
+ {
301
+ var hash = new HashCode ( ) ;
302
+ hash . Add ( Name ) ;
303
+ hash . Add ( value ) ;
304
+
305
+ foreach ( var kv in labels )
306
+ {
307
+ hash . Add ( kv . Key ) ;
308
+ hash . Add ( kv . Value ) ;
309
+ }
310
+
311
+ return hash . ToHashCode ( ) ;
312
+ }
313
+
314
+ public override string ToString ( )
315
+ {
316
+ string labelStr = labels . Count > 0
317
+ ? $ "{{{string.Join(", ", labels. Select ( kvp => $ "{ kvp . Key } =\" { kvp . Value } \" ") ) } } } "
318
+ : string . Empty ;
319
+ return $ "{ Name } { labelStr } { value } ";
320
+ }
321
+ }
322
+ }
0 commit comments