001package io.prometheus.metrics.model.registry;
002
003import static java.util.Collections.emptySet;
004
005import io.prometheus.metrics.model.snapshots.MetricMetadata;
006import io.prometheus.metrics.model.snapshots.MetricSnapshot;
007import io.prometheus.metrics.model.snapshots.MetricSnapshots;
008import io.prometheus.metrics.model.snapshots.Unit;
009import java.util.ArrayList;
010import java.util.Collections;
011import java.util.HashSet;
012import java.util.List;
013import java.util.Objects;
014import java.util.Set;
015import java.util.concurrent.ConcurrentHashMap;
016import java.util.function.Predicate;
017import javax.annotation.Nullable;
018
019public class PrometheusRegistry {
020
021  public static final PrometheusRegistry defaultRegistry = new PrometheusRegistry();
022
023  private final Set<Collector> collectors = ConcurrentHashMap.newKeySet();
024  private final Set<MultiCollector> multiCollectors = ConcurrentHashMap.newKeySet();
025  private final ConcurrentHashMap<String, RegistrationInfo> registered = new ConcurrentHashMap<>();
026  private final ConcurrentHashMap<Collector, CollectorRegistration> collectorMetadata =
027      new ConcurrentHashMap<>();
028  private final ConcurrentHashMap<MultiCollector, List<MultiCollectorRegistration>>
029      multiCollectorMetadata = new ConcurrentHashMap<>();
030
031  /**
032   * Maps exposition-level names (e.g. "events_total", "events_created") to the owning
033   * prometheusName (e.g. "events"). Used to detect cross-type collisions at registration time.
034   */
035  private final ConcurrentHashMap<String, String> expositionNameOwners = new ConcurrentHashMap<>();
036
037  /** Stores the registration details for a Collector at registration time. */
038  private static class CollectorRegistration {
039    final String prometheusName;
040    @Nullable final String expositionBasePrometheusName;
041    final Set<String> labelNames;
042
043    CollectorRegistration(
044        String prometheusName,
045        @Nullable String expositionBasePrometheusName,
046        @Nullable Set<String> labelNames) {
047      this.prometheusName = prometheusName;
048      this.expositionBasePrometheusName = expositionBasePrometheusName;
049      this.labelNames = immutableLabelNames(labelNames);
050    }
051  }
052
053  /**
054   * Stores the registration details for a single metric within a MultiCollector. A MultiCollector
055   * can produce multiple metrics, so we need one of these per metric name.
056   */
057  private static class MultiCollectorRegistration {
058    final String prometheusName;
059    final Set<String> labelNames;
060
061    MultiCollectorRegistration(String prometheusName, @Nullable Set<String> labelNames) {
062      this.prometheusName = prometheusName;
063      this.labelNames = immutableLabelNames(labelNames);
064    }
065  }
066
067  /**
068   * Tracks registration information for each metric name to enable validation of type consistency,
069   * label schema uniqueness, and help/unit consistency.
070   */
071  private static class RegistrationInfo {
072    private final MetricType type;
073    private final Set<Set<String>> labelSchemas;
074    @Nullable private String help;
075    @Nullable private Unit unit;
076
077    private RegistrationInfo(
078        MetricType type,
079        Set<Set<String>> labelSchemas,
080        @Nullable String help,
081        @Nullable Unit unit) {
082      this.type = type;
083      this.labelSchemas = labelSchemas;
084      this.help = help;
085      this.unit = unit;
086    }
087
088    static RegistrationInfo of(
089        MetricType type,
090        @Nullable Set<String> labelNames,
091        @Nullable String help,
092        @Nullable Unit unit) {
093      Set<Set<String>> labelSchemas = ConcurrentHashMap.newKeySet();
094      Set<String> normalized =
095          (labelNames == null || labelNames.isEmpty()) ? emptySet() : labelNames;
096      labelSchemas.add(normalized);
097      return new RegistrationInfo(type, labelSchemas, help, unit);
098    }
099
100    /**
101     * Validates that the given help and unit are exactly equal to this registration. Throws if
102     * values differ, including when one is null and the other is non-null. This ensures consistent
103     * metadata across all collectors sharing the same metric name.
104     */
105    void validateMetadata(@Nullable String newHelp, @Nullable Unit newUnit) {
106      if (!Objects.equals(help, newHelp)) {
107        throw new IllegalArgumentException(
108            "Conflicting help strings. Existing: \"" + help + "\", new: \"" + newHelp + "\"");
109      }
110      if (!Objects.equals(unit, newUnit)) {
111        throw new IllegalArgumentException(
112            "Conflicting unit. Existing: " + unit + ", new: " + newUnit);
113      }
114    }
115
116    /**
117     * Adds a label schema to this registration.
118     *
119     * @param labelNames the label names to add (null or empty sets are normalized to empty set)
120     * @return true if the schema was added (new), false if it already existed
121     */
122    boolean addLabelSet(@Nullable Set<String> labelNames) {
123      Set<String> normalized =
124          (labelNames == null || labelNames.isEmpty()) ? emptySet() : labelNames;
125      return labelSchemas.add(normalized);
126    }
127
128    /**
129     * Removes a label schema from this registration.
130     *
131     * @param labelNames the label names to remove (null or empty sets are normalized to empty set)
132     */
133    void removeLabelSet(@Nullable Set<String> labelNames) {
134      Set<String> normalized =
135          (labelNames == null || labelNames.isEmpty()) ? emptySet() : labelNames;
136      labelSchemas.remove(normalized);
137    }
138
139    /** Returns true if all label schemas have been unregistered. */
140    boolean isEmpty() {
141      return labelSchemas.isEmpty();
142    }
143
144    MetricType getType() {
145      return type;
146    }
147  }
148
149  /**
150   * Returns an immutable set of label names for storage. Defends against mutation of the set
151   * returned by {@code Collector.getLabelNames()} after registration, which would break duplicate
152   * detection and unregistration.
153   */
154  private static Set<String> immutableLabelNames(@Nullable Set<String> labelNames) {
155    if (labelNames == null || labelNames.isEmpty()) {
156      return emptySet();
157    }
158    return Collections.unmodifiableSet(new HashSet<>(labelNames));
159  }
160
161  /**
162   * Computes the set of exposition-level time series names that a metric with the given name and
163   * type will produce.
164   */
165  static Set<String> computeExpositionNames(String prometheusName, MetricType type) {
166    Set<String> names = new HashSet<>();
167    switch (type) {
168      case COUNTER:
169        names.add(prometheusName + "_total");
170        names.add(prometheusName + "_created");
171        break;
172      case INFO:
173        names.add(prometheusName + "_info");
174        break;
175      case HISTOGRAM:
176        names.add(prometheusName + "_bucket");
177        names.add(prometheusName + "_count");
178        names.add(prometheusName + "_sum");
179        names.add(prometheusName + "_created");
180        break;
181      case SUMMARY:
182        names.add(prometheusName + "_count");
183        names.add(prometheusName + "_sum");
184        names.add(prometheusName + "_created");
185        break;
186      case GAUGE:
187      case STATESET:
188      case UNKNOWN:
189      default:
190        names.add(prometheusName);
191        break;
192    }
193    return names;
194  }
195
196  /**
197   * Validates the registration of a metric with the given parameters. Ensures type consistency,
198   * label schema uniqueness, help/unit consistency, and exposition name collision detection.
199   */
200  private void validateRegistration(
201      String prometheusName,
202      MetricType metricType,
203      Set<String> normalizedLabels,
204      @Nullable String help,
205      @Nullable Unit unit) {
206    final MetricType type = metricType;
207    final Set<String> names = normalizedLabels;
208    final String helpForValidation = help;
209    final Unit unitForValidation = unit;
210
211    Set<String> expositionNames = computeExpositionNames(prometheusName, type);
212    String claimError = claimExpositionNames(expositionNames, prometheusName);
213    if (claimError != null) {
214      throw new IllegalArgumentException(claimError);
215    }
216
217    boolean success = false;
218    try {
219      registered.compute(
220          prometheusName,
221          (n, existingInfo) -> {
222            if (existingInfo == null) {
223              return RegistrationInfo.of(type, names, helpForValidation, unitForValidation);
224            } else {
225              if (existingInfo.getType() != type) {
226                throw new IllegalArgumentException(
227                    prometheusName
228                        + ": Conflicting metric types. Existing: "
229                        + existingInfo.getType()
230                        + ", new: "
231                        + type);
232              }
233              // Check label set first; only mutate help/unit after validation passes.
234              if (!existingInfo.addLabelSet(names)) {
235                throw new IllegalArgumentException(
236                    prometheusName
237                        + ": duplicate metric name with identical label schema "
238                        + names);
239              }
240              // Roll back label schema if metadata validation fails
241              try {
242                existingInfo.validateMetadata(helpForValidation, unitForValidation);
243              } catch (IllegalArgumentException e) {
244                existingInfo.removeLabelSet(names);
245                throw e;
246              }
247              return existingInfo;
248            }
249          });
250      success = true;
251    } finally {
252      if (!success) {
253        releaseExpositionNames(expositionNames, prometheusName);
254      }
255    }
256  }
257
258  /**
259   * Atomically claims exposition names for the given metric. Returns null on success, or an error
260   * message on conflict. Rolls back any partially claimed names on failure.
261   */
262  @Nullable
263  private String claimExpositionNames(Set<String> expositionNames, String prometheusName) {
264    for (String expositionName : expositionNames) {
265      String existingOwner = expositionNameOwners.putIfAbsent(expositionName, prometheusName);
266      if (existingOwner != null && !existingOwner.equals(prometheusName)) {
267        releaseExpositionNames(expositionNames, prometheusName);
268        return "'"
269            + prometheusName
270            + "' and '"
271            + existingOwner
272            + "' have conflicting exposition name: '"
273            + expositionName
274            + "'";
275      }
276    }
277    return null;
278  }
279
280  private void releaseExpositionNames(Set<String> expositionNames, String owner) {
281    for (String expositionName : expositionNames) {
282      expositionNameOwners.remove(expositionName, owner);
283    }
284  }
285
286  public void register(Collector collector) {
287    if (!collectors.add(collector)) {
288      throw new IllegalArgumentException("Collector instance is already registered");
289    }
290    try {
291      String prometheusName = collector.getPrometheusName();
292      MetricType metricType = collector.getMetricType();
293      Set<String> normalizedLabels = immutableLabelNames(collector.getLabelNames());
294      MetricMetadata metadata = collector.getMetadata();
295      String help = metadata != null ? metadata.getHelp() : null;
296      Unit unit = metadata != null ? metadata.getUnit() : null;
297
298      // Only perform validation if collector provides sufficient metadata.
299      // Collectors that don't implement getPrometheusName()/getMetricType() will skip validation.
300      if (prometheusName != null && metricType != null) {
301        validateRegistration(prometheusName, metricType, normalizedLabels, help, unit);
302        String expositionBasePrometheusName =
303            metadata != null ? metadata.getExpositionBasePrometheusName() : null;
304        collectorMetadata.put(
305            collector,
306            new CollectorRegistration(
307                prometheusName, expositionBasePrometheusName, normalizedLabels));
308      }
309      // Catch RuntimeException broadly because collector methods (getPrometheusName, getMetricType,
310      // etc.) are user-implemented and could throw any RuntimeException. Ensures cleanup on
311      // failure.
312    } catch (RuntimeException e) {
313      collectors.remove(collector);
314      CollectorRegistration reg = collectorMetadata.remove(collector);
315      if (reg != null && reg.prometheusName != null) {
316        unregisterLabelSchema(reg.prometheusName, reg.labelNames);
317      }
318      throw e;
319    }
320  }
321
322  public void register(MultiCollector collector) {
323    if (!multiCollectors.add(collector)) {
324      throw new IllegalArgumentException("MultiCollector instance is already registered");
325    }
326    List<String> prometheusNamesList = collector.getPrometheusNames();
327    List<MultiCollectorRegistration> registrations = new ArrayList<>();
328
329    try {
330      for (String prometheusName : prometheusNamesList) {
331        MetricType metricType = collector.getMetricType(prometheusName);
332        Set<String> normalizedLabels = immutableLabelNames(collector.getLabelNames(prometheusName));
333        MetricMetadata metadata = collector.getMetadata(prometheusName);
334        String help = metadata != null ? metadata.getHelp() : null;
335        Unit unit = metadata != null ? metadata.getUnit() : null;
336
337        if (metricType != null) {
338          validateRegistration(prometheusName, metricType, normalizedLabels, help, unit);
339          registrations.add(new MultiCollectorRegistration(prometheusName, normalizedLabels));
340        }
341      }
342
343      multiCollectorMetadata.put(collector, registrations);
344      // Catch RuntimeException broadly because collector methods (getPrometheusNames,
345      // getMetricType, etc.) are user-implemented and could throw any RuntimeException.
346      // Ensures cleanup on failure.
347    } catch (RuntimeException e) {
348      multiCollectors.remove(collector);
349      for (MultiCollectorRegistration registration : registrations) {
350        unregisterLabelSchema(registration.prometheusName, registration.labelNames);
351      }
352      throw e;
353    }
354  }
355
356  public void unregister(Collector collector) {
357    collectors.remove(collector);
358
359    CollectorRegistration registration = collectorMetadata.remove(collector);
360    if (registration != null && registration.prometheusName != null) {
361      unregisterLabelSchema(registration.prometheusName, registration.labelNames);
362    }
363  }
364
365  public void unregister(MultiCollector collector) {
366    multiCollectors.remove(collector);
367
368    List<MultiCollectorRegistration> registrations = multiCollectorMetadata.remove(collector);
369    if (registrations != null) {
370      for (MultiCollectorRegistration registration : registrations) {
371        unregisterLabelSchema(registration.prometheusName, registration.labelNames);
372      }
373    }
374  }
375
376  /**
377   * Removes the label schema for the given metric name. If no label schemas remain for that name,
378   * removes the metric name entirely from the registry, including its exposition name reservations.
379   */
380  private void unregisterLabelSchema(String prometheusName, Set<String> labelNames) {
381    registered.computeIfPresent(
382        prometheusName,
383        (name, info) -> {
384          info.removeLabelSet(labelNames);
385          if (info.isEmpty()) {
386            // Remove exposition name reservations for this metric.
387            Set<String> expositionNames = computeExpositionNames(prometheusName, info.getType());
388            for (String expositionName : expositionNames) {
389              expositionNameOwners.remove(expositionName, prometheusName);
390            }
391            return null;
392          }
393          return info;
394        });
395  }
396
397  public void clear() {
398    collectors.clear();
399    multiCollectors.clear();
400    registered.clear();
401    collectorMetadata.clear();
402    multiCollectorMetadata.clear();
403    expositionNameOwners.clear();
404  }
405
406  public MetricSnapshots scrape() {
407    return scrape((PrometheusScrapeRequest) null);
408  }
409
410  public MetricSnapshots scrape(@Nullable PrometheusScrapeRequest scrapeRequest) {
411    List<MetricSnapshot> allSnapshots = new ArrayList<>();
412    for (Collector collector : collectors) {
413      MetricSnapshot snapshot =
414          scrapeRequest == null ? collector.collect() : collector.collect(scrapeRequest);
415      if (snapshot != null) {
416        allSnapshots.add(snapshot);
417      }
418    }
419    for (MultiCollector collector : multiCollectors) {
420      MetricSnapshots snapshots =
421          scrapeRequest == null ? collector.collect() : collector.collect(scrapeRequest);
422      for (MetricSnapshot snapshot : snapshots) {
423        allSnapshots.add(snapshot);
424      }
425    }
426
427    MetricSnapshots.Builder result = MetricSnapshots.builder();
428    for (MetricSnapshot snapshot : allSnapshots) {
429      result.metricSnapshot(snapshot);
430    }
431    return result.build();
432  }
433
434  public MetricSnapshots scrape(Predicate<String> includedNames) {
435    if (includedNames == null) {
436      return scrape();
437    }
438    return scrape(includedNames, null);
439  }
440
441  public MetricSnapshots scrape(
442      Predicate<String> includedNames, @Nullable PrometheusScrapeRequest scrapeRequest) {
443    if (includedNames == null) {
444      return scrape(scrapeRequest);
445    }
446    List<MetricSnapshot> allSnapshots = new ArrayList<>();
447    for (Collector collector : collectors) {
448      String prometheusName = collector.getPrometheusName();
449      // prometheusName == null means the name is unknown, and we have to scrape to learn the name.
450      // prometheusName != null means we can skip the scrape if the name is excluded.
451      // Also test the original name (e.g. "events_total" for a counter named "events").
452      CollectorRegistration reg = collectorMetadata.get(collector);
453      String expositionName = reg != null ? reg.expositionBasePrometheusName : null;
454      if (prometheusName == null
455          || includedNames.test(prometheusName)
456          || (expositionName != null && includedNames.test(expositionName))) {
457        MetricSnapshot snapshot =
458            scrapeRequest == null
459                ? collector.collect(includedNames)
460                : collector.collect(includedNames, scrapeRequest);
461        if (snapshot != null) {
462          allSnapshots.add(snapshot);
463        }
464      }
465    }
466    for (MultiCollector collector : multiCollectors) {
467      List<String> prometheusNames = collector.getPrometheusNames();
468      // empty prometheusNames means the names are unknown, and we have to scrape to learn the
469      // names.
470      // non-empty prometheusNames means we can exclude the collector if all names are excluded by
471      // the filter.
472      boolean excluded = !prometheusNames.isEmpty();
473      for (String prometheusName : prometheusNames) {
474        if (includedNames.test(prometheusName)) {
475          excluded = false;
476          break;
477        }
478      }
479      if (!excluded) {
480        MetricSnapshots snapshots =
481            scrapeRequest == null
482                ? collector.collect(includedNames)
483                : collector.collect(includedNames, scrapeRequest);
484        for (MetricSnapshot snapshot : snapshots) {
485          if (snapshot != null) {
486            allSnapshots.add(snapshot);
487          }
488        }
489      }
490    }
491
492    MetricSnapshots.Builder result = MetricSnapshots.builder();
493    for (MetricSnapshot snapshot : allSnapshots) {
494      result.metricSnapshot(snapshot);
495    }
496    return result.build();
497  }
498}