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