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}