prometheus-net
619 строк · 25.7 Кб
1#if NET
2using System;
3using System.Buffers;
4using System.Globalization;
5using System.Runtime.CompilerServices;
6
7namespace Prometheus;
8
9/// <remarks>
10/// Does NOT take ownership of the stream - caller remains the boss.
11/// </remarks>
12internal sealed class TextSerializer : IMetricsSerializer
13{
14internal static ReadOnlySpan<byte> NewLine => [(byte)'\n'];
15internal static ReadOnlySpan<byte> Quote => [(byte)'"'];
16internal static ReadOnlySpan<byte> Equal => [(byte)'='];
17internal static ReadOnlySpan<byte> Comma => [(byte)','];
18internal static ReadOnlySpan<byte> Underscore => [(byte)'_'];
19internal static ReadOnlySpan<byte> LeftBrace => [(byte)'{'];
20internal static ReadOnlySpan<byte> RightBraceSpace => [(byte)'}', (byte)' '];
21internal static ReadOnlySpan<byte> Space => [(byte)' '];
22internal static ReadOnlySpan<byte> SpaceHashSpaceLeftBrace => [(byte)' ', (byte)'#', (byte)' ', (byte)'{'];
23internal static ReadOnlySpan<byte> PositiveInfinity => [(byte)'+', (byte)'I', (byte)'n', (byte)'f'];
24internal static ReadOnlySpan<byte> NegativeInfinity => [(byte)'-', (byte)'I', (byte)'n', (byte)'f'];
25internal static ReadOnlySpan<byte> NotANumber => [(byte)'N', (byte)'a', (byte)'N'];
26internal static ReadOnlySpan<byte> DotZero => [(byte)'.', (byte)'0'];
27internal static ReadOnlySpan<byte> FloatPositiveOne => [(byte)'1', (byte)'.', (byte)'0'];
28internal static ReadOnlySpan<byte> FloatZero => [(byte)'0', (byte)'.', (byte)'0'];
29internal static ReadOnlySpan<byte> FloatNegativeOne => [(byte)'-', (byte)'1', (byte)'.', (byte)'0'];
30internal static ReadOnlySpan<byte> IntPositiveOne => [(byte)'1'];
31internal static ReadOnlySpan<byte> IntZero => [(byte)'0'];
32internal static ReadOnlySpan<byte> IntNegativeOne => [(byte)'-', (byte)'1'];
33internal static ReadOnlySpan<byte> HashHelpSpace => [(byte)'#', (byte)' ', (byte)'H', (byte)'E', (byte)'L', (byte)'P', (byte)' '];
34internal static ReadOnlySpan<byte> NewlineHashTypeSpace => [(byte)'\n', (byte)'#', (byte)' ', (byte)'T', (byte)'Y', (byte)'P', (byte)'E', (byte)' '];
35
36internal static readonly byte[] UnknownBytes = "unknown"u8.ToArray();
37internal static readonly byte[] EofNewLineBytes = [(byte)'#', (byte)' ', (byte)'E', (byte)'O', (byte)'F', (byte)'\n'];
38internal static readonly byte[] PositiveInfinityBytes = [(byte)'+', (byte)'I', (byte)'n', (byte)'f'];
39
40internal static readonly Dictionary<MetricType, byte[]> MetricTypeToBytes = new()
41{
42{ MetricType.Gauge, "gauge"u8.ToArray() },
43{ MetricType.Counter, "counter"u8.ToArray() },
44{ MetricType.Histogram, "histogram"u8.ToArray() },
45{ MetricType.Summary, "summary"u8.ToArray() },
46};
47
48private static readonly char[] DotEChar = ['.', 'e'];
49
50public TextSerializer(Stream stream, ExpositionFormat fmt = ExpositionFormat.PrometheusText)
51{
52_expositionFormat = fmt;
53_stream = new Lazy<Stream>(() => AddStreamBuffering(stream));
54}
55
56// Enables delay-loading of the stream, because touching stream in HTTP handler triggers some behavior.
57public TextSerializer(Func<Stream> streamFactory,
58ExpositionFormat fmt = ExpositionFormat.PrometheusText)
59{
60_expositionFormat = fmt;
61_stream = new Lazy<Stream>(() => AddStreamBuffering(streamFactory()));
62}
63
64/// <summary>
65/// Ensures that writes to the stream are buffered, meaning we do not emit individual "write 1 byte" calls to the stream.
66/// This has been rumored by some users to be relevant in their scenarios (though never with solid evidence or repro steps).
67/// However, we can easily simulate this via the serialization benchmark through named pipes - they are super slow if writing
68/// individual characters. It is a reasonable assumption that this limitation is also true elsewhere, at least on some OS/platform.
69/// </summary>
70private static Stream AddStreamBuffering(Stream inner)
71{
72return new BufferedStream(inner, bufferSize: 16 * 1024);
73}
74
75public async Task FlushAsync(CancellationToken cancel)
76{
77// If we never opened the stream, we don't touch it on flush.
78if (!_stream.IsValueCreated)
79return;
80
81await _stream.Value.FlushAsync(cancel);
82}
83
84private readonly Lazy<Stream> _stream;
85
86[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
87public async ValueTask WriteFamilyDeclarationAsync(string name, byte[] nameBytes, byte[] helpBytes, MetricType type,
88byte[] typeBytes, CancellationToken cancel)
89{
90var bufferLength = MeasureFamilyDeclarationLength(name, nameBytes, helpBytes, type, typeBytes);
91var buffer = ArrayPool<byte>.Shared.Rent(bufferLength);
92
93try
94{
95var nameLen = nameBytes.Length;
96if (_expositionFormat == ExpositionFormat.OpenMetricsText && type == MetricType.Counter)
97{
98if (name.EndsWith("_total"))
99{
100nameLen -= 6; // in OpenMetrics the counter name does not include the _total prefix.
101}
102else
103{
104typeBytes = UnknownBytes; // if the total prefix is missing the _total prefix it is out of spec
105}
106}
107
108var position = 0;
109AppendToBufferAndIncrementPosition(HashHelpSpace, buffer, ref position);
110AppendToBufferAndIncrementPosition(nameBytes.AsSpan(0, nameLen), buffer, ref position);
111// The space after the name in "HELP" is mandatory as per ABNF, even if there is no help text.
112AppendToBufferAndIncrementPosition(Space, buffer, ref position);
113if (helpBytes.Length > 0)
114{
115AppendToBufferAndIncrementPosition(helpBytes, buffer, ref position);
116}
117AppendToBufferAndIncrementPosition(NewlineHashTypeSpace, buffer, ref position);
118AppendToBufferAndIncrementPosition(nameBytes.AsSpan(0, nameLen), buffer, ref position);
119AppendToBufferAndIncrementPosition(Space, buffer, ref position);
120AppendToBufferAndIncrementPosition(typeBytes, buffer, ref position);
121AppendToBufferAndIncrementPosition(NewLine, buffer, ref position);
122
123await _stream.Value.WriteAsync(buffer.AsMemory(0, position), cancel);
124}
125finally
126{
127ArrayPool<byte>.Shared.Return(buffer);
128}
129}
130
131public int MeasureFamilyDeclarationLength(string name, byte[] nameBytes, byte[] helpBytes, MetricType type, byte[] typeBytes)
132{
133// We mirror the logic in the Write() call but just measure how many bytes of buffer we need.
134var length = 0;
135
136var nameLen = nameBytes.Length;
137
138if (_expositionFormat == ExpositionFormat.OpenMetricsText && type == MetricType.Counter)
139{
140if (name.EndsWith("_total"))
141{
142nameLen -= 6; // in OpenMetrics the counter name does not include the _total prefix.
143}
144else
145{
146typeBytes = UnknownBytes; // if the total prefix is missing the _total prefix it is out of spec
147}
148}
149
150length += HashHelpSpace.Length;
151length += nameLen;
152// The space after the name in "HELP" is mandatory as per ABNF, even if there is no help text.
153length += Space.Length;
154length += helpBytes.Length;
155length += NewlineHashTypeSpace.Length;
156length += nameLen;
157length += Space.Length;
158length += typeBytes.Length;
159length += NewLine.Length;
160
161return length;
162}
163
164public async ValueTask WriteEnd(CancellationToken cancel)
165{
166if (_expositionFormat == ExpositionFormat.OpenMetricsText)
167await _stream.Value.WriteAsync(EofNewLineBytes, cancel);
168}
169
170[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
171public async ValueTask WriteMetricPointAsync(byte[] name, byte[] flattenedLabels, CanonicalLabel canonicalLabel,
172double value, ObservedExemplar exemplar, byte[]? suffix, CancellationToken cancel)
173{
174// This is a max length because we do not know ahead of time how many bytes the actual value will consume.
175var bufferMaxLength = MeasureIdentifierPartLength(name, flattenedLabels, canonicalLabel, suffix) + MeasureValueMaxLength(value) + NewLine.Length;
176
177if (_expositionFormat == ExpositionFormat.OpenMetricsText && exemplar.IsValid)
178bufferMaxLength += MeasureExemplarMaxLength(exemplar);
179
180var buffer = ArrayPool<byte>.Shared.Rent(bufferMaxLength);
181
182try
183{
184var position = WriteIdentifierPart(buffer, name, flattenedLabels, canonicalLabel, suffix);
185
186position += WriteValue(buffer.AsSpan(position..), value);
187
188if (_expositionFormat == ExpositionFormat.OpenMetricsText && exemplar.IsValid)
189{
190position += WriteExemplar(buffer.AsSpan(position..), exemplar);
191}
192
193AppendToBufferAndIncrementPosition(NewLine, buffer, ref position);
194
195ValidateBufferMaxLengthAndPosition(bufferMaxLength, position);
196
197await _stream.Value.WriteAsync(buffer.AsMemory(0, position), cancel);
198}
199finally
200{
201ArrayPool<byte>.Shared.Return(buffer);
202}
203}
204
205[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
206public async ValueTask WriteMetricPointAsync(byte[] name, byte[] flattenedLabels, CanonicalLabel canonicalLabel,
207long value, ObservedExemplar exemplar, byte[]? suffix, CancellationToken cancel)
208{
209// This is a max length because we do not know ahead of time how many bytes the actual value will consume.
210var bufferMaxLength = MeasureIdentifierPartLength(name, flattenedLabels, canonicalLabel, suffix) + MeasureValueMaxLength(value) + NewLine.Length;
211
212if (_expositionFormat == ExpositionFormat.OpenMetricsText && exemplar.IsValid)
213bufferMaxLength += MeasureExemplarMaxLength(exemplar);
214
215var buffer = ArrayPool<byte>.Shared.Rent(bufferMaxLength);
216
217try
218{
219var position = WriteIdentifierPart(buffer, name, flattenedLabels, canonicalLabel, suffix);
220
221position += WriteValue(buffer.AsSpan(position..), value);
222
223if (_expositionFormat == ExpositionFormat.OpenMetricsText && exemplar.IsValid)
224{
225position += WriteExemplar(buffer.AsSpan(position..), exemplar);
226}
227
228AppendToBufferAndIncrementPosition(NewLine, buffer, ref position);
229
230ValidateBufferMaxLengthAndPosition(bufferMaxLength, position);
231
232await _stream.Value.WriteAsync(buffer.AsMemory(0, position), cancel);
233}
234finally
235{
236ArrayPool<byte>.Shared.Return(buffer);
237}
238}
239
240private int WriteExemplar(Span<byte> buffer, ObservedExemplar exemplar)
241{
242var position = 0;
243
244AppendToBufferAndIncrementPosition(SpaceHashSpaceLeftBrace, buffer, ref position);
245
246for (var i = 0; i < exemplar.Labels!.Length; i++)
247{
248if (i > 0)
249AppendToBufferAndIncrementPosition(Comma, buffer, ref position);
250
251ref var labelPair = ref exemplar.Labels[i];
252position += WriteExemplarLabel(buffer[position..], labelPair.KeyBytes, labelPair.Value);
253}
254
255AppendToBufferAndIncrementPosition(RightBraceSpace, buffer, ref position);
256position += WriteValue(buffer[position..], exemplar.Value);
257AppendToBufferAndIncrementPosition(Space, buffer, ref position);
258position += WriteValue(buffer[position..], exemplar.Timestamp);
259
260return position;
261}
262
263private int MeasureExemplarMaxLength(ObservedExemplar exemplar)
264{
265// We mirror the logic in the Write() call but just measure how many bytes of buffer we need.
266var length = 0;
267
268length += SpaceHashSpaceLeftBrace.Length;
269
270for (var i = 0; i < exemplar.Labels!.Length; i++)
271{
272if (i > 0)
273length += Comma.Length;
274
275ref var labelPair = ref exemplar.Labels[i];
276length += MeasureExemplarLabelMaxLength(labelPair.KeyBytes, labelPair.Value);
277}
278
279length += RightBraceSpace.Length;
280length += MeasureValueMaxLength(exemplar.Value);
281length += Space.Length;
282length += MeasureValueMaxLength(exemplar.Timestamp);
283
284return length;
285}
286
287private static int WriteExemplarLabel(Span<byte> buffer, byte[] label, string value)
288{
289var position = 0;
290
291AppendToBufferAndIncrementPosition(label, buffer, ref position);
292AppendToBufferAndIncrementPosition(Equal, buffer, ref position);
293AppendToBufferAndIncrementPosition(Quote, buffer, ref position);
294position += PrometheusConstants.ExemplarEncoding.GetBytes(value.AsSpan(), buffer[position..]);
295AppendToBufferAndIncrementPosition(Quote, buffer, ref position);
296
297return position;
298}
299
300private static int MeasureExemplarLabelMaxLength(byte[] label, string value)
301{
302// We mirror the logic in the Write() call but just measure how many bytes of buffer we need.
303var length = 0;
304
305length += label.Length;
306length += Equal.Length;
307length += Quote.Length;
308length += PrometheusConstants.ExemplarEncoding.GetMaxByteCount(value.Length);
309length += Quote.Length;
310
311return length;
312}
313
314private int WriteValue(Span<byte> buffer, double value)
315{
316var position = 0;
317
318if (_expositionFormat == ExpositionFormat.OpenMetricsText)
319{
320switch (value)
321{
322case 0:
323AppendToBufferAndIncrementPosition(FloatZero, buffer, ref position);
324return position;
325case 1:
326AppendToBufferAndIncrementPosition(FloatPositiveOne, buffer, ref position);
327return position;
328case -1:
329AppendToBufferAndIncrementPosition(FloatNegativeOne, buffer, ref position);
330return position;
331case double.PositiveInfinity:
332AppendToBufferAndIncrementPosition(PositiveInfinity, buffer, ref position);
333return position;
334case double.NegativeInfinity:
335AppendToBufferAndIncrementPosition(NegativeInfinity, buffer, ref position);
336return position;
337case double.NaN:
338AppendToBufferAndIncrementPosition(NotANumber, buffer, ref position);
339return position;
340}
341}
342
343// Size limit guided by https://stackoverflow.com/questions/21146544/what-is-the-maximum-length-of-double-tostringd
344if (!value.TryFormat(_stringCharsBuffer, out var charsWritten, "g", CultureInfo.InvariantCulture))
345throw new Exception("Failed to encode floating point value as string.");
346
347var encodedBytes = PrometheusConstants.ExportEncoding.GetBytes(_stringCharsBuffer, 0, charsWritten, _stringBytesBuffer, 0);
348AppendToBufferAndIncrementPosition(_stringBytesBuffer.AsSpan(0, encodedBytes), buffer, ref position);
349
350// In certain places (e.g. "le" label) we need floating point values to actually have the decimal point in them for OpenMetrics.
351if (_expositionFormat == ExpositionFormat.OpenMetricsText && RequiresOpenMetricsDotZero(_stringCharsBuffer, charsWritten))
352AppendToBufferAndIncrementPosition(DotZero, buffer, ref position);
353
354return position;
355}
356
357static bool RequiresOpenMetricsDotZero(char[] buffer, int length)
358{
359return buffer.AsSpan(0..length).IndexOfAny(DotEChar) == -1; /* did not contain .|e, so needs a .0 to turn it into a floating-point value */
360}
361
362private int MeasureValueMaxLength(double value)
363{
364// We mirror the logic in the Write() call but just measure how many bytes of buffer we need.
365if (_expositionFormat == ExpositionFormat.OpenMetricsText)
366{
367switch (value)
368{
369case 0:
370return FloatZero.Length;
371case 1:
372return FloatPositiveOne.Length;
373case -1:
374return FloatNegativeOne.Length;
375case double.PositiveInfinity:
376return PositiveInfinity.Length;
377case double.NegativeInfinity:
378return NegativeInfinity.Length;
379case double.NaN:
380return NotANumber.Length;
381}
382}
383
384// We do not want to spend time formatting the value just to measure the length and throw away the result.
385// Therefore we just consider the max length and return it. The max length is just the length of the value-encoding buffer.
386return _stringBytesBuffer.Length;
387}
388
389private int WriteValue(Span<byte> buffer, long value)
390{
391var position = 0;
392
393if (_expositionFormat == ExpositionFormat.OpenMetricsText)
394{
395switch (value)
396{
397case 0:
398AppendToBufferAndIncrementPosition(IntZero, buffer, ref position);
399return position;
400case 1:
401AppendToBufferAndIncrementPosition(IntPositiveOne, buffer, ref position);
402return position;
403case -1:
404AppendToBufferAndIncrementPosition(IntNegativeOne, buffer, ref position);
405return position;
406}
407}
408
409if (!value.TryFormat(_stringCharsBuffer, out var charsWritten, "D", CultureInfo.InvariantCulture))
410throw new Exception("Failed to encode integer value as string.");
411
412var encodedBytes = PrometheusConstants.ExportEncoding.GetBytes(_stringCharsBuffer, 0, charsWritten, _stringBytesBuffer, 0);
413AppendToBufferAndIncrementPosition(_stringBytesBuffer.AsSpan(0, encodedBytes), buffer, ref position);
414
415return position;
416}
417
418private int MeasureValueMaxLength(long value)
419{
420// We mirror the logic in the Write() call but just measure how many bytes of buffer we need.
421if (_expositionFormat == ExpositionFormat.OpenMetricsText)
422{
423switch (value)
424{
425case 0:
426return IntZero.Length;
427case 1:
428return IntPositiveOne.Length;
429case -1:
430return IntNegativeOne.Length;
431}
432}
433
434// We do not want to spend time formatting the value just to measure the length and throw away the result.
435// Therefore we just consider the max length and return it. The max length is just the length of the value-encoding buffer.
436return _stringBytesBuffer.Length;
437}
438
439// Reuse a buffer to do the serialization and UTF-8 encoding.
440// Size limit guided by https://stackoverflow.com/questions/21146544/what-is-the-maximum-length-of-double-tostringd
441private readonly char[] _stringCharsBuffer = new char[32];
442private readonly byte[] _stringBytesBuffer = new byte[32];
443
444private readonly ExpositionFormat _expositionFormat;
445
446private static void AppendToBufferAndIncrementPosition(ReadOnlySpan<byte> from, Span<byte> to, ref int position)
447{
448from.CopyTo(to[position..]);
449position += from.Length;
450}
451
452private static void ValidateBufferLengthAndPosition(int bufferLength, int position)
453{
454if (position != bufferLength)
455throw new Exception("Internal error: counting the same bytes twice got us a different value.");
456}
457
458private static void ValidateBufferMaxLengthAndPosition(int bufferMaxLength, int position)
459{
460if (position > bufferMaxLength)
461throw new Exception("Internal error: counting the same bytes twice got us a different value.");
462}
463
464/// <summary>
465/// Creates a metric identifier, with an optional name postfix and an optional extra label to append to the end.
466/// familyname_postfix{labelkey1="labelvalue1",labelkey2="labelvalue2"}
467/// Note: Terminates with a SPACE
468/// </summary>
469private int WriteIdentifierPart(Span<byte> buffer, byte[] name, byte[] flattenedLabels, CanonicalLabel extraLabel, byte[]? suffix = null)
470{
471var position = 0;
472
473AppendToBufferAndIncrementPosition(name, buffer, ref position);
474
475if (suffix != null && suffix.Length > 0)
476{
477AppendToBufferAndIncrementPosition(Underscore, buffer, ref position);
478AppendToBufferAndIncrementPosition(suffix, buffer, ref position);
479}
480
481if (flattenedLabels.Length > 0 || extraLabel.IsNotEmpty)
482{
483AppendToBufferAndIncrementPosition(LeftBrace, buffer, ref position);
484if (flattenedLabels.Length > 0)
485{
486AppendToBufferAndIncrementPosition(flattenedLabels, buffer, ref position);
487}
488
489// Extra labels go to the end (i.e. they are deepest to inherit from).
490if (extraLabel.IsNotEmpty)
491{
492if (flattenedLabels.Length > 0)
493{
494AppendToBufferAndIncrementPosition(Comma, buffer, ref position);
495}
496
497AppendToBufferAndIncrementPosition(extraLabel.Name, buffer, ref position);
498AppendToBufferAndIncrementPosition(Equal, buffer, ref position);
499AppendToBufferAndIncrementPosition(Quote, buffer, ref position);
500
501if (_expositionFormat == ExpositionFormat.OpenMetricsText)
502AppendToBufferAndIncrementPosition(extraLabel.OpenMetrics, buffer, ref position);
503else
504AppendToBufferAndIncrementPosition(extraLabel.Prometheus, buffer, ref position);
505
506AppendToBufferAndIncrementPosition(Quote, buffer, ref position);
507}
508
509AppendToBufferAndIncrementPosition(RightBraceSpace, buffer, ref position);
510}
511else
512{
513AppendToBufferAndIncrementPosition(Space, buffer, ref position);
514}
515
516return position;
517}
518
519private int MeasureIdentifierPartLength(byte[] name, byte[] flattenedLabels, CanonicalLabel extraLabel, byte[]? suffix = null)
520{
521// We mirror the logic in the Write() call but just measure how many bytes of buffer we need.
522var length = 0;
523
524length += name.Length;
525
526if (suffix != null && suffix.Length > 0)
527{
528length += Underscore.Length;
529length += suffix.Length;
530}
531
532if (flattenedLabels.Length > 0 || extraLabel.IsNotEmpty)
533{
534length += LeftBrace.Length;
535if (flattenedLabels.Length > 0)
536{
537length += flattenedLabels.Length;
538}
539
540// Extra labels go to the end (i.e. they are deepest to inherit from).
541if (extraLabel.IsNotEmpty)
542{
543if (flattenedLabels.Length > 0)
544{
545length += Comma.Length;
546}
547
548length += extraLabel.Name.Length;
549length += Equal.Length;
550length += Quote.Length;
551
552if (_expositionFormat == ExpositionFormat.OpenMetricsText)
553length += extraLabel.OpenMetrics.Length;
554else
555length += extraLabel.Prometheus.Length;
556
557length += Quote.Length;
558}
559
560length += RightBraceSpace.Length;
561}
562else
563{
564length += Space.Length;
565}
566
567return length;
568}
569
570/// <summary>
571/// Encode the special variable in regular Prometheus form and also return a OpenMetrics variant, these can be
572/// the same.
573/// see: https://github.com/OpenObservability/OpenMetrics/blob/main/specification/OpenMetrics.md#considerations-canonical-numbers
574/// </summary>
575internal static CanonicalLabel EncodeValueAsCanonicalLabel(byte[] name, double value)
576{
577if (double.IsPositiveInfinity(value))
578return new CanonicalLabel(name, PositiveInfinityBytes, PositiveInfinityBytes);
579
580// Size limit guided by https://stackoverflow.com/questions/21146544/what-is-the-maximum-length-of-double-tostringd
581Span<char> buffer = stackalloc char[32];
582
583if (!value.TryFormat(buffer, out var charsWritten, "g", CultureInfo.InvariantCulture))
584throw new Exception("Failed to encode floating point value as string.");
585
586var prometheusChars = buffer[0..charsWritten];
587
588var prometheusByteCount = PrometheusConstants.ExportEncoding.GetByteCount(prometheusChars);
589var prometheusBytes = new byte[prometheusByteCount];
590
591if (PrometheusConstants.ExportEncoding.GetBytes(prometheusChars, prometheusBytes) != prometheusByteCount)
592throw new Exception("Internal error: counting the same bytes twice got us a different value.");
593
594var openMetricsByteCount = prometheusByteCount;
595byte[] openMetricsBytes;
596
597// Identify whether the written characters are expressed as floating-point, by checking for presence of the 'e' or '.' characters.
598if (prometheusChars.IndexOfAny(DotEChar) == -1)
599{
600// Prometheus defaults to integer-formatting without a decimal point, if possible.
601// OpenMetrics requires labels containing numeric values to be expressed in floating point format.
602// If all we find is an integer, we add a ".0" to the end to make it a floating point value.
603openMetricsByteCount += 2;
604
605openMetricsBytes = new byte[openMetricsByteCount];
606Array.Copy(prometheusBytes, openMetricsBytes, prometheusByteCount);
607
608DotZero.CopyTo(openMetricsBytes.AsSpan(prometheusByteCount));
609}
610else
611{
612// It is already a floating-point value in Prometheus representation - reuse same bytes for OpenMetrics.
613openMetricsBytes = prometheusBytes;
614}
615
616return new CanonicalLabel(name, prometheusBytes, openMetricsBytes);
617}
618}
619#endif