001package io.prometheus.metrics.core.metrics;
002
003import static java.util.Objects.requireNonNull;
004
005import io.prometheus.metrics.annotations.StableApi;
006import io.prometheus.metrics.config.MetricsProperties;
007import io.prometheus.metrics.config.PrometheusProperties;
008import io.prometheus.metrics.core.datapoints.DistributionDataPoint;
009import io.prometheus.metrics.core.exemplars.ExemplarSampler;
010import io.prometheus.metrics.core.exemplars.ExemplarSamplerConfig;
011import io.prometheus.metrics.model.registry.MetricType;
012import io.prometheus.metrics.model.snapshots.Exemplars;
013import io.prometheus.metrics.model.snapshots.Labels;
014import io.prometheus.metrics.model.snapshots.Quantile;
015import io.prometheus.metrics.model.snapshots.Quantiles;
016import io.prometheus.metrics.model.snapshots.SummarySnapshot;
017import java.util.ArrayList;
018import java.util.Collections;
019import java.util.List;
020import java.util.concurrent.TimeUnit;
021import java.util.concurrent.atomic.DoubleAdder;
022import java.util.concurrent.atomic.LongAdder;
023import java.util.function.Supplier;
024import javax.annotation.Nullable;
025
026/**
027 * Summary metric. Example:
028 *
029 * <pre>{@code
030 * Summary summary = Summary.builder()
031 *         .name("http_request_duration_seconds_hi")
032 *         .help("HTTP request service time in seconds")
033 *         .unit(SECONDS)
034 *         .labelNames("method", "path", "status_code")
035 *         .quantile(0.5, 0.01)
036 *         .quantile(0.95, 0.001)
037 *         .quantile(0.99, 0.001)
038 *         .register();
039 *
040 * long start = System.nanoTime();
041 * // process a request, duration will be observed
042 * summary.labelValues("GET", "/", "200").observe(Unit.nanosToSeconds(System.nanoTime() - start));
043 * }</pre>
044 *
045 * See {@link Summary.Builder} for configuration options.
046 */
047@StableApi
048public class Summary extends StatefulMetric<DistributionDataPoint, Summary.DataPoint>
049    implements DistributionDataPoint {
050
051  private final List<CKMSQuantiles.Quantile> quantiles; // May be empty, but cannot be null.
052
053  private final long maxAgeSeconds;
054  private final int ageBuckets;
055  @Nullable private final ExemplarSamplerConfig exemplarSamplerConfig;
056  @Nullable private final Supplier<Labels> exemplarLabelsSupplier;
057
058  private Summary(Builder builder, PrometheusProperties prometheusProperties) {
059    super(builder);
060    MetricsProperties[] properties = getMetricProperties(builder, prometheusProperties);
061    quantiles = Collections.unmodifiableList(makeQuantiles(properties));
062    maxAgeSeconds = getConfigProperty(properties, MetricsProperties::getSummaryMaxAgeSeconds);
063    ageBuckets = getConfigProperty(properties, MetricsProperties::getSummaryNumberOfAgeBuckets);
064    boolean exemplarsEnabled =
065        getConfigProperty(properties, MetricsProperties::getExemplarsEnabled);
066    if (exemplarsEnabled) {
067      exemplarSamplerConfig =
068          new ExemplarSamplerConfig(prometheusProperties.getExemplarProperties(), 4);
069    } else {
070      exemplarSamplerConfig = null;
071    }
072    exemplarLabelsSupplier = builder.exemplarLabelsSupplier;
073  }
074
075  private List<CKMSQuantiles.Quantile> makeQuantiles(MetricsProperties[] properties) {
076    List<CKMSQuantiles.Quantile> result = new ArrayList<>();
077    List<Double> quantiles = getConfigProperty(properties, MetricsProperties::getSummaryQuantiles);
078    List<Double> quantileErrors =
079        getConfigProperty(properties, MetricsProperties::getSummaryQuantileErrors);
080    if (quantiles != null) {
081      for (int i = 0; i < quantiles.size(); i++) {
082        if (quantileErrors.size() > 0) {
083          result.add(new CKMSQuantiles.Quantile(quantiles.get(i), quantileErrors.get(i)));
084        } else {
085          result.add(
086              new CKMSQuantiles.Quantile(quantiles.get(i), Builder.defaultError(quantiles.get(i))));
087        }
088      }
089    }
090    return result;
091  }
092
093  @Override
094  public double getSum() {
095    return getNoLabels().getSum();
096  }
097
098  @Override
099  public long getCount() {
100    return getNoLabels().getCount();
101  }
102
103  @Override
104  public void observe(double amount) {
105    getNoLabels().observe(amount);
106  }
107
108  @Override
109  public void observeWithExemplar(double amount, Labels labels) {
110    getNoLabels().observeWithExemplar(amount, labels);
111  }
112
113  @Override
114  public SummarySnapshot collect() {
115    return (SummarySnapshot) super.collect();
116  }
117
118  @Override
119  protected SummarySnapshot collect(List<Labels> labels, List<DataPoint> metricData) {
120    List<SummarySnapshot.SummaryDataPointSnapshot> data = new ArrayList<>(labels.size());
121    for (int i = 0; i < labels.size(); i++) {
122      data.add(metricData.get(i).collect(labels.get(i)));
123    }
124    return new SummarySnapshot(metadata, data);
125  }
126
127  /**
128   * @deprecated Use {@link #getMetricFamilyDescriptor()} instead.
129   */
130  @Override
131  @Deprecated
132  @SuppressWarnings("InlineMeSuggester")
133  public MetricType getMetricType() {
134    return MetricType.SUMMARY;
135  }
136
137  @Override
138  protected DataPoint newDataPoint() {
139    return new DataPoint();
140  }
141
142  public class DataPoint implements DistributionDataPoint {
143
144    private final LongAdder count = new LongAdder();
145    private final DoubleAdder sum = new DoubleAdder();
146    @Nullable private final SlidingWindow<CKMSQuantiles> quantileValues;
147    private final Buffer buffer = new Buffer();
148    @Nullable private final ExemplarSampler exemplarSampler;
149
150    private final long createdTimeMillis = System.currentTimeMillis();
151
152    private DataPoint() {
153      if (quantiles.isEmpty()) {
154        quantileValues = null;
155      } else {
156        CKMSQuantiles.Quantile[] quantilesArray = quantiles.toArray(new CKMSQuantiles.Quantile[0]);
157        quantileValues =
158            new SlidingWindow<>(
159                CKMSQuantiles.class,
160                () -> new CKMSQuantiles(quantilesArray),
161                CKMSQuantiles::insert,
162                maxAgeSeconds,
163                ageBuckets);
164      }
165      if (exemplarSamplerConfig != null) {
166        exemplarSampler = new ExemplarSampler(exemplarSamplerConfig, null, exemplarLabelsSupplier);
167      } else {
168        exemplarSampler = null;
169      }
170    }
171
172    @Override
173    public double getSum() {
174      return sum.sum();
175    }
176
177    @Override
178    public long getCount() {
179      return count.sum();
180    }
181
182    @Override
183    public void observe(double value) {
184      if (Double.isNaN(value)) {
185        return;
186      }
187      if (!buffer.append(value)) {
188        doObserve(value);
189      }
190      if (exemplarSampler != null) {
191        exemplarSampler.observe(value);
192      }
193    }
194
195    @Override
196    public void observeWithExemplar(double value, Labels labels) {
197      if (Double.isNaN(value)) {
198        return;
199      }
200      if (!buffer.append(value)) {
201        doObserve(value);
202      }
203      if (exemplarSampler != null) {
204        exemplarSampler.observeWithExemplar(value, labels);
205      }
206    }
207
208    private void doObserve(double amount) {
209      sum.add(amount);
210      if (quantileValues != null) {
211        quantileValues.observe(amount);
212      }
213      // count must be incremented last, because in collect() the count
214      // indicates the number of completed observations.
215      count.increment();
216    }
217
218    private SummarySnapshot.SummaryDataPointSnapshot collect(Labels labels) {
219      return buffer.run(
220          expectedCount -> count.sum() == expectedCount,
221          // Note: Exemplars are currently hard-coded as empty for Summary metrics.
222          // While exemplars are sampled during observe() and observeWithExemplar() calls
223          // via the exemplarSampler field, they are not included in the snapshot to maintain
224          // consistency with the buffering mechanism. The buffer.run() ensures atomic
225          // collection of count, sum, and quantiles. Adding exemplars would require
226          // coordination between the buffer and exemplarSampler, which could impact
227          // performance. Consider using Histogram instead if exemplars are needed.
228          () ->
229              new SummarySnapshot.SummaryDataPointSnapshot(
230                  count.sum(),
231                  sum.sum(),
232                  makeQuantiles(),
233                  labels,
234                  Exemplars.EMPTY,
235                  createdTimeMillis),
236          this::doObserve);
237    }
238
239    private List<CKMSQuantiles.Quantile> getQuantiles() {
240      return quantiles;
241    }
242
243    private Quantiles makeQuantiles() {
244      Quantile[] quantiles = new Quantile[getQuantiles().size()];
245      for (int i = 0; i < getQuantiles().size(); i++) {
246        CKMSQuantiles.Quantile quantile = getQuantiles().get(i);
247        quantiles[i] =
248            new Quantile(
249                quantile.quantile, requireNonNull(quantileValues).current().get(quantile.quantile));
250      }
251      return Quantiles.of(quantiles);
252    }
253  }
254
255  public static Summary.Builder builder() {
256    return new Builder(PrometheusProperties.get());
257  }
258
259  public static Summary.Builder builder(PrometheusProperties config) {
260    return new Builder(config);
261  }
262
263  public static class Builder extends StatefulMetric.Builder<Summary.Builder, Summary> {
264
265    /** 5 minutes. See {@link #maxAgeSeconds(long)}. */
266    public static final long DEFAULT_MAX_AGE_SECONDS = TimeUnit.MINUTES.toSeconds(5);
267
268    /** 5. See {@link #numberOfAgeBuckets(int)} */
269    public static final int DEFAULT_NUMBER_OF_AGE_BUCKETS = 5;
270
271    private final List<CKMSQuantiles.Quantile> quantiles = new ArrayList<>();
272    @Nullable private Long maxAgeSeconds;
273    @Nullable private Integer ageBuckets;
274
275    private Builder(PrometheusProperties properties) {
276      super(Collections.singletonList("quantile"), properties);
277    }
278
279    private static double defaultError(double quantile) {
280      if (quantile <= 0.01 || quantile >= 0.99) {
281        return 0.001;
282      } else if (quantile <= 0.02 || quantile >= 0.98) {
283        return 0.005;
284      } else {
285        return 0.01;
286      }
287    }
288
289    /**
290     * Add a quantile. See {@link #quantile(double, double)}.
291     *
292     * <p>Default errors are:
293     *
294     * <ul>
295     *   <li>error = 0.001 if quantile &lt;= 0.01 or quantile &gt;= 0.99
296     *   <li>error = 0.005 if quantile &lt;= 0.02 or quantile &gt;= 0.98
297     *   <li>error = 0.01 else.
298     * </ul>
299     */
300    public Builder quantile(double quantile) {
301      return quantile(quantile, defaultError(quantile));
302    }
303
304    /**
305     * Add a quantile. Call multiple times to add multiple quantiles.
306     *
307     * <p>Example: The following will track the 0.95 quantile:
308     *
309     * <pre>{@code
310     * .quantile(0.95, 0.001)
311     * }</pre>
312     *
313     * The second argument is the acceptable error margin, i.e. with the code above the quantile
314     * will not be exactly the 0.95 quantile but something between 0.949 and 0.951.
315     *
316     * <p>There are two special cases:
317     *
318     * <ul>
319     *   <li>{@code .quantile(0.0, 0.0)} gives you the minimum observed value
320     *   <li>{@code .quantile(1.0, 0.0)} gives you the maximum observed value
321     * </ul>
322     */
323    public Builder quantile(double quantile, double error) {
324      if (quantile < 0.0 || quantile > 1.0) {
325        throw new IllegalArgumentException(
326            "Quantile " + quantile + " invalid: Expected number between 0.0 and 1.0.");
327      }
328      if (error < 0.0 || error > 1.0) {
329        throw new IllegalArgumentException(
330            "Error " + error + " invalid: Expected number between 0.0 and 1.0.");
331      }
332      quantiles.add(new CKMSQuantiles.Quantile(quantile, error));
333      return this;
334    }
335
336    /**
337     * The quantiles are relative to a moving time window. {@code maxAgeSeconds} is the size of that
338     * time window. Default is {@link #DEFAULT_MAX_AGE_SECONDS}.
339     */
340    public Builder maxAgeSeconds(long maxAgeSeconds) {
341      if (maxAgeSeconds <= 0) {
342        throw new IllegalArgumentException("maxAgeSeconds cannot be " + maxAgeSeconds);
343      }
344      this.maxAgeSeconds = maxAgeSeconds;
345      return this;
346    }
347
348    /**
349     * The quantiles are relative to a moving time window. The {@code numberOfAgeBuckets} defines
350     * how smoothly the time window moves forward. For example, if the time window is 5 minutes and
351     * has 5 age buckets, then it is moving forward every minute by one minute. Default is {@link
352     * #DEFAULT_NUMBER_OF_AGE_BUCKETS}.
353     */
354    public Builder numberOfAgeBuckets(int ageBuckets) {
355      if (ageBuckets <= 0) {
356        throw new IllegalArgumentException("ageBuckets cannot be " + ageBuckets);
357      }
358      this.ageBuckets = ageBuckets;
359      return this;
360    }
361
362    @Override
363    protected MetricsProperties toProperties() {
364      double[] quantiles = null;
365      double[] quantileErrors = null;
366      if (!this.quantiles.isEmpty()) {
367        quantiles = new double[this.quantiles.size()];
368        quantileErrors = new double[this.quantiles.size()];
369        for (int i = 0; i < this.quantiles.size(); i++) {
370          quantiles[i] = this.quantiles.get(i).quantile;
371          quantileErrors[i] = this.quantiles.get(i).epsilon;
372        }
373      }
374      MetricsProperties.Builder builder = MetricsProperties.builder();
375      if (quantiles != null) {
376        builder.summaryQuantiles(quantiles);
377      }
378      if (quantileErrors != null) {
379        builder.summaryQuantileErrors(quantileErrors);
380      }
381      return builder
382          .exemplarsEnabled(exemplarsEnabled)
383          .summaryNumberOfAgeBuckets(ageBuckets)
384          .summaryMaxAgeSeconds(maxAgeSeconds)
385          .build();
386    }
387
388    /** Default properties for summary metrics. */
389    @Override
390    public MetricsProperties getDefaultProperties() {
391      return MetricsProperties.builder()
392          .exemplarsEnabled(true)
393          .summaryQuantiles()
394          .summaryNumberOfAgeBuckets(DEFAULT_NUMBER_OF_AGE_BUCKETS)
395          .summaryMaxAgeSeconds(DEFAULT_MAX_AGE_SECONDS)
396          .build();
397    }
398
399    @Override
400    public Summary build() {
401      return new Summary(this, properties);
402    }
403
404    @Override
405    protected Builder self() {
406      return this;
407    }
408  }
409}