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}