Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ Notes](../../RELEASENOTES.md).
blocks, which could stall concurrent scrapes being waited on.
([#7571](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7571))

* Fixed metric values and histogram bucket bounds being written with 17
significant digits instead of their shortest round-trippable representation.
([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/pull/TODO))

* Fixed the canonical representation used for histogram `le` and summary
`quantile` label values falling back to 17 significant digits incorrectly.
([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/pull/TODO))
Comment thread
martincostello marked this conversation as resolved.
Resolved

## 1.17.0-beta.1

Released 2026-Jul-16
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ Notes](../../RELEASENOTES.md).
blocks, which could stall concurrent scrapes being waited on.
([#7571](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7571))

* Fixed metric values and histogram bucket bounds being written with 17
significant digits instead of their shortest round-trippable representation.
([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/pull/TODO))

* Fixed the canonical representation used for histogram `le` and summary
`quantile` label values falling back to 17 significant digits incorrectly.
([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/pull/TODO))
Comment thread
martincostello marked this conversation as resolved.
Resolved

## 1.17.0-beta.1

Released 2026-Jul-16
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,29 @@ public Property WriteLongMatchesReferenceImplementation() => Prop.ForAll(
static (value) => SerializeLong(value).SequenceEqual(ReferenceWriteLong(value)));

[Property(MaxTest = MaxTests)]
public Property WriteDoubleMatchesReferenceImplementation() => Prop.ForAll(
public Property WriteDoubleRoundTripsWithoutPadding() => Prop.ForAll(
Generators.DoubleArbitrary(),
static (value) => SerializeDouble(value).SequenceEqual(ReferenceWriteDouble(value)));
static (value) =>
{
var written = Encoding.UTF8.GetString(SerializeDouble(value));

if (double.IsNaN(value))
{
return written == "NaN";
}
else if (double.IsPositiveInfinity(value))
{
return written == "+Inf";
}
else if (double.IsNegativeInfinity(value))
{
return written == "-Inf";
}

return double.TryParse(written, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
&& parsed.Equals(value)
&& written.Length <= value.ToString("G17", CultureInfo.InvariantCulture).Length;
});

[Property(MaxTest = MaxTests)]
public Property EscapeNameWithDotsMatchesReferenceImplementation() => Prop.ForAll(
Expand Down Expand Up @@ -207,14 +227,6 @@ c is (>= 'A' and <= 'Z') or

private static byte[] ReferenceWriteLong(long value) => Encoding.UTF8.GetBytes(value.ToString(CultureInfo.InvariantCulture));

private static byte[] ReferenceWriteDouble(double value) => value switch
{
var doubleValue when double.IsPositiveInfinity(doubleValue) => Encoding.UTF8.GetBytes("+Inf"),
var doubleValue when double.IsNegativeInfinity(doubleValue) => Encoding.UTF8.GetBytes("-Inf"),
var doubleValue when double.IsNaN(doubleValue) => Encoding.UTF8.GetBytes("NaN"),
_ => Encoding.UTF8.GetBytes(value.ToString("G17", CultureInfo.InvariantCulture)),
};

// An independent re-implementation of the dots and values escaping schemes used to validate
// PrometheusEscaping.EscapeName. It deliberately uses different mechanisms (manual surrogate
// decoding, a StringBuilder, and int.ToString("x")) than the production code.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ public async Task Can_Scrape_Prometheus(string accept)
histogram.Name,
new ExplicitBucketHistogramConfiguration
{
Boundaries = [5, 10],
// Boundaries which exercise every shape a bucket bound can be rendered
// with: fixed-point, and both negative and positive scientific notation.
Boundaries = [1.5e-05, 0.005, 5, 10, 1e6],
ExemplarReservoirFactory = () => new SimpleFixedSizeExemplarReservoir(3),
TagKeys = [KeepTag],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,26 +82,18 @@ public static TheoryData<string, bool> EscapingSchemesAndFormats
{ long.MaxValue, "9223372036854775807" },
{ ulong.MinValue, "0" },
{ ulong.MaxValue, "18446744073709551615" },
{ float.MinValue, "-3.40282346638528860e+038" },
{ float.MinValue, "-3.4028234663852886e+38" },
{ 0f, "0.0" },
{ float.NaN, "NaN" },
{ float.NegativeInfinity, "-Inf" },
{ float.PositiveInfinity, "+Inf" },
{ float.MaxValue, "3.40282346638528860e+038" },
#if NET
{ double.MinValue, "-1.79769313486231571e+308" },
#else
{ double.MinValue, "-1.79769313486231570e+308" },
#endif
{ float.MaxValue, "3.4028234663852886e+38" },
{ double.MinValue, "-1.7976931348623157e+308" },
{ 0d, "0.0" },
{ double.NegativeInfinity, "-Inf" },
{ double.PositiveInfinity, "+Inf" },
{ double.NaN, "NaN" },
#if NET
{ double.MaxValue, "1.79769313486231571e+308" },
#else
{ double.MaxValue, "1.79769313486231570e+308" },
#endif
{ double.MaxValue, "1.7976931348623157e+308" },
{ decimal.MinValue, "-79228162514264337593543950335" },
{ 0m, "0" },
{ decimal.MaxValue, "79228162514264337593543950335" },
Expand Down Expand Up @@ -601,6 +593,37 @@ public async Task HistogramOneDimension()
await Verify(output, "txt", VerifySettings);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task HistogramWithFractionalBoundaries(bool useOpenMetrics)
{
var buffer = new byte[85000];
var metrics = new List<Metric>();

using var meter = CreateMeter();
using var provider = Sdk.CreateMeterProviderBuilder()
.AddMeter(meter.Name)
.AddView("test_histogram", new ExplicitBucketHistogramConfiguration
{
// The bucket boundaries used by the ASP.NET Core duration histograms,
// none of which are exactly representable as a double.
Boundaries = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10],
})
.AddInMemoryExporter(metrics)
.Build();

var histogram = meter.CreateHistogram<double>("test_histogram", unit: "s");
histogram.Record(0.0111996);

provider.ForceFlush();

var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics);
var output = Encoding.UTF8.GetString(buffer, 0, cursor);

await Verify(output, "txt", VerifySettings).UseParameters(useOpenMetrics);
}

[Fact]
public async Task HistogramTwoDimensions()
{
Expand Down Expand Up @@ -1450,8 +1473,16 @@ public void WriteLongMatchesInvariantFormatting(long value)
[InlineData(double.NegativeInfinity, "-Inf")]
[InlineData(-1234.5, "-1234.5")]
[InlineData(0d, "0")]
[InlineData(-0d, "-0")]
[InlineData(1234.5, "1234.5")]
[InlineData(double.PositiveInfinity, "+Inf")]
//// Values are written using their shortest round-trippable representation, not padded out to
//// the 17 significant digits that are only needed to round-trip the least tractable values.
[InlineData(0.005d, "0.005")]
[InlineData(0.075d, "0.075")]
[InlineData(0.0111996d, "0.0111996")]
[InlineData(1e-07d, "1E-07")]
[InlineData(0.1d + 0.2d, "0.30000000000000004")]
public void WriteDoubleMatchesInvariantFormatting(double value, string expected)
{
var buffer = new byte[64];
Expand All @@ -1471,6 +1502,39 @@ public void WriteDoubleFormatsNaN()
Assert.Equal("NaN", Encoding.UTF8.GetString(buffer, 0, cursor));
}

[Fact]
public void WriteDoubleDoesNotLosePrecision()
{
// Values whose shortest representation needs 16 or 17 significant digits, which the
// default formatting on .NET Framework would silently round to 15 digits.
double[] values =
[
double.Epsilon,
double.MinValue,
double.MaxValue,
0.1d + 0.2d,
1d / 3d,
Math.PI,
2.2250738585072014e-308,
7.793112545177861,
-6.186496929997836e+44,
];

var buffer = new byte[64];

foreach (var value in values)
{
var cursor = TextFormatSerializer.WriteDouble(buffer, 0, value);
var written = Encoding.UTF8.GetString(buffer, 0, cursor);

Assert.True(
double.TryParse(written, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed),
$"'{written}' could not be parsed.");

Assert.Equal(value, parsed);
}
}

[Theory]
[InlineData(false)]
[InlineData(true)]
Expand Down Expand Up @@ -1583,6 +1647,31 @@ public void WriteTargetInfoSkipsEmptyNonSingletonResource(bool openMetricsReques
[InlineData(1e6d, "1e+06")]
[InlineData(1e10d, "1e+10")]
[InlineData(double.PositiveInfinity, "+Inf")]
//// The bucket boundaries used by the HTTP metric duration histograms
[InlineData(0.005d, "0.005")]
[InlineData(0.025d, "0.025")]
[InlineData(0.05d, "0.05")]
[InlineData(0.075d, "0.075")]
[InlineData(0.25d, "0.25")]
[InlineData(0.5d, "0.5")]
[InlineData(0.75d, "0.75")]
[InlineData(2.5d, "2.5")]
[InlineData(7.5d, "7.5")]
//// Values that are neither a power of ten nor exactly representable, whose
//// shortest round-trippable representation has more than three decimal places.
[InlineData(0.0025d, "0.0025")]
[InlineData(0.0075d, "0.0075")]
[InlineData(0.00025d, "0.00025")]
[InlineData(1.5e-05d, "1.5e-05")]
[InlineData(33.333d, "33.333")]
[InlineData(12.5d, "12.5")]
[InlineData(1234567.89d, "1.23456789e+06")]
[InlineData(0.1d + 0.2d, "0.30000000000000004")]
//// Powers of ten outside the range the specification gives examples for
[InlineData(1e11d, "1e+11")]
[InlineData(1e100d, "1e+100")]
[InlineData(1e-100d, "1e-100")]
[InlineData(-1e11d, "-1e+11")]
public void WriteCanonicalLabelValueUsesOpenMetricsCanonicalNumbers(double value, string expected)
{
var buffer = new byte[64];
Expand All @@ -1602,7 +1691,6 @@ public void WriteCanonicalLabelValueFormatsNaN()
Assert.Equal("NaN", Encoding.UTF8.GetString(buffer, 0, cursor));
}

#if NET
[Theory]
[InlineData(double.PositiveInfinity, 3)]
[InlineData(0d, 2)]
Expand All @@ -1615,19 +1703,31 @@ public void WriteCanonicalLabelValueThrowsArgumentExceptionWhenBufferTooSmall(do

Assert.Equal("Destination buffer too small.", exception.Message);
}
#endif

// The specification only requires consistency for the values it gives examples for, but
// every canonical number must still round-trip to the value it was rendered from.
[Theory]
[InlineData(0.00011d, "0.00011")]
[InlineData(1e11d, "1.00000000000000000e+011")]
[InlineData(1234567.89d, "1.23456788999999990e+006")]
public void WriteCanonicalLabelValueUsesBuiltInFormattingForNonCanonicalNumbers(double value, string expected)
[InlineData(0.00011d)]
[InlineData(1e11d)]
[InlineData(1234567.89d)]
[InlineData(1000000.0000005d)]
[InlineData(double.Epsilon)]
[InlineData(double.MinValue)]
[InlineData(double.MaxValue)]
[InlineData(0.1d + 0.2d)]
[InlineData(Math.PI)]
public void WriteCanonicalLabelValueRoundTrips(double value)
{
var buffer = new byte[64];

var cursor = TextFormatSerializer.WriteCanonicalLabelValue(buffer, 0, value);
var written = Encoding.UTF8.GetString(buffer, 0, cursor);

Assert.Equal(expected, Encoding.UTF8.GetString(buffer, 0, cursor));
Assert.True(
double.TryParse(written, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed),
$"'{written}' could not be parsed.");

Assert.Equal(value, parsed);
}

[Fact]
Expand All @@ -1640,7 +1740,7 @@ public void WriteCanonicalLabelValueDoesNotRoundNearPowersOfTen()
var near = WriteCanonicalLabelValueToString(NearPowerOfTen);

Assert.Equal("1e+06", exact);
Assert.Equal(NearPowerOfTen.ToString("e17", CultureInfo.InvariantCulture), near);
Assert.Equal("1.0000000000005e+06", near);
Assert.NotEqual(exact, near);

static string WriteCanonicalLabelValueToString(double value)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# TYPE test_histogram_seconds histogram
Comment thread
martincostello marked this conversation as resolved.
Resolved
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.005"} 0
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.01"} 0
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.025"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.05"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.075"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.1"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.25"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.5"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.75"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="1"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="2.5"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="5"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="7.5"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="10"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="+Inf"} 1
test_histogram_seconds_sum{otel_scope_name="HistogramWithFractionalBoundaries"} 0.0111996
test_histogram_seconds_count{otel_scope_name="HistogramWithFractionalBoundaries"} 1
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# TYPE test_histogram_seconds histogram
Comment thread
martincostello marked this conversation as resolved.
Resolved
# UNIT test_histogram_seconds seconds
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.005"} 0
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.01"} 0
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.025"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.05"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.075"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.1"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.25"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.5"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="0.75"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="1.0"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="2.5"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="5.0"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="7.5"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="10.0"} 1
test_histogram_seconds_bucket{otel_scope_name="HistogramWithFractionalBoundaries",le="+Inf"} 1
test_histogram_seconds_sum{otel_scope_name="HistogramWithFractionalBoundaries"} 0.0111996
test_histogram_seconds_count{otel_scope_name="HistogramWithFractionalBoundaries"} 1
test_histogram_seconds_created{otel_scope_name="HistogramWithFractionalBoundaries"} <TIMESTAMP>