001package io.prometheus.metrics.core.metrics; 002 003import io.prometheus.metrics.annotations.StableApi; 004import io.prometheus.metrics.config.ExemplarsProperties; 005import io.prometheus.metrics.config.MetricsProperties; 006import io.prometheus.metrics.config.PrometheusProperties; 007import io.prometheus.metrics.core.datapoints.DistributionDataPoint; 008import io.prometheus.metrics.core.exemplars.ExemplarSampler; 009import io.prometheus.metrics.core.exemplars.ExemplarSamplerConfig; 010import io.prometheus.metrics.core.util.Scheduler; 011import io.prometheus.metrics.model.registry.MetricType; 012import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; 013import io.prometheus.metrics.model.snapshots.Exemplars; 014import io.prometheus.metrics.model.snapshots.HistogramSnapshot; 015import io.prometheus.metrics.model.snapshots.Labels; 016import io.prometheus.metrics.model.snapshots.NativeHistogramBuckets; 017import java.math.BigDecimal; 018import java.util.ArrayList; 019import java.util.Collections; 020import java.util.List; 021import java.util.Map; 022import java.util.SortedSet; 023import java.util.TreeSet; 024import java.util.concurrent.ConcurrentHashMap; 025import java.util.concurrent.TimeUnit; 026import java.util.concurrent.atomic.AtomicBoolean; 027import java.util.concurrent.atomic.DoubleAdder; 028import java.util.concurrent.atomic.LongAdder; 029import java.util.function.Supplier; 030import javax.annotation.Nullable; 031 032/** 033 * Histogram metric. Example usage: 034 * 035 * <pre>{@code 036 * Histogram histogram = Histogram.builder() 037 * .name("http_request_duration_seconds") 038 * .help("HTTP request service time in seconds") 039 * .unit(SECONDS) 040 * .labelNames("method", "path", "status_code") 041 * .register(); 042 * 043 * long start = System.nanoTime(); 044 * // do something 045 * histogram.labelValues("GET", "/", "200").observe(Unit.nanosToSeconds(System.nanoTime() - start)); 046 * }</pre> 047 * 048 * Prometheus supports two internal representations of histograms: 049 * 050 * <ol> 051 * <li><i>Classic Histograms</i> have a fixed number of buckets with fixed bucket boundaries. 052 * <li><i>Native Histograms</i> have an infinite number of buckets with a dynamic resolution. 053 * Prometheus native histograms are the same as OpenTelemetry's exponential histograms. 054 * </ol> 055 * 056 * By default, a histogram maintains both representations, i.e. the example above will maintain a 057 * classic histogram representation with Prometheus' default bucket boundaries as well as native 058 * histogram representation. Which representation is used depends on the exposition format, i.e. 059 * which content type the Prometheus server accepts when scraping. Exposition format "Text" exposes 060 * the classic histogram, exposition format "Protobuf" exposes both representations. This is great 061 * for migrating from classic histograms to native histograms. 062 * 063 * <p>If you want the classic representation only, use {@link Histogram.Builder#classicOnly}. If you 064 * want the native representation only, use {@link Histogram.Builder#nativeOnly}. 065 */ 066@StableApi 067public class Histogram extends StatefulMetric<DistributionDataPoint, Histogram.DataPoint> 068 implements DistributionDataPoint { 069 070 // nativeSchema == CLASSIC_HISTOGRAM indicates that this is a classic histogram only. 071 private static final int CLASSIC_HISTOGRAM = Integer.MIN_VALUE; 072 073 // NATIVE_BOUNDS is used to look up the native bucket index depending on the current schema. 074 private static final double[][] NATIVE_BOUNDS; 075 076 @Nullable private final ExemplarSamplerConfig exemplarSamplerConfig; 077 @Nullable private final Supplier<Labels> exemplarLabelsSupplier; 078 079 // Upper bounds for the classic histogram buckets. Contains at least +Inf. 080 // An empty array indicates that this is a native histogram only. 081 private final double[] classicUpperBounds; 082 083 // The schema defines the resolution of the native histogram. 084 // Schema is Prometheus terminology, in OpenTelemetry it's named "scale". 085 // The formula for the bucket boundaries at position "index" is: 086 // 087 // base := base = (2^(2^-scale)) 088 // lowerBound := base^(index-1) 089 // upperBound := base^(index) 090 // 091 // Note that this is off-by-one compared to OpenTelemetry. 092 // 093 // Example: With schema 0 the bucket boundaries are ... 1/16, 1/8, 1/4, 1/2, 1, 2, 4, 8, 16, ... 094 // Each increment in schema doubles the number of buckets. 095 // 096 // The initialNativeSchema is the schema we start with. The histogram will automatically scale 097 // down 098 // if the number of native histogram buckets exceeds nativeMaxBuckets. 099 private final int nativeInitialSchema; // integer in [-4, 8] 100 101 // Native histogram buckets get smaller and smaller the closer they get to zero. 102 // To avoid wasting a lot of buckets for observations fluctuating around zero, we consider all 103 // values in [-zeroThreshold, +zeroThreshold] to be equal to zero. 104 // 105 // The zeroThreshold is initialized with minZeroThreshold, and will grow up to maxZeroThreshold if 106 // the number of native histogram buckets exceeds nativeMaxBuckets. 107 private final double nativeMinZeroThreshold; 108 private final double nativeMaxZeroThreshold; 109 110 // When the number of native histogram buckets becomes larger than nativeMaxBuckets, 111 // an attempt is made to reduce the number of buckets: 112 // (1) Reset if the last reset is longer than the reset duration ago 113 // (2) Increase the zero bucket width if it's smaller than nativeMaxZeroThreshold 114 // (3) Decrease the nativeSchema, i.e. merge pairs of neighboring buckets into one 115 private final int nativeMaxBuckets; 116 117 // If the number of native histogram buckets exceeds nativeMaxBuckets, 118 // the histogram may reset (all values set to zero) after nativeResetDurationSeconds is expired. 119 private final long nativeResetDurationSeconds; // 0 indicates no reset 120 121 private Histogram(Histogram.Builder builder, PrometheusProperties prometheusProperties) { 122 super(builder); 123 MetricsProperties[] properties = getMetricProperties(builder, prometheusProperties); 124 nativeInitialSchema = 125 getConfigProperty( 126 properties, 127 props -> { 128 if (Boolean.TRUE.equals(props.getHistogramClassicOnly())) { 129 return CLASSIC_HISTOGRAM; 130 } else { 131 return props.getHistogramNativeInitialSchema(); 132 } 133 }); 134 classicUpperBounds = 135 getConfigProperty( 136 properties, 137 props -> { 138 if (Boolean.TRUE.equals(props.getHistogramNativeOnly())) { 139 return new double[] {}; 140 } else if (props.getHistogramClassicUpperBounds() != null) { 141 SortedSet<Double> upperBounds = 142 new TreeSet<>(props.getHistogramClassicUpperBounds()); 143 upperBounds.add(Double.POSITIVE_INFINITY); 144 double[] result = new double[upperBounds.size()]; 145 int i = 0; 146 for (double upperBound : upperBounds) { 147 result[i++] = upperBound; 148 } 149 return result; 150 } else { 151 return null; 152 } 153 }); 154 double max = 155 getConfigProperty(properties, MetricsProperties::getHistogramNativeMaxZeroThreshold); 156 double min = 157 getConfigProperty(properties, MetricsProperties::getHistogramNativeMinZeroThreshold); 158 nativeMaxZeroThreshold = 159 max == Builder.DEFAULT_NATIVE_MAX_ZERO_THRESHOLD && min > max ? min : max; 160 nativeMinZeroThreshold = Math.min(min, nativeMaxZeroThreshold); 161 nativeMaxBuckets = 162 getConfigProperty(properties, MetricsProperties::getHistogramNativeMaxNumberOfBuckets); 163 nativeResetDurationSeconds = 164 getConfigProperty(properties, MetricsProperties::getHistogramNativeResetDurationSeconds); 165 boolean exemplarsEnabled = 166 getConfigProperty(properties, MetricsProperties::getExemplarsEnabled); 167 ExemplarsProperties exemplarsProperties = prometheusProperties.getExemplarProperties(); 168 if (exemplarsEnabled) { 169 exemplarSamplerConfig = 170 classicUpperBounds.length == 0 171 ? new ExemplarSamplerConfig(exemplarsProperties, 4) 172 : new ExemplarSamplerConfig(exemplarsProperties, classicUpperBounds); 173 } else { 174 exemplarSamplerConfig = null; 175 } 176 exemplarLabelsSupplier = builder.exemplarLabelsSupplier; 177 } 178 179 @Override 180 public double getSum() { 181 return getNoLabels().getSum(); 182 } 183 184 @Override 185 public long getCount() { 186 return getNoLabels().getCount(); 187 } 188 189 @Override 190 public void observe(double amount) { 191 getNoLabels().observe(amount); 192 } 193 194 @Override 195 public void observeWithExemplar(double amount, Labels labels) { 196 getNoLabels().observeWithExemplar(amount, labels); 197 } 198 199 public class DataPoint implements DistributionDataPoint { 200 private final LongAdder[] classicBuckets; 201 private final ConcurrentHashMap<Integer, LongAdder> nativeBucketsForPositiveValues = 202 new ConcurrentHashMap<>(); 203 private final ConcurrentHashMap<Integer, LongAdder> nativeBucketsForNegativeValues = 204 new ConcurrentHashMap<>(); 205 private final LongAdder nativeZeroCount = new LongAdder(); 206 private final LongAdder count = new LongAdder(); 207 private final DoubleAdder sum = new DoubleAdder(); 208 private volatile int nativeSchema = 209 nativeInitialSchema; // integer in [-4, 8] or CLASSIC_HISTOGRAM 210 private volatile double nativeZeroThreshold = Histogram.this.nativeMinZeroThreshold; 211 private volatile long createdTimeMillis = System.currentTimeMillis(); 212 private final Buffer buffer = new Buffer(); 213 private volatile boolean resetDurationExpired = false; 214 @Nullable private final ExemplarSampler exemplarSampler; 215 216 private DataPoint() { 217 if (exemplarSamplerConfig != null) { 218 exemplarSampler = new ExemplarSampler(exemplarSamplerConfig, null, exemplarLabelsSupplier); 219 } else { 220 exemplarSampler = null; 221 } 222 classicBuckets = new LongAdder[classicUpperBounds.length]; 223 for (int i = 0; i < classicUpperBounds.length; i++) { 224 classicBuckets[i] = new LongAdder(); 225 } 226 maybeScheduleNextReset(); 227 } 228 229 @Override 230 public double getSum() { 231 return sum.sum(); 232 } 233 234 @Override 235 public long getCount() { 236 return count.sum(); 237 } 238 239 @Override 240 public void observe(double value) { 241 if (Double.isNaN(value)) { 242 // See https://github.com/prometheus/client_golang/issues/1275 on ignoring NaN observations. 243 return; 244 } 245 if (!buffer.append(value)) { 246 doObserve(value, false); 247 } 248 if (exemplarSampler != null) { 249 exemplarSampler.observe(value); 250 } 251 } 252 253 @Override 254 public void observeWithExemplar(double value, Labels labels) { 255 if (Double.isNaN(value)) { 256 // See https://github.com/prometheus/client_golang/issues/1275 on ignoring NaN observations. 257 return; 258 } 259 if (!buffer.append(value)) { 260 doObserve(value, false); 261 } 262 if (exemplarSampler != null) { 263 exemplarSampler.observeWithExemplar(value, labels); 264 } 265 } 266 267 private void doObserve(double value, boolean fromBuffer) { 268 // classicUpperBounds is an empty array if this is a native histogram only. 269 for (int i = 0; i < classicUpperBounds.length; ++i) { 270 // The last bucket is +Inf, so we always increment. 271 if (value <= classicUpperBounds[i]) { 272 classicBuckets[i].add(1); 273 break; 274 } 275 } 276 boolean nativeBucketCreated = false; 277 if (Histogram.this.nativeInitialSchema != CLASSIC_HISTOGRAM) { 278 if (value > nativeZeroThreshold) { 279 nativeBucketCreated = addToNativeBucket(value, nativeBucketsForPositiveValues); 280 } else if (value < -nativeZeroThreshold) { 281 nativeBucketCreated = addToNativeBucket(-value, nativeBucketsForNegativeValues); 282 } else { 283 nativeZeroCount.add(1); 284 } 285 } 286 sum.add(value); 287 count 288 .increment(); // must be the last step, because count is used to signal that the operation 289 // is complete. 290 if (!fromBuffer) { 291 // maybeResetOrScaleDown will switch to the buffer, 292 // which won't work if we are currently still processing observations from the buffer. 293 // The reason is that before switching to the buffer we wait for all pending observations to 294 // be counted. 295 // If we do this while still applying observations from the buffer, the pending observations 296 // from 297 // the buffer will never be counted, and the buffer.run() method will wait forever. 298 maybeResetOrScaleDown(value, nativeBucketCreated); 299 } 300 } 301 302 private HistogramSnapshot.HistogramDataPointSnapshot collect(Labels labels) { 303 Exemplars exemplars = exemplarSampler != null ? exemplarSampler.collect() : Exemplars.EMPTY; 304 return buffer.run( 305 expectedCount -> count.sum() == expectedCount, 306 () -> { 307 if (classicUpperBounds.length == 0) { 308 // native only 309 return new HistogramSnapshot.HistogramDataPointSnapshot( 310 nativeSchema, 311 nativeZeroCount.sum(), 312 nativeZeroThreshold, 313 toBucketList(nativeBucketsForPositiveValues), 314 toBucketList(nativeBucketsForNegativeValues), 315 sum.sum(), 316 labels, 317 exemplars, 318 createdTimeMillis); 319 } else if (Histogram.this.nativeInitialSchema == CLASSIC_HISTOGRAM) { 320 // classic only 321 return new HistogramSnapshot.HistogramDataPointSnapshot( 322 ClassicHistogramBuckets.of(classicUpperBounds, classicBuckets), 323 sum.sum(), 324 labels, 325 exemplars, 326 createdTimeMillis); 327 } else { 328 // hybrid: classic and native 329 return new HistogramSnapshot.HistogramDataPointSnapshot( 330 ClassicHistogramBuckets.of(classicUpperBounds, classicBuckets), 331 nativeSchema, 332 nativeZeroCount.sum(), 333 nativeZeroThreshold, 334 toBucketList(nativeBucketsForPositiveValues), 335 toBucketList(nativeBucketsForNegativeValues), 336 sum.sum(), 337 labels, 338 exemplars, 339 createdTimeMillis); 340 } 341 }, 342 v -> doObserve(v, true)); 343 } 344 345 private boolean addToNativeBucket(double value, ConcurrentHashMap<Integer, LongAdder> buckets) { 346 boolean newBucketCreated = false; 347 int bucketIndex; 348 if (Double.isInfinite(value)) { 349 bucketIndex = findBucketIndex(Double.MAX_VALUE) + 1; 350 } else { 351 bucketIndex = findBucketIndex(value); 352 } 353 LongAdder bucketCount = buckets.get(bucketIndex); 354 if (bucketCount == null) { 355 LongAdder newBucketCount = new LongAdder(); 356 LongAdder existingBucketCount = buckets.putIfAbsent(bucketIndex, newBucketCount); 357 if (existingBucketCount == null) { 358 newBucketCreated = true; 359 bucketCount = newBucketCount; 360 } else { 361 bucketCount = existingBucketCount; 362 } 363 } 364 bucketCount.increment(); 365 return newBucketCreated; 366 } 367 368 private int findBucketIndex(double value) { 369 // Preconditions: 370 // Double.isNan(value) is false; 371 // Double.isInfinite(value) is false; 372 // value > 0 373 // --- 374 // The following is a naive implementation of C's frexp() function. 375 // Performance can be improved by using the internal Bit representation of floating point 376 // numbers. 377 // More info on the Bit representation of floating point numbers: 378 // https://stackoverflow.com/questions/8341395/what-is-a-subnormal-floating-point-number 379 // Result: value == frac * 2^exp where frac in [0.5, 1). 380 double frac = value; 381 int exp = 0; 382 while (frac < 0.5) { 383 frac *= 2.0; 384 exp--; 385 } 386 while (frac >= 1.0) { 387 frac /= 2.0; 388 exp++; 389 } 390 // end of frexp() 391 392 if (nativeSchema >= 1) { 393 return findIndex(NATIVE_BOUNDS[nativeSchema - 1], frac) 394 + (exp - 1) * NATIVE_BOUNDS[nativeSchema - 1].length; 395 } else { 396 int bucketIndex = exp; 397 if (frac == 0.5) { 398 bucketIndex--; 399 } 400 int offset = (1 << -nativeSchema) - 1; 401 bucketIndex = (bucketIndex + offset) >> -nativeSchema; 402 return bucketIndex; 403 } 404 } 405 406 private int findIndex(double[] bounds, double frac) { 407 // The following is the equivalent of golang's sort.SearchFloat64s(bounds, frac) 408 // See https://pkg.go.dev/sort#SearchFloat64s 409 int first = 0; 410 int last = bounds.length - 1; 411 while (first <= last) { 412 int mid = (first + last) / 2; 413 if (bounds[mid] == frac) { 414 return mid; 415 } else if (bounds[mid] < frac) { 416 first = mid + 1; 417 } else { 418 last = mid - 1; 419 } 420 } 421 return last + 1; 422 } 423 424 /** 425 * Makes sure that the number of native buckets does not exceed nativeMaxBuckets. 426 * 427 * <ul> 428 * <li>If the histogram has already been scaled down (nativeSchema < initialSchema) reset 429 * after resetIntervalExpired to get back to the original schema. 430 * <li>If a new bucket was created and we now exceed nativeMaxBuckets run maybeScaleDown() to 431 * scale down 432 * </ul> 433 */ 434 private void maybeResetOrScaleDown(double value, boolean nativeBucketCreated) { 435 AtomicBoolean wasReset = new AtomicBoolean(false); 436 if (resetDurationExpired && nativeSchema < nativeInitialSchema) { 437 // If nativeSchema < initialNativeSchema the histogram has been scaled down. 438 // So if resetDurationExpired we will reset it to restore the original native schema. 439 buffer.run( 440 expectedCount -> count.sum() == expectedCount, 441 () -> { 442 if (maybeReset()) { 443 wasReset.set(true); 444 } 445 return null; 446 }, 447 v -> doObserve(v, true)); 448 } else if (nativeBucketCreated) { 449 // If a new bucket was created we need to check if nativeMaxBuckets is exceeded 450 // and scale down if so. 451 maybeScaleDown(wasReset); 452 } 453 if (wasReset.get()) { 454 // We just discarded the newly observed value. Observe it again. 455 if (!buffer.append(value)) { 456 doObserve(value, true); 457 } 458 } 459 } 460 461 private void maybeScaleDown(AtomicBoolean wasReset) { 462 if (nativeMaxBuckets == 0 || nativeSchema == -4) { 463 return; 464 } 465 int numberOfBuckets = 466 nativeBucketsForPositiveValues.size() + nativeBucketsForNegativeValues.size(); 467 if (numberOfBuckets <= nativeMaxBuckets) { 468 return; 469 } 470 buffer.run( 471 expectedCount -> count.sum() == expectedCount, 472 () -> { 473 // Now we are in the synchronized block while new observations go into the buffer. 474 // Check again if we need to limit the bucket size, because another thread might 475 // have limited it in the meantime. 476 int numBuckets = 477 nativeBucketsForPositiveValues.size() + nativeBucketsForNegativeValues.size(); 478 if (numBuckets <= nativeMaxBuckets || nativeSchema == -4) { 479 return null; 480 } 481 if (maybeReset()) { 482 wasReset.set(true); 483 return null; 484 } 485 if (maybeWidenZeroBucket()) { 486 return null; 487 } 488 doubleBucketWidth(); 489 return null; 490 }, 491 v -> doObserve(v, true)); 492 } 493 494 // maybeReset is called in the synchronized block while new observations go into the buffer. 495 private boolean maybeReset() { 496 if (!resetDurationExpired) { 497 return false; 498 } 499 resetDurationExpired = false; 500 buffer.reset(); 501 nativeBucketsForPositiveValues.clear(); 502 nativeBucketsForNegativeValues.clear(); 503 nativeZeroCount.reset(); 504 count.reset(); 505 sum.reset(); 506 for (LongAdder classicBucket : classicBuckets) { 507 classicBucket.reset(); 508 } 509 nativeZeroThreshold = nativeMinZeroThreshold; 510 nativeSchema = Histogram.this.nativeInitialSchema; 511 createdTimeMillis = System.currentTimeMillis(); 512 if (exemplarSampler != null) { 513 exemplarSampler.reset(); 514 } 515 maybeScheduleNextReset(); 516 return true; 517 } 518 519 // maybeWidenZeroBucket is called in the synchronized block while new observations go into the 520 // buffer. 521 private boolean maybeWidenZeroBucket() { 522 if (nativeZeroThreshold >= nativeMaxZeroThreshold) { 523 return false; 524 } 525 int smallestIndex = findSmallestIndex(nativeBucketsForPositiveValues); 526 int smallestNegativeIndex = findSmallestIndex(nativeBucketsForNegativeValues); 527 if (smallestNegativeIndex < smallestIndex) { 528 smallestIndex = smallestNegativeIndex; 529 } 530 if (smallestIndex == Integer.MAX_VALUE) { 531 return false; 532 } 533 double newZeroThreshold = nativeBucketIndexToUpperBound(nativeSchema, smallestIndex); 534 if (newZeroThreshold > nativeMaxZeroThreshold) { 535 return false; 536 } 537 mergeWithZeroBucket(smallestIndex, nativeBucketsForPositiveValues); 538 mergeWithZeroBucket(smallestIndex, nativeBucketsForNegativeValues); 539 nativeZeroThreshold = newZeroThreshold; 540 return true; 541 } 542 543 private void mergeWithZeroBucket(int index, Map<Integer, LongAdder> buckets) { 544 LongAdder count = buckets.remove(index); 545 if (count != null) { 546 nativeZeroCount.add(count.sum()); 547 } 548 } 549 550 private double nativeBucketIndexToUpperBound(int schema, int index) { 551 double result = calcUpperBound(schema, index); 552 if (Double.isInfinite(result)) { 553 // The last bucket boundary should always be MAX_VALUE, so that the +Inf bucket counts only 554 // actual +Inf observations. 555 // However, MAX_VALUE is not a natural bucket boundary, so we introduce MAX_VALUE 556 // as an artificial boundary before +Inf. 557 double previousBucketBoundary = calcUpperBound(schema, index - 1); 558 if (Double.isFinite(previousBucketBoundary) && previousBucketBoundary < Double.MAX_VALUE) { 559 return Double.MAX_VALUE; 560 } 561 } 562 return result; 563 } 564 565 private double calcUpperBound(int schema, int index) { 566 // The actual formula is: 567 // --- 568 // base := 2^(2^-schema); 569 // upperBound := base^index; 570 // --- 571 // The following implementation reduces the numerical error for index > 0. 572 // It's not very efficient. We should refactor and use an algorithm as in client_golang's 573 // getLe() 574 double factor = 1.0; 575 while (index > 0) { 576 if (index % 2 == 0) { 577 index /= 2; 578 schema -= 1; 579 } else { 580 index -= 1; 581 factor *= Math.pow(2, Math.pow(2, -schema)); 582 } 583 } 584 return factor * Math.pow(2, index * Math.pow(2, -schema)); 585 } 586 587 private int findSmallestIndex(Map<Integer, LongAdder> nativeBuckets) { 588 int result = Integer.MAX_VALUE; 589 for (int key : nativeBuckets.keySet()) { 590 if (key < result) { 591 result = key; 592 } 593 } 594 return result; 595 } 596 597 // doubleBucketWidth is called in the synchronized block while new observations go into the 598 // buffer. 599 @SuppressWarnings("NonAtomicVolatileUpdate") 600 private void doubleBucketWidth() { 601 doubleBucketWidth(nativeBucketsForPositiveValues); 602 doubleBucketWidth(nativeBucketsForNegativeValues); 603 nativeSchema--; 604 } 605 606 private void doubleBucketWidth(Map<Integer, LongAdder> buckets) { 607 int[] keys = new int[buckets.size()]; 608 long[] values = new long[keys.length]; 609 int i = 0; 610 for (Map.Entry<Integer, LongAdder> entry : buckets.entrySet()) { 611 keys[i] = entry.getKey(); 612 values[i] = entry.getValue().sum(); 613 i++; 614 } 615 buckets.clear(); 616 for (i = 0; i < keys.length; i++) { 617 int index = (keys[i] > 0 ? keys[i] + 1 : keys[i]) / 2; 618 LongAdder count = buckets.computeIfAbsent(index, k -> new LongAdder()); 619 count.add(values[i]); 620 } 621 } 622 623 private NativeHistogramBuckets toBucketList(ConcurrentHashMap<Integer, LongAdder> map) { 624 int[] bucketIndexes = new int[map.size()]; 625 long[] counts = new long[map.size()]; 626 int i = 0; 627 for (Map.Entry<Integer, LongAdder> entry : map.entrySet()) { 628 bucketIndexes[i] = entry.getKey(); 629 counts[i] = entry.getValue().sum(); 630 i++; 631 } 632 return NativeHistogramBuckets.of(bucketIndexes, counts); 633 } 634 635 @SuppressWarnings("FutureReturnValueIgnored") 636 private void maybeScheduleNextReset() { 637 if (nativeResetDurationSeconds > 0) { 638 Scheduler.schedule( 639 () -> resetDurationExpired = true, nativeResetDurationSeconds, TimeUnit.SECONDS); 640 } 641 } 642 } 643 644 @Override 645 public HistogramSnapshot collect() { 646 return (HistogramSnapshot) super.collect(); 647 } 648 649 @Override 650 protected HistogramSnapshot collect(List<Labels> labels, List<DataPoint> metricData) { 651 List<HistogramSnapshot.HistogramDataPointSnapshot> data = new ArrayList<>(labels.size()); 652 for (int i = 0; i < labels.size(); i++) { 653 data.add(metricData.get(i).collect(labels.get(i))); 654 } 655 return new HistogramSnapshot(metadata, data); 656 } 657 658 /** 659 * @deprecated Use {@link #getMetricFamilyDescriptor()} instead. 660 */ 661 @Override 662 @Deprecated 663 @SuppressWarnings("InlineMeSuggester") 664 public MetricType getMetricType() { 665 return MetricType.HISTOGRAM; 666 } 667 668 @Override 669 protected DataPoint newDataPoint() { 670 return new DataPoint(); 671 } 672 673 static { 674 // See bounds in client_golang's histogram implementation. 675 NATIVE_BOUNDS = new double[8][]; 676 for (int schema = 1; schema <= 8; schema++) { 677 NATIVE_BOUNDS[schema - 1] = new double[1 << schema]; 678 NATIVE_BOUNDS[schema - 1][0] = 0.5; 679 // https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/metrics/v1/metrics.proto#L501 680 double base = Math.pow(2, Math.pow(2, -schema)); 681 for (int i = 1; i < NATIVE_BOUNDS[schema - 1].length; i++) { 682 if (i % 2 == 0 && schema > 1) { 683 // Use previously calculated value for increased precision, see comment in client_golang's 684 // implementation. 685 NATIVE_BOUNDS[schema - 1][i] = NATIVE_BOUNDS[schema - 2][i / 2]; 686 } else { 687 NATIVE_BOUNDS[schema - 1][i] = NATIVE_BOUNDS[schema - 1][i - 1] * base; 688 } 689 } 690 } 691 } 692 693 public static Builder builder() { 694 return new Builder(PrometheusProperties.get()); 695 } 696 697 public static Builder builder(PrometheusProperties config) { 698 return new Builder(config); 699 } 700 701 public static class Builder extends StatefulMetric.Builder<Histogram.Builder, Histogram> { 702 703 @SuppressWarnings("MutablePublicArray") 704 public static final double[] DEFAULT_CLASSIC_UPPER_BOUNDS = 705 new double[] {.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}; 706 707 private static final double DEFAULT_NATIVE_MIN_ZERO_THRESHOLD = Math.pow(2.0, -128); 708 private static final double DEFAULT_NATIVE_MAX_ZERO_THRESHOLD = Math.pow(2.0, -128); 709 private static final int DEFAULT_NATIVE_INITIAL_SCHEMA = 5; 710 private static final int DEFAULT_NATIVE_MAX_NUMBER_OF_BUCKETS = 160; 711 private static final long DEFAULT_NATIVE_RESET_DURATION_SECONDS = 0; // 0 means no reset 712 713 @Nullable private Boolean nativeOnly; 714 @Nullable private Boolean classicOnly; 715 @Nullable private double[] classicUpperBounds; 716 @Nullable private Integer nativeInitialSchema; 717 @Nullable private Double nativeMaxZeroThreshold; 718 @Nullable private Double nativeMinZeroThreshold; 719 @Nullable private Integer nativeMaxNumberOfBuckets; 720 @Nullable private Long nativeResetDurationSeconds; 721 722 @Override 723 public Histogram build() { 724 return new Histogram(this, properties); 725 } 726 727 @Override 728 protected MetricsProperties toProperties() { 729 MetricsProperties.Builder builder = MetricsProperties.builder(); 730 if (classicUpperBounds != null) { 731 builder.histogramClassicUpperBounds(classicUpperBounds); 732 } 733 return builder 734 .exemplarsEnabled(exemplarsEnabled) 735 .histogramNativeOnly(nativeOnly) 736 .histogramClassicOnly(classicOnly) 737 .histogramNativeInitialSchema(nativeInitialSchema) 738 .histogramNativeMinZeroThreshold(nativeMinZeroThreshold) 739 .histogramNativeMaxZeroThreshold(nativeMaxZeroThreshold) 740 .histogramNativeMaxNumberOfBuckets(nativeMaxNumberOfBuckets) 741 .histogramNativeResetDurationSeconds(nativeResetDurationSeconds) 742 .build(); 743 } 744 745 /** Default properties for histogram metrics. */ 746 @Override 747 public MetricsProperties getDefaultProperties() { 748 return MetricsProperties.builder() 749 .exemplarsEnabled(true) 750 .histogramNativeOnly(false) 751 .histogramClassicOnly(false) 752 .histogramClassicUpperBounds(DEFAULT_CLASSIC_UPPER_BOUNDS) 753 .histogramNativeInitialSchema(DEFAULT_NATIVE_INITIAL_SCHEMA) 754 .histogramNativeMinZeroThreshold(DEFAULT_NATIVE_MIN_ZERO_THRESHOLD) 755 .histogramNativeMaxZeroThreshold(DEFAULT_NATIVE_MAX_ZERO_THRESHOLD) 756 .histogramNativeMaxNumberOfBuckets(DEFAULT_NATIVE_MAX_NUMBER_OF_BUCKETS) 757 .histogramNativeResetDurationSeconds(DEFAULT_NATIVE_RESET_DURATION_SECONDS) 758 .build(); 759 } 760 761 private Builder(PrometheusProperties config) { 762 super(Collections.singletonList("le"), config); 763 } 764 765 /** 766 * Use the native histogram representation only, i.e. don't maintain classic histogram buckets. 767 * See {@link Histogram} for more info. 768 */ 769 public Builder nativeOnly() { 770 if (Boolean.TRUE.equals(classicOnly)) { 771 throw new IllegalArgumentException("Cannot call nativeOnly() after calling classicOnly()."); 772 } 773 nativeOnly = true; 774 return this; 775 } 776 777 /** 778 * Use the classic histogram representation only, i.e. don't maintain native histogram buckets. 779 * See {@link Histogram} for more info. 780 */ 781 public Builder classicOnly() { 782 if (Boolean.TRUE.equals(nativeOnly)) { 783 throw new IllegalArgumentException("Cannot call classicOnly() after calling nativeOnly()."); 784 } 785 classicOnly = true; 786 return this; 787 } 788 789 /** 790 * Set the upper bounds for the classic histogram buckets. Default is {@link 791 * Builder#DEFAULT_CLASSIC_UPPER_BOUNDS}. If the +Inf bucket is missing it will be added. If 792 * upperBounds contains duplicates the duplicates will be removed. 793 */ 794 public Builder classicUpperBounds(double... upperBounds) { 795 this.classicUpperBounds = upperBounds; 796 for (double bound : upperBounds) { 797 if (Double.isNaN(bound)) { 798 throw new IllegalArgumentException("Cannot use NaN as upper bound for a histogram"); 799 } 800 } 801 return this; 802 } 803 804 /** 805 * Create classic histogram buckets with linear bucket boundaries. 806 * 807 * <p>Example: {@code classicLinearUpperBounds(1.0, 0.5, 10)} creates bucket boundaries {@code 808 * [[1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5]}. 809 * 810 * @param start is the first bucket boundary 811 * @param width is the width of each bucket 812 * @param count is the total number of buckets, including start 813 */ 814 public Builder classicLinearUpperBounds(double start, double width, int count) { 815 this.classicUpperBounds = new double[count]; 816 // Use BigDecimal to avoid weird bucket boundaries like 0.7000000000000001. 817 BigDecimal s = new BigDecimal(Double.toString(start)); 818 BigDecimal w = new BigDecimal(Double.toString(width)); 819 for (int i = 0; i < count; i++) { 820 classicUpperBounds[i] = s.add(w.multiply(new BigDecimal(i))).doubleValue(); 821 } 822 return this; 823 } 824 825 /** 826 * Create classic histogram buckets with exponential boundaries. 827 * 828 * <p>Example: {@code classicExponentialUpperBounds(1.0, 2.0, 10)} creates bucket boundaries 829 * {@code [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 512.0]} 830 * 831 * @param start is the first bucket boundary 832 * @param factor growth factor 833 * @param count total number of buckets, including start 834 */ 835 public Builder classicExponentialUpperBounds(double start, double factor, int count) { 836 classicUpperBounds = new double[count]; 837 for (int i = 0; i < count; i++) { 838 classicUpperBounds[i] = start * Math.pow(factor, i); 839 } 840 return this; 841 } 842 843 /** 844 * The schema is a number in [-4, 8] defining the resolution of the native histogram. Default is 845 * {@link Builder#DEFAULT_NATIVE_INITIAL_SCHEMA}. 846 * 847 * <p>The higher the schema, the finer the resolution. Schema is Prometheus terminology. In 848 * OpenTelemetry it's called "scale". 849 * 850 * <p>Note that the schema for a histogram may be automatically decreased at runtime if the 851 * number of native histogram buckets exceeds {@link #nativeMaxNumberOfBuckets(int)}. 852 * 853 * <p>The following table shows: 854 * 855 * <ul> 856 * <li>factor: The growth factor for bucket boundaries, i.e. next bucket boundary = growth 857 * factor * previous bucket boundary. 858 * <li>max quantile error: The maximum error for quantiles calculated using the Prometheus 859 * histogram_quantile() function, relative to the observed value, assuming harmonic mean. 860 * </ul> 861 * 862 * <table border="1"> 863 * <caption>max quantile errors for different growth factors</caption> 864 * <tr> 865 * <td>schema</td><td>factor</td><td>max quantile error</td> 866 * </tr> 867 * <tr> 868 * <td>-4</td><td>65.536</td><td>99%</td> 869 * </tr> 870 * <tr> 871 * <td>-3</td><td>256</td><td>99%</td> 872 * </tr> 873 * <tr> 874 * <td>-2</td><td>16</td><td>88%</td> 875 * </tr> 876 * <tr> 877 * <td>-1</td><td>4</td><td>60%</td> 878 * </tr> 879 * <tr> 880 * <td>0</td><td>2</td><td>33%</td> 881 * </tr> 882 * <tr> 883 * <td>1</td><td>1.4142...</td><td>17%</td> 884 * </tr> 885 * <tr> 886 * <td>2</td><td>1.1892...</td><td>9%</td> 887 * </tr> 888 * <tr> 889 * <td>3</td><td>1.1090...</td><td>4%</td> 890 * </tr> 891 * <tr> 892 * <td>4</td><td>1.0442...</td><td>2%</td> 893 * </tr> 894 * <tr> 895 * <td>5</td><td>1.0218...</td><td>1%</td> 896 * </tr> 897 * <tr> 898 * <td>6</td><td>1.0108...</td><td>0.5%</td> 899 * </tr> 900 * <tr> 901 * <td>7</td><td>1.0054...</td><td>0.3%</td> 902 * </tr> 903 * <tr> 904 * <td>8</td><td>1.0027...</td><td>0.1%</td> 905 * </tr> 906 * </table> 907 */ 908 public Builder nativeInitialSchema(int nativeSchema) { 909 if (nativeSchema < -4 || nativeSchema > 8) { 910 throw new IllegalArgumentException( 911 "Unsupported native histogram schema " 912 + nativeSchema 913 + ": expecting -4 <= schema <= 8."); 914 } 915 this.nativeInitialSchema = nativeSchema; 916 return this; 917 } 918 919 /** 920 * Native histogram buckets get smaller and smaller the closer they get to zero. To avoid 921 * wasting a lot of buckets for observations fluctuating around zero, we consider all values in 922 * [-zeroThreshold, +zeroThreshold] to be equal to zero. 923 * 924 * <p>The zeroThreshold is initialized with minZeroThreshold, and will grow up to 925 * maxZeroThreshold if the number of native histogram buckets exceeds nativeMaxBuckets. 926 * 927 * <p>Default is {@link Builder#DEFAULT_NATIVE_MAX_NUMBER_OF_BUCKETS}. 928 */ 929 public Builder nativeMaxZeroThreshold(double nativeMaxZeroThreshold) { 930 if (nativeMaxZeroThreshold < 0) { 931 throw new IllegalArgumentException( 932 "Illegal native max zero threshold " + nativeMaxZeroThreshold + ": must be >= 0"); 933 } 934 this.nativeMaxZeroThreshold = nativeMaxZeroThreshold; 935 return this; 936 } 937 938 /** 939 * Native histogram buckets get smaller and smaller the closer they get to zero. To avoid 940 * wasting a lot of buckets for observations fluctuating around zero, we consider all values in 941 * [-zeroThreshold, +zeroThreshold] to be equal to zero. 942 * 943 * <p>The zeroThreshold is initialized with minZeroThreshold, and will grow up to 944 * maxZeroThreshold if the number of native histogram buckets exceeds nativeMaxBuckets. 945 * 946 * <p>Default is {@link Builder#DEFAULT_NATIVE_MIN_ZERO_THRESHOLD}. 947 */ 948 public Builder nativeMinZeroThreshold(double nativeMinZeroThreshold) { 949 if (nativeMinZeroThreshold < 0) { 950 throw new IllegalArgumentException( 951 "Illegal native min zero threshold " + nativeMinZeroThreshold + ": must be >= 0"); 952 } 953 this.nativeMinZeroThreshold = nativeMinZeroThreshold; 954 return this; 955 } 956 957 /** 958 * Limit the number of native buckets. 959 * 960 * <p>If the number of native buckets exceeds the maximum, the {@link #nativeInitialSchema(int)} 961 * is decreased, i.e. the resolution of the histogram is decreased to reduce the number of 962 * buckets. 963 * 964 * <p>Default is {@link Builder#DEFAULT_NATIVE_MAX_NUMBER_OF_BUCKETS}. 965 */ 966 public Builder nativeMaxNumberOfBuckets(int nativeMaxBuckets) { 967 this.nativeMaxNumberOfBuckets = nativeMaxBuckets; 968 return this; 969 } 970 971 /** 972 * If the histogram needed to be scaled down because {@link #nativeMaxNumberOfBuckets(int)} was 973 * exceeded, reset the histogram after a certain time interval to go back to the original {@link 974 * #nativeInitialSchema(int)}. 975 * 976 * <p>Reset means all values are set to zero. A good value might be 24h or 7d. 977 * 978 * <p>Default is no reset. 979 */ 980 public Builder nativeResetDuration(long duration, TimeUnit unit) { 981 if (duration <= 0) { 982 throw new IllegalArgumentException(duration + ": value > 0 expected"); 983 } 984 long seconds = unit.toSeconds(duration); 985 if (seconds == 0) { 986 throw new IllegalArgumentException( 987 duration 988 + " " 989 + unit 990 + ": duration must be at least 1 second. Sub-second durations are not supported."); 991 } 992 nativeResetDurationSeconds = seconds; 993 return this; 994 } 995 996 @Override 997 protected Builder self() { 998 return this; 999 } 1000 } 1001}