001package io.prometheus.jmx; 002 003import io.prometheus.client.Collector; 004import io.prometheus.client.Counter; 005import org.yaml.snakeyaml.Yaml; 006 007import java.io.File; 008import java.io.FileReader; 009import java.io.IOException; 010import java.io.InputStream; 011import java.io.PrintWriter; 012import java.io.StringWriter; 013import java.util.ArrayList; 014import java.util.HashMap; 015import java.util.Iterator; 016import java.util.LinkedHashMap; 017import java.util.LinkedList; 018import java.util.List; 019import java.util.Map; 020import java.util.Set; 021import java.util.TreeMap; 022import java.util.logging.Logger; 023import java.util.regex.Matcher; 024import java.util.regex.Pattern; 025import javax.management.MalformedObjectNameException; 026import javax.management.ObjectName; 027 028import static java.lang.String.format; 029 030public class JmxCollector extends Collector implements Collector.Describable { 031 static final Counter configReloadSuccess = Counter.build() 032 .name("jmx_config_reload_success_total") 033 .help("Number of times configuration have successfully been reloaded.").register(); 034 035 static final Counter configReloadFailure = Counter.build() 036 .name("jmx_config_reload_failure_total") 037 .help("Number of times configuration have failed to be reloaded.").register(); 038 039 private static final Logger LOGGER = Logger.getLogger(JmxCollector.class.getName()); 040 041 static class Rule { 042 Pattern pattern; 043 String name; 044 String value; 045 Double valueFactor = 1.0; 046 String help; 047 boolean attrNameSnakeCase; 048 boolean cache = false; 049 Type type = Type.UNKNOWN; 050 ArrayList<String> labelNames; 051 ArrayList<String> labelValues; 052 } 053 054 private static class Config { 055 Integer startDelaySeconds = 0; 056 String jmxUrl = ""; 057 String username = ""; 058 String password = ""; 059 boolean ssl = false; 060 boolean lowercaseOutputName; 061 boolean lowercaseOutputLabelNames; 062 List<ObjectName> whitelistObjectNames = new ArrayList<ObjectName>(); 063 List<ObjectName> blacklistObjectNames = new ArrayList<ObjectName>(); 064 List<Rule> rules = new ArrayList<Rule>(); 065 long lastUpdate = 0L; 066 067 MatchedRulesCache rulesCache; 068 } 069 070 private Config config; 071 private File configFile; 072 private long createTimeNanoSecs = System.nanoTime(); 073 074 private final JmxMBeanPropertyCache jmxMBeanPropertyCache = new JmxMBeanPropertyCache(); 075 076 public JmxCollector(File in) throws IOException, MalformedObjectNameException { 077 configFile = in; 078 config = loadConfig((Map<String, Object>)new Yaml().load(new FileReader(in))); 079 config.lastUpdate = configFile.lastModified(); 080 } 081 082 public JmxCollector(String yamlConfig) throws MalformedObjectNameException { 083 config = loadConfig((Map<String, Object>)new Yaml().load(yamlConfig)); 084 } 085 086 public JmxCollector(InputStream inputStream) throws MalformedObjectNameException { 087 config = loadConfig((Map<String, Object>)new Yaml().load(inputStream)); 088 } 089 090 private void reloadConfig() { 091 try { 092 FileReader fr = new FileReader(configFile); 093 094 try { 095 Map<String, Object> newYamlConfig = (Map<String, Object>)new Yaml().load(fr); 096 config = loadConfig(newYamlConfig); 097 config.lastUpdate = configFile.lastModified(); 098 configReloadSuccess.inc(); 099 } catch (Exception e) { 100 LOGGER.severe("Configuration reload failed: " + e.toString()); 101 configReloadFailure.inc(); 102 } finally { 103 fr.close(); 104 } 105 106 } catch (IOException e) { 107 LOGGER.severe("Configuration reload failed: " + e.toString()); 108 configReloadFailure.inc(); 109 } 110 } 111 112 private synchronized Config getLatestConfig() { 113 if (configFile != null) { 114 long mtime = configFile.lastModified(); 115 if (mtime > config.lastUpdate) { 116 LOGGER.fine("Configuration file changed, reloading..."); 117 reloadConfig(); 118 } 119 } 120 121 return config; 122 } 123 124 private Config loadConfig(Map<String, Object> yamlConfig) throws MalformedObjectNameException { 125 Config cfg = new Config(); 126 127 if (yamlConfig == null) { // Yaml config empty, set config to empty map. 128 yamlConfig = new HashMap<String, Object>(); 129 } 130 131 if (yamlConfig.containsKey("startDelaySeconds")) { 132 try { 133 cfg.startDelaySeconds = (Integer) yamlConfig.get("startDelaySeconds"); 134 } catch (NumberFormatException e) { 135 throw new IllegalArgumentException("Invalid number provided for startDelaySeconds", e); 136 } 137 } 138 if (yamlConfig.containsKey("hostPort")) { 139 if (yamlConfig.containsKey("jmxUrl")) { 140 throw new IllegalArgumentException("At most one of hostPort and jmxUrl must be provided"); 141 } 142 cfg.jmxUrl ="service:jmx:rmi:///jndi/rmi://" + (String)yamlConfig.get("hostPort") + "/jmxrmi"; 143 } else if (yamlConfig.containsKey("jmxUrl")) { 144 cfg.jmxUrl = (String)yamlConfig.get("jmxUrl"); 145 } 146 147 if (yamlConfig.containsKey("username")) { 148 cfg.username = (String)yamlConfig.get("username"); 149 } 150 151 if (yamlConfig.containsKey("password")) { 152 cfg.password = (String)yamlConfig.get("password"); 153 } 154 155 if (yamlConfig.containsKey("ssl")) { 156 cfg.ssl = (Boolean)yamlConfig.get("ssl"); 157 } 158 159 if (yamlConfig.containsKey("lowercaseOutputName")) { 160 cfg.lowercaseOutputName = (Boolean)yamlConfig.get("lowercaseOutputName"); 161 } 162 163 if (yamlConfig.containsKey("lowercaseOutputLabelNames")) { 164 cfg.lowercaseOutputLabelNames = (Boolean)yamlConfig.get("lowercaseOutputLabelNames"); 165 } 166 167 if (yamlConfig.containsKey("whitelistObjectNames")) { 168 List<Object> names = (List<Object>) yamlConfig.get("whitelistObjectNames"); 169 for(Object name : names) { 170 cfg.whitelistObjectNames.add(new ObjectName((String)name)); 171 } 172 } else { 173 cfg.whitelistObjectNames.add(null); 174 } 175 176 if (yamlConfig.containsKey("blacklistObjectNames")) { 177 List<Object> names = (List<Object>) yamlConfig.get("blacklistObjectNames"); 178 for (Object name : names) { 179 cfg.blacklistObjectNames.add(new ObjectName((String)name)); 180 } 181 } 182 183 if (yamlConfig.containsKey("rules")) { 184 List<Map<String,Object>> configRules = (List<Map<String,Object>>) yamlConfig.get("rules"); 185 for (Map<String, Object> ruleObject : configRules) { 186 Map<String, Object> yamlRule = ruleObject; 187 Rule rule = new Rule(); 188 cfg.rules.add(rule); 189 if (yamlRule.containsKey("pattern")) { 190 rule.pattern = Pattern.compile("^.*(?:" + (String)yamlRule.get("pattern") + ").*$"); 191 } 192 if (yamlRule.containsKey("name")) { 193 rule.name = (String)yamlRule.get("name"); 194 } 195 if (yamlRule.containsKey("value")) { 196 rule.value = String.valueOf(yamlRule.get("value")); 197 } 198 if (yamlRule.containsKey("valueFactor")) { 199 String valueFactor = String.valueOf(yamlRule.get("valueFactor")); 200 try { 201 rule.valueFactor = Double.valueOf(valueFactor); 202 } catch (NumberFormatException e) { 203 // use default value 204 } 205 } 206 if (yamlRule.containsKey("attrNameSnakeCase")) { 207 rule.attrNameSnakeCase = (Boolean)yamlRule.get("attrNameSnakeCase"); 208 } 209 if (yamlRule.containsKey("cache")) { 210 rule.cache = (Boolean)yamlRule.get("cache"); 211 } 212 if (yamlRule.containsKey("type")) { 213 String t = (String)yamlRule.get("type"); 214 // Gracefully handle switch to OM data model. 215 if ("UNTYPED".equals(t)) { 216 t = "UNKNOWN"; 217 } 218 rule.type = Type.valueOf(t); 219 } 220 if (yamlRule.containsKey("help")) { 221 rule.help = (String)yamlRule.get("help"); 222 } 223 if (yamlRule.containsKey("labels")) { 224 TreeMap labels = new TreeMap((Map<String, Object>)yamlRule.get("labels")); 225 rule.labelNames = new ArrayList<String>(); 226 rule.labelValues = new ArrayList<String>(); 227 for (Map.Entry<String, Object> entry : (Set<Map.Entry<String, Object>>)labels.entrySet()) { 228 rule.labelNames.add(entry.getKey()); 229 rule.labelValues.add((String)entry.getValue()); 230 } 231 } 232 233 // Validation. 234 if ((rule.labelNames != null || rule.help != null) && rule.name == null) { 235 throw new IllegalArgumentException("Must provide name, if help or labels are given: " + yamlRule); 236 } 237 if (rule.name != null && rule.pattern == null) { 238 throw new IllegalArgumentException("Must provide pattern, if name is given: " + yamlRule); 239 } 240 } 241 } else { 242 // Default to a single default rule. 243 cfg.rules.add(new Rule()); 244 } 245 246 cfg.rulesCache = new MatchedRulesCache(cfg.rules); 247 248 return cfg; 249 250 } 251 252 static String toSnakeAndLowerCase(String attrName) { 253 if (attrName == null || attrName.isEmpty()) { 254 return attrName; 255 } 256 char firstChar = attrName.subSequence(0, 1).charAt(0); 257 boolean prevCharIsUpperCaseOrUnderscore = Character.isUpperCase(firstChar) || firstChar == '_'; 258 StringBuilder resultBuilder = new StringBuilder(attrName.length()).append(Character.toLowerCase(firstChar)); 259 for (char attrChar : attrName.substring(1).toCharArray()) { 260 boolean charIsUpperCase = Character.isUpperCase(attrChar); 261 if (!prevCharIsUpperCaseOrUnderscore && charIsUpperCase) { 262 resultBuilder.append("_"); 263 } 264 resultBuilder.append(Character.toLowerCase(attrChar)); 265 prevCharIsUpperCaseOrUnderscore = charIsUpperCase || attrChar == '_'; 266 } 267 return resultBuilder.toString(); 268 } 269 270 /** 271 * Change invalid chars to underscore, and merge underscores. 272 * @param name Input string 273 * @return 274 */ 275 static String safeName(String name) { 276 if (name == null) { 277 return null; 278 } 279 boolean prevCharIsUnderscore = false; 280 StringBuilder safeNameBuilder = new StringBuilder(name.length()); 281 if (!name.isEmpty() && Character.isDigit(name.charAt(0))) { 282 // prevent a numeric prefix. 283 safeNameBuilder.append("_"); 284 } 285 for (char nameChar : name.toCharArray()) { 286 boolean isUnsafeChar = !JmxCollector.isLegalCharacter(nameChar); 287 if ((isUnsafeChar || nameChar == '_')) { 288 if (prevCharIsUnderscore) { 289 continue; 290 } else { 291 safeNameBuilder.append("_"); 292 prevCharIsUnderscore = true; 293 } 294 } else { 295 safeNameBuilder.append(nameChar); 296 prevCharIsUnderscore = false; 297 } 298 } 299 300 return safeNameBuilder.toString(); 301 } 302 303 private static boolean isLegalCharacter(char input) { 304 return ((input == ':') || 305 (input == '_') || 306 (input >= 'a' && input <= 'z') || 307 (input >= 'A' && input <= 'Z') || 308 (input >= '0' && input <= '9')); 309 } 310 311 class Receiver implements JmxScraper.MBeanReceiver { 312 Map<String, MetricFamilySamples> metricFamilySamplesMap = 313 new HashMap<String, MetricFamilySamples>(); 314 315 Config config; 316 MatchedRulesCache.StalenessTracker stalenessTracker; 317 318 private static final char SEP = '_'; 319 320 Receiver(Config config, MatchedRulesCache.StalenessTracker stalenessTracker) { 321 this.config = config; 322 this.stalenessTracker = stalenessTracker; 323 } 324 325 // [] and () are special in regexes, so swtich to <>. 326 private String angleBrackets(String s) { 327 return "<" + s.substring(1, s.length() - 1) + ">"; 328 } 329 330 void addSample(MetricFamilySamples.Sample sample, Type type, String help) { 331 MetricFamilySamples mfs = metricFamilySamplesMap.get(sample.name); 332 if (mfs == null) { 333 // JmxScraper.MBeanReceiver is only called from one thread, 334 // so there's no race here. 335 mfs = new MetricFamilySamples(sample.name, type, help, new ArrayList<MetricFamilySamples.Sample>()); 336 metricFamilySamplesMap.put(sample.name, mfs); 337 } 338 mfs.samples.add(sample); 339 } 340 341 // Add the matched rule to the cached rules and tag it as not stale 342 // if the rule is configured to be cached 343 private void addToCache(final Rule rule, final String cacheKey, final MatchedRule matchedRule) { 344 if (rule.cache) { 345 config.rulesCache.put(rule, cacheKey, matchedRule); 346 stalenessTracker.add(rule, cacheKey); 347 } 348 } 349 350 private MatchedRule defaultExport( 351 String matchName, 352 String domain, 353 LinkedHashMap<String, String> beanProperties, 354 LinkedList<String> attrKeys, 355 String attrName, 356 String help, 357 Double value, 358 double valueFactor, 359 Type type) { 360 StringBuilder name = new StringBuilder(); 361 name.append(domain); 362 if (beanProperties.size() > 0) { 363 name.append(SEP); 364 name.append(beanProperties.values().iterator().next()); 365 } 366 for (String k : attrKeys) { 367 name.append(SEP); 368 name.append(k); 369 } 370 name.append(SEP); 371 name.append(attrName); 372 String fullname = safeName(name.toString()); 373 374 if (config.lowercaseOutputName) { 375 fullname = fullname.toLowerCase(); 376 } 377 378 List<String> labelNames = new ArrayList<String>(); 379 List<String> labelValues = new ArrayList<String>(); 380 if (beanProperties.size() > 1) { 381 Iterator<Map.Entry<String, String>> iter = beanProperties.entrySet().iterator(); 382 // Skip the first one, it's been used in the name. 383 iter.next(); 384 while (iter.hasNext()) { 385 Map.Entry<String, String> entry = iter.next(); 386 String labelName = safeName(entry.getKey()); 387 if (config.lowercaseOutputLabelNames) { 388 labelName = labelName.toLowerCase(); 389 } 390 labelNames.add(labelName); 391 labelValues.add(entry.getValue()); 392 } 393 } 394 395 return new MatchedRule(fullname, matchName, type, help, labelNames, labelValues, value, valueFactor); 396 } 397 398 public void recordBean( 399 String domain, 400 LinkedHashMap<String, String> beanProperties, 401 LinkedList<String> attrKeys, 402 String attrName, 403 String attrType, 404 String attrDescription, 405 Object beanValue) { 406 407 String beanName = domain + angleBrackets(beanProperties.toString()) + angleBrackets(attrKeys.toString()); 408 // attrDescription tends not to be useful, so give the fully qualified name too. 409 String help = attrDescription + " (" + beanName + attrName + ")"; 410 String attrNameSnakeCase = toSnakeAndLowerCase(attrName); 411 412 MatchedRule matchedRule = MatchedRule.unmatched(); 413 414 for (Rule rule : config.rules) { 415 // Rules with bean values cannot be properly cached (only the value from the first scrape will be cached). 416 // If caching for the rule is enabled, replace the value with a dummy <cache> to avoid caching different values at different times. 417 Object matchBeanValue = rule.cache ? "<cache>" : beanValue; 418 419 String matchName = beanName + (rule.attrNameSnakeCase ? attrNameSnakeCase : attrName) + ": " + matchBeanValue; 420 421 if (rule.cache) { 422 MatchedRule cachedRule = config.rulesCache.get(rule, matchName); 423 if (cachedRule != null) { 424 stalenessTracker.add(rule, matchName); 425 if (cachedRule.isMatched()) { 426 matchedRule = cachedRule; 427 break; 428 } 429 430 // The bean was cached earlier, but did not match the current rule. 431 // Skip it to avoid matching against the same pattern again 432 continue; 433 } 434 } 435 436 Matcher matcher = null; 437 if (rule.pattern != null) { 438 matcher = rule.pattern.matcher(matchName); 439 if (!matcher.matches()) { 440 addToCache(rule, matchName, MatchedRule.unmatched()); 441 continue; 442 } 443 } 444 445 Double value = null; 446 if (rule.value != null && !rule.value.isEmpty()) { 447 String val = matcher.replaceAll(rule.value); 448 try { 449 value = Double.valueOf(val); 450 } catch (NumberFormatException e) { 451 LOGGER.fine("Unable to parse configured value '" + val + "' to number for bean: " + beanName + attrName + ": " + beanValue); 452 return; 453 } 454 } 455 456 // If there's no name provided, use default export format. 457 if (rule.name == null) { 458 matchedRule = defaultExport(matchName, domain, beanProperties, attrKeys, rule.attrNameSnakeCase ? attrNameSnakeCase : attrName, help, value, rule.valueFactor, rule.type); 459 addToCache(rule, matchName, matchedRule); 460 break; 461 } 462 463 // Matcher is set below here due to validation in the constructor. 464 String name = safeName(matcher.replaceAll(rule.name)); 465 if (name.isEmpty()) { 466 return; 467 } 468 if (config.lowercaseOutputName) { 469 name = name.toLowerCase(); 470 } 471 472 // Set the help. 473 if (rule.help != null) { 474 help = matcher.replaceAll(rule.help); 475 } 476 477 // Set the labels. 478 ArrayList<String> labelNames = new ArrayList<String>(); 479 ArrayList<String> labelValues = new ArrayList<String>(); 480 if (rule.labelNames != null) { 481 for (int i = 0; i < rule.labelNames.size(); i++) { 482 final String unsafeLabelName = rule.labelNames.get(i); 483 final String labelValReplacement = rule.labelValues.get(i); 484 try { 485 String labelName = safeName(matcher.replaceAll(unsafeLabelName)); 486 String labelValue = matcher.replaceAll(labelValReplacement); 487 if (config.lowercaseOutputLabelNames) { 488 labelName = labelName.toLowerCase(); 489 } 490 if (!labelName.isEmpty() && !labelValue.isEmpty()) { 491 labelNames.add(labelName); 492 labelValues.add(labelValue); 493 } 494 } catch (Exception e) { 495 throw new RuntimeException( 496 format("Matcher '%s' unable to use: '%s' value: '%s'", matcher, unsafeLabelName, labelValReplacement), e); 497 } 498 } 499 } 500 501 matchedRule = new MatchedRule(name, matchName, rule.type, help, labelNames, labelValues, value, rule.valueFactor); 502 addToCache(rule, matchName, matchedRule); 503 break; 504 } 505 506 if (matchedRule.isUnmatched()) { 507 return; 508 } 509 510 Number value; 511 if (matchedRule.value != null) { 512 beanValue = matchedRule.value; 513 } 514 515 if (beanValue instanceof Number) { 516 value = ((Number) beanValue).doubleValue() * matchedRule.valueFactor; 517 } else if (beanValue instanceof Boolean) { 518 value = (Boolean) beanValue ? 1 : 0; 519 } else { 520 LOGGER.fine("Ignoring unsupported bean: " + beanName + attrName + ": " + beanValue); 521 return; 522 } 523 524 // Add to samples. 525 LOGGER.fine("add metric sample: " + matchedRule.name + " " + matchedRule.labelNames + " " + matchedRule.labelValues + " " + value.doubleValue()); 526 addSample(new MetricFamilySamples.Sample(matchedRule.name, matchedRule.labelNames, matchedRule.labelValues, value.doubleValue()), matchedRule.type, matchedRule.help); 527 } 528 529 } 530 531 public List<MetricFamilySamples> collect() { 532 // Take a reference to the current config and collect with this one 533 // (to avoid race conditions in case another thread reloads the config in the meantime) 534 Config config = getLatestConfig(); 535 536 MatchedRulesCache.StalenessTracker stalenessTracker = new MatchedRulesCache.StalenessTracker(); 537 Receiver receiver = new Receiver(config, stalenessTracker); 538 JmxScraper scraper = new JmxScraper(config.jmxUrl, config.username, config.password, config.ssl, 539 config.whitelistObjectNames, config.blacklistObjectNames, receiver, jmxMBeanPropertyCache); 540 long start = System.nanoTime(); 541 double error = 0; 542 if ((config.startDelaySeconds > 0) && 543 ((start - createTimeNanoSecs) / 1000000000L < config.startDelaySeconds)) { 544 throw new IllegalStateException("JMXCollector waiting for startDelaySeconds"); 545 } 546 try { 547 scraper.doScrape(); 548 } catch (Exception e) { 549 error = 1; 550 StringWriter sw = new StringWriter(); 551 e.printStackTrace(new PrintWriter(sw)); 552 LOGGER.severe("JMX scrape failed: " + sw.toString()); 553 } 554 config.rulesCache.evictStaleEntries(stalenessTracker); 555 556 List<MetricFamilySamples> mfsList = new ArrayList<MetricFamilySamples>(); 557 mfsList.addAll(receiver.metricFamilySamplesMap.values()); 558 List<MetricFamilySamples.Sample> samples = new ArrayList<MetricFamilySamples.Sample>(); 559 samples.add(new MetricFamilySamples.Sample( 560 "jmx_scrape_duration_seconds", new ArrayList<String>(), new ArrayList<String>(), (System.nanoTime() - start) / 1.0E9)); 561 mfsList.add(new MetricFamilySamples("jmx_scrape_duration_seconds", Type.GAUGE, "Time this JMX scrape took, in seconds.", samples)); 562 563 samples = new ArrayList<MetricFamilySamples.Sample>(); 564 samples.add(new MetricFamilySamples.Sample( 565 "jmx_scrape_error", new ArrayList<String>(), new ArrayList<String>(), error)); 566 mfsList.add(new MetricFamilySamples("jmx_scrape_error", Type.GAUGE, "Non-zero if this scrape failed.", samples)); 567 samples = new ArrayList<MetricFamilySamples.Sample>(); 568 samples.add(new MetricFamilySamples.Sample( 569 "jmx_scrape_cached_beans", new ArrayList<String>(), new ArrayList<String>(), stalenessTracker.cachedCount())); 570 mfsList.add(new MetricFamilySamples("jmx_scrape_cached_beans", Type.GAUGE, "Number of beans with their matching rule cached", samples)); 571 return mfsList; 572 } 573 574 public List<MetricFamilySamples> describe() { 575 List<MetricFamilySamples> sampleFamilies = new ArrayList<MetricFamilySamples>(); 576 sampleFamilies.add(new MetricFamilySamples("jmx_scrape_duration_seconds", Type.GAUGE, "Time this JMX scrape took, in seconds.", new ArrayList<MetricFamilySamples.Sample>())); 577 sampleFamilies.add(new MetricFamilySamples("jmx_scrape_error", Type.GAUGE, "Non-zero if this scrape failed.", new ArrayList<MetricFamilySamples.Sample>())); 578 sampleFamilies.add(new MetricFamilySamples("jmx_scrape_cached_beans", Type.GAUGE, "Number of beans with their matching rule cached", new ArrayList<MetricFamilySamples.Sample>())); 579 return sampleFamilies; 580 } 581 582 /** 583 * Convenience function to run standalone. 584 */ 585 public static void main(String[] args) throws Exception { 586 String hostPort = ""; 587 if (args.length > 0) { 588 hostPort = args[0]; 589 } 590 JmxCollector jc = new JmxCollector(("{" 591 + "`hostPort`: `" + hostPort + "`," 592 + "}").replace('`', '"')); 593 for(MetricFamilySamples mfs : jc.collect()) { 594 System.out.println(mfs); 595 } 596 } 597}