001package io.prometheus.jmx;
002
003import io.prometheus.client.Collector;
004import io.prometheus.client.Counter;
005import java.io.IOException;
006import java.io.PrintWriter;
007import java.io.File;
008import java.io.FileReader;
009import java.io.StringWriter;
010import java.util.ArrayList;
011import java.util.Iterator;
012import java.util.LinkedHashMap;
013import java.util.LinkedList;
014import java.util.List;
015import java.util.HashMap;
016import java.util.Map;
017import java.util.Set;
018import java.util.TreeMap;
019import java.util.logging.Logger;
020import java.util.regex.Matcher;
021import java.util.regex.Pattern;
022import javax.management.MalformedObjectNameException;
023import javax.management.ObjectName;
024
025import org.yaml.snakeyaml.Yaml;
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      ArrayList<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 static final Pattern snakeCasePattern = Pattern.compile("([a-z0-9])([A-Z])");
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    class Receiver implements JmxScraper.MBeanReceiver {
223      Map<String, MetricFamilySamples> metricFamilySamplesMap =
224        new HashMap<String, MetricFamilySamples>();
225
226      private static final char SEP = '_';
227
228      private final Pattern unsafeChars = Pattern.compile("[^a-zA-Z0-9:_]");
229      private final Pattern multipleUnderscores = Pattern.compile("__+");
230
231      // [] and () are special in regexes, so swtich to <>.
232      private String angleBrackets(String s) {
233        return "<" + s.substring(1, s.length() - 1) + ">";
234      }
235
236      private String safeName(String s) {
237        // Change invalid chars to underscore, and merge underscores.
238        return multipleUnderscores.matcher(unsafeChars.matcher(s).replaceAll("_")).replaceAll("_");
239      }
240
241      void addSample(MetricFamilySamples.Sample sample, Type type, String help) {
242        MetricFamilySamples mfs = metricFamilySamplesMap.get(sample.name);
243        if (mfs == null) {
244          // JmxScraper.MBeanReceiver is only called from one thread,
245          // so there's no race here.
246          mfs = new MetricFamilySamples(sample.name, type, help, new ArrayList<MetricFamilySamples.Sample>());
247          metricFamilySamplesMap.put(sample.name, mfs);
248        }
249        mfs.samples.add(sample);
250      }
251
252      private void defaultExport(
253          String domain,
254          LinkedHashMap<String, String> beanProperties,
255          LinkedList<String> attrKeys,
256          String attrName,
257          String attrType,
258          String help,
259          Object value,
260          Type type) {
261        StringBuilder name = new StringBuilder();
262        name.append(domain);
263        if (beanProperties.size() > 0) {
264            name.append(SEP);
265            name.append(beanProperties.values().iterator().next());
266        }
267        for (String k : attrKeys) {
268            name.append(SEP);
269            name.append(k);
270        }
271        name.append(SEP);
272        name.append(attrName);
273        String fullname = safeName(name.toString());
274
275        if (config.lowercaseOutputName) {
276          fullname = fullname.toLowerCase();
277        }
278
279        List<String> labelNames = new ArrayList<String>();
280        List<String> labelValues = new ArrayList<String>();
281        if (beanProperties.size() > 1) {
282            Iterator<Map.Entry<String, String>> iter = beanProperties.entrySet().iterator();
283            // Skip the first one, it's been used in the name.
284            iter.next();
285            while (iter.hasNext()) {
286              Map.Entry<String, String> entry = iter.next();
287              String labelName = safeName(entry.getKey());
288              if (config.lowercaseOutputLabelNames) {
289                labelName = labelName.toLowerCase();
290              }
291              labelNames.add(labelName);
292              labelValues.add(entry.getValue());
293            }
294        }
295
296        addSample(new MetricFamilySamples.Sample(fullname, labelNames, labelValues, ((Number)value).doubleValue()),
297          type, help);
298      }
299
300      public void recordBean(
301          String domain,
302          LinkedHashMap<String, String> beanProperties,
303          LinkedList<String> attrKeys,
304          String attrName,
305          String attrType,
306          String attrDescription,
307          Object beanValue) {
308
309        String beanName = domain + angleBrackets(beanProperties.toString()) + angleBrackets(attrKeys.toString());
310        // attrDescription tends not to be useful, so give the fully qualified name too.
311        String help = attrDescription + " (" + beanName + attrName + ")";
312        String attrNameSnakeCase = snakeCasePattern.matcher(attrName).replaceAll("$1_$2").toLowerCase();
313
314        for (Rule rule : config.rules) {
315          Matcher matcher = null;
316          String matchName = beanName + (rule.attrNameSnakeCase ? attrNameSnakeCase : attrName);
317          if (rule.pattern != null) {
318            matcher = rule.pattern.matcher(matchName + ": " + beanValue);
319            if (!matcher.matches()) {
320              continue;
321            }
322          }
323
324          Number value;
325          if (rule.value != null && !rule.value.isEmpty()) {
326            String val = matcher.replaceAll(rule.value);
327
328            try {
329              beanValue = Double.valueOf(val);
330            } catch (NumberFormatException e) {
331              LOGGER.fine("Unable to parse configured value '" + val + "' to number for bean: " + beanName + attrName + ": " + beanValue);
332              return;
333            }
334          }
335          if (beanValue instanceof Number) {
336            value = ((Number)beanValue).doubleValue() * rule.valueFactor;
337          } else if (beanValue instanceof Boolean) {
338            value = (Boolean)beanValue ? 1 : 0;
339          } else {
340            LOGGER.fine("Ignoring unsupported bean: " + beanName + attrName + ": " + beanValue);
341            return;
342          }
343
344          // If there's no name provided, use default export format.
345          if (rule.name == null) {
346            defaultExport(domain, beanProperties, attrKeys, rule.attrNameSnakeCase ? attrNameSnakeCase : attrName, attrType, help, value, rule.type);
347            return;
348          }
349
350          // Matcher is set below here due to validation in the constructor.
351          String name = safeName(matcher.replaceAll(rule.name));
352          if (name.isEmpty()) {
353            return;
354          }
355          if (config.lowercaseOutputName) {
356            name = name.toLowerCase();
357          }
358
359          // Set the help.
360          if (rule.help != null) {
361            help = matcher.replaceAll(rule.help);
362          }
363
364          // Set the labels.
365          ArrayList<String> labelNames = new ArrayList<String>();
366          ArrayList<String> labelValues = new ArrayList<String>();
367          if (rule.labelNames != null) {
368            for (int i = 0; i < rule.labelNames.size(); i++) {
369              final String unsafeLabelName = rule.labelNames.get(i);
370              final String labelValReplacement = rule.labelValues.get(i);
371              try {
372                String labelName = safeName(matcher.replaceAll(unsafeLabelName));
373                String labelValue = matcher.replaceAll(labelValReplacement);
374                if (config.lowercaseOutputLabelNames) {
375                  labelName = labelName.toLowerCase();
376                }
377                if (!labelName.isEmpty() && !labelValue.isEmpty()) {
378                  labelNames.add(labelName);
379                  labelValues.add(labelValue);
380                }
381              } catch (Exception e) {
382                throw new RuntimeException(
383                  format("Matcher '%s' unable to use: '%s' value: '%s'", matcher, unsafeLabelName, labelValReplacement), e);
384              }
385            }
386          }
387
388          // Add to samples.
389          LOGGER.fine("add metric sample: " + name + " " + labelNames + " " + labelValues + " " + value.doubleValue());
390          addSample(new MetricFamilySamples.Sample(name, labelNames, labelValues, value.doubleValue()), rule.type, help);
391          return;
392        }
393      }
394
395    }
396
397    public List<MetricFamilySamples> collect() {
398      if (configFile != null) {
399        long mtime = configFile.lastModified();
400        if (mtime > config.lastUpdate) {
401          LOGGER.fine("Configuration file changed, reloading...");
402          reloadConfig();
403        }
404      }
405
406      Receiver receiver = new Receiver();
407      JmxScraper scraper = new JmxScraper(config.jmxUrl, config.username, config.password, config.ssl, config.whitelistObjectNames, config.blacklistObjectNames, receiver);
408      long start = System.nanoTime();
409      double error = 0;
410      if ((config.startDelaySeconds > 0) &&
411        ((start - createTimeNanoSecs) / 1000000000L < config.startDelaySeconds)) {
412        throw new IllegalStateException("JMXCollector waiting for startDelaySeconds");
413      }
414      try {
415        scraper.doScrape();
416      } catch (Exception e) {
417        error = 1;
418        StringWriter sw = new StringWriter();
419        e.printStackTrace(new PrintWriter(sw));
420        LOGGER.severe("JMX scrape failed: " + sw.toString());
421      }
422      List<MetricFamilySamples> mfsList = new ArrayList<MetricFamilySamples>();
423      mfsList.addAll(receiver.metricFamilySamplesMap.values());
424      List<MetricFamilySamples.Sample> samples = new ArrayList<MetricFamilySamples.Sample>();
425      samples.add(new MetricFamilySamples.Sample(
426          "jmx_scrape_duration_seconds", new ArrayList<String>(), new ArrayList<String>(), (System.nanoTime() - start) / 1.0E9));
427      mfsList.add(new MetricFamilySamples("jmx_scrape_duration_seconds", Type.GAUGE, "Time this JMX scrape took, in seconds.", samples));
428
429      samples = new ArrayList<MetricFamilySamples.Sample>();
430      samples.add(new MetricFamilySamples.Sample(
431          "jmx_scrape_error", new ArrayList<String>(), new ArrayList<String>(), error));
432      mfsList.add(new MetricFamilySamples("jmx_scrape_error", Type.GAUGE, "Non-zero if this scrape failed.", samples));
433      return mfsList;
434    }
435
436    public List<MetricFamilySamples> describe() {
437      List<MetricFamilySamples> sampleFamilies = new ArrayList<MetricFamilySamples>();
438      sampleFamilies.add(new MetricFamilySamples("jmx_scrape_duration_seconds", Type.GAUGE, "Time this JMX scrape took, in seconds.", new ArrayList<MetricFamilySamples.Sample>()));
439      sampleFamilies.add(new MetricFamilySamples("jmx_scrape_error", Type.GAUGE, "Non-zero if this scrape failed.", new ArrayList<MetricFamilySamples.Sample>()));
440      return sampleFamilies;
441    }
442
443    /**
444     * Convenience function to run standalone.
445     */
446    public static void main(String[] args) throws Exception {
447      String hostPort = "";
448      if (args.length > 0) {
449        hostPort = args[0];
450      }
451      JmxCollector jc = new JmxCollector(("{"
452      + "`hostPort`: `" + hostPort + "`,"
453      + "}").replace('`', '"'));
454      for(MetricFamilySamples mfs : jc.collect()) {
455        System.out.println(mfs);
456      }
457    }
458}