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