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