Skip to content

Commit 4c1e41b

Browse files
prometheus: validate exponential histogram scale range (open-telemetry#6779)
1 parent 69f189f commit 4c1e41b

File tree

3 files changed

+106
-1
lines changed

3 files changed

+106
-1
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1818
- Add metric's schema URL as `otel_scope_schema_url` label in `go.opentelemetry.io/otel/exporters/prometheus`. (#5947)
1919
- Add metric's scope attributes as `otel_scope_[attribute]` labels in `go.opentelemetry.io/otel/exporters/prometheus`. (#5947)
2020

21+
### Fixed
22+
23+
- Validate exponential histogram scale range for Prometheus compatibility in `go.opentelemetry.io/otel/exporters/prometheus`. (#6779)
24+
2125
<!-- Released section -->
2226
<!-- Don't change this section unless doing release -->
2327

exporters/prometheus/exporter.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,19 @@ func addExponentialHistogramMetric[N int64 | float64](
282282

283283
desc := prometheus.NewDesc(name, m.Description, keys, nil)
284284

285+
// Prometheus native histograms support scales in the range [-4, 8]
286+
scale := dp.Scale
287+
if scale < -4 {
288+
// Reject scales below -4 as they cannot be represented in Prometheus
289+
otel.Handle(fmt.Errorf(
290+
"exponential histogram scale %d is below minimum supported scale -4, skipping data point",
291+
scale))
292+
continue
293+
}
294+
if scale > 8 {
295+
scale = 8
296+
}
297+
285298
// From spec: note that Prometheus Native Histograms buckets are indexed by upper boundary while Exponential Histograms are indexed by lower boundary, the result being that the Offset fields are different-by-one.
286299
positiveBuckets := make(map[int]int64)
287300
for i, c := range dp.PositiveBucket.Counts {
@@ -308,7 +321,7 @@ func addExponentialHistogramMetric[N int64 | float64](
308321
positiveBuckets,
309322
negativeBuckets,
310323
dp.ZeroCount,
311-
dp.Scale,
324+
scale,
312325
dp.ZeroThreshold,
313326
dp.StartTime,
314327
values...)

exporters/prometheus/exporter_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"sync"
1212
"testing"
13+
"time"
1314

1415
"github.com/prometheus/client_golang/prometheus"
1516
"github.com/prometheus/client_golang/prometheus/testutil"
@@ -22,6 +23,7 @@ import (
2223
"go.opentelemetry.io/otel/attribute"
2324
otelmetric "go.opentelemetry.io/otel/metric"
2425
"go.opentelemetry.io/otel/sdk/metric"
26+
"go.opentelemetry.io/otel/sdk/metric/metricdata"
2527
"go.opentelemetry.io/otel/sdk/resource"
2628
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
2729
"go.opentelemetry.io/otel/trace"
@@ -1180,3 +1182,89 @@ func TestExemplars(t *testing.T) {
11801182
})
11811183
}
11821184
}
1185+
1186+
func TestExponentialHistogramScaleValidation(t *testing.T) {
1187+
ctx := context.Background()
1188+
1189+
t.Run("normal_exponential_histogram_works", func(t *testing.T) {
1190+
registry := prometheus.NewRegistry()
1191+
exporter, err := New(WithRegisterer(registry), WithoutTargetInfo(), WithoutScopeInfo())
1192+
require.NoError(t, err)
1193+
1194+
provider := metric.NewMeterProvider(
1195+
metric.WithReader(exporter),
1196+
metric.WithResource(resource.Default()),
1197+
)
1198+
defer func() {
1199+
err := provider.Shutdown(ctx)
1200+
require.NoError(t, err)
1201+
}()
1202+
1203+
// Create a histogram with a valid scale
1204+
meter := provider.Meter("test")
1205+
hist, err := meter.Float64Histogram(
1206+
"test_exponential_histogram",
1207+
otelmetric.WithDescription("test histogram"),
1208+
)
1209+
require.NoError(t, err)
1210+
hist.Record(ctx, 1.0)
1211+
hist.Record(ctx, 10.0)
1212+
hist.Record(ctx, 100.0)
1213+
1214+
metricFamilies, err := registry.Gather()
1215+
require.NoError(t, err)
1216+
assert.NotEmpty(t, metricFamilies)
1217+
})
1218+
1219+
t.Run("error_handling_for_invalid_scales", func(t *testing.T) {
1220+
var capturedError error
1221+
originalHandler := otel.GetErrorHandler()
1222+
otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) {
1223+
capturedError = err
1224+
}))
1225+
defer otel.SetErrorHandler(originalHandler)
1226+
1227+
now := time.Now()
1228+
invalidScaleData := metricdata.ExponentialHistogramDataPoint[float64]{
1229+
Attributes: attribute.NewSet(),
1230+
StartTime: now,
1231+
Time: now,
1232+
Count: 1,
1233+
Sum: 10.0,
1234+
Scale: -5, // Invalid scale below -4
1235+
ZeroCount: 0,
1236+
ZeroThreshold: 0.0,
1237+
PositiveBucket: metricdata.ExponentialBucket{
1238+
Offset: 1,
1239+
Counts: []uint64{1},
1240+
},
1241+
NegativeBucket: metricdata.ExponentialBucket{
1242+
Offset: 1,
1243+
Counts: []uint64{},
1244+
},
1245+
}
1246+
1247+
ch := make(chan prometheus.Metric, 10)
1248+
defer close(ch)
1249+
1250+
histogram := metricdata.ExponentialHistogram[float64]{
1251+
Temporality: metricdata.CumulativeTemporality,
1252+
DataPoints: []metricdata.ExponentialHistogramDataPoint[float64]{invalidScaleData},
1253+
}
1254+
1255+
m := metricdata.Metrics{
1256+
Name: "test_histogram",
1257+
Description: "test",
1258+
}
1259+
1260+
addExponentialHistogramMetric(ch, histogram, m, "test_histogram", keyVals{})
1261+
assert.Error(t, capturedError)
1262+
assert.Contains(t, capturedError.Error(), "scale -5 is below minimum")
1263+
select {
1264+
case <-ch:
1265+
t.Error("Expected no metrics to be produced for invalid scale")
1266+
default:
1267+
// No metrics were produced for the invalid scale
1268+
}
1269+
})
1270+
}

0 commit comments

Comments
 (0)