001package io.prometheus.client.exporter;
002
003import java.io.BufferedWriter;
004import java.io.ByteArrayOutputStream;
005import java.io.IOException;
006import java.io.InputStream;
007import java.io.OutputStreamWriter;
008import java.io.UnsupportedEncodingException;
009import java.net.HttpURLConnection;
010import java.net.InetAddress;
011import java.net.MalformedURLException;
012import java.net.URI;
013import java.net.URL;
014import java.net.URLEncoder;
015import java.net.UnknownHostException;
016import java.util.Collections;
017import java.util.HashMap;
018import java.util.Map;
019import javax.xml.bind.DatatypeConverter;
020
021import io.prometheus.client.Collector;
022import io.prometheus.client.CollectorRegistry;
023import io.prometheus.client.exporter.common.TextFormat;
024
025/**
026 * Export metrics via the Prometheus Pushgateway.
027 * <p>
028 * The Prometheus Pushgateway exists to allow ephemeral and batch jobs to expose their metrics to Prometheus.
029 * Since these kinds of jobs may not exist long enough to be scraped, they can instead push their metrics
030 * to a Pushgateway. This class allows pushing the contents of a {@link CollectorRegistry} to
031 * a Pushgateway.
032 * <p>
033 * Example usage:
034 * <pre>
035 * {@code
036 *   void executeBatchJob() throws Exception {
037 *     CollectorRegistry registry = new CollectorRegistry();
038 *     Gauge duration = Gauge.build()
039 *         .name("my_batch_job_duration_seconds").help("Duration of my batch job in seconds.").register(registry);
040 *     Gauge.Timer durationTimer = duration.startTimer();
041 *     try {
042 *       // Your code here.
043 *
044 *       // This is only added to the registry after success,
045 *       // so that a previous success in the Pushgateway isn't overwritten on failure.
046 *       Gauge lastSuccess = Gauge.build()
047 *           .name("my_batch_job_last_success").help("Last time my batch job succeeded, in unixtime.").register(registry);
048 *       lastSuccess.setToCurrentTime();
049 *     } finally {
050 *       durationTimer.setDuration();
051 *       PushGateway pg = new PushGateway("127.0.0.1:9091");
052 *       pg.pushAdd(registry, "my_batch_job");
053 *     }
054 *   }
055 * }
056 * </pre>
057 * <p>
058 * See <a href="https://github.com/prometheus/pushgateway">https://github.com/prometheus/pushgateway</a>
059 */
060public class PushGateway {
061
062  private static final int MILLISECONDS_PER_SECOND = 1000;
063
064  // Visible for testing.
065  protected final String gatewayBaseURL;
066
067  private HttpConnectionFactory connectionFactory = new DefaultHttpConnectionFactory();
068
069  /**
070   * Construct a Pushgateway, with the given address.
071   * <p>
072   * @param address  host:port or ip:port of the Pushgateway.
073   */
074  public PushGateway(String address) {
075    this(createURLSneakily("http://" + address));
076  }
077
078  /**
079   * Construct a Pushgateway, with the given URL.
080   * <p>
081   * @param serverBaseURL the base URL and optional context path of the Pushgateway server.
082   */
083  public PushGateway(URL serverBaseURL) {
084    this.gatewayBaseURL = URI.create(serverBaseURL.toString() + "/metrics/")
085      .normalize()
086      .toString();
087  }
088
089  public void setConnectionFactory(HttpConnectionFactory connectionFactory) {
090    this.connectionFactory = connectionFactory;
091  }
092
093  /**
094   * Creates a URL instance from a String representation of a URL without throwing a checked exception.
095   * Required because you can't wrap a call to another constructor in a try statement.
096   *
097   * @param urlString the String representation of the URL.
098   * @return The URL instance.
099   */
100  private static URL createURLSneakily(final String urlString) {
101    try {
102      return new URL(urlString);
103    } catch (MalformedURLException e) {
104      throw new RuntimeException(e);
105    }
106  }
107
108  /**
109   * Pushes all metrics in a registry, replacing all those with the same job and no grouping key.
110   * <p>
111   * This uses the PUT HTTP method.
112  */
113  public void push(CollectorRegistry registry, String job) throws IOException {
114    doRequest(registry, job, null, "PUT");
115  }
116
117  /**
118   * Pushes all metrics in a Collector, replacing all those with the same job and no grouping key.
119   * <p>
120   * This is useful for pushing a single Gauge.
121   * <p>
122   * This uses the PUT HTTP method.
123  */
124  public void push(Collector collector, String job) throws IOException {
125    CollectorRegistry registry = new CollectorRegistry();
126    collector.register(registry);
127    push(registry, job);
128  }
129
130  /**
131   * Pushes all metrics in a registry, replacing all those with the same job and grouping key.
132   * <p>
133   * This uses the PUT HTTP method.
134  */
135  public void push(CollectorRegistry registry, String job, Map<String, String> groupingKey) throws IOException {
136    doRequest(registry, job, groupingKey, "PUT");
137  }
138
139  /**
140   * Pushes all metrics in a Collector, replacing all those with the same job and grouping key.
141   * <p>
142   * This is useful for pushing a single Gauge.
143   * <p>
144   * This uses the PUT HTTP method.
145  */
146  public void push(Collector collector, String job, Map<String, String> groupingKey) throws IOException {
147    CollectorRegistry registry = new CollectorRegistry();
148    collector.register(registry);
149    push(registry, job, groupingKey);
150  }
151
152  /**
153   * Pushes all metrics in a registry, replacing only previously pushed metrics of the same name and job and no grouping key.
154   * <p>
155   * This uses the POST HTTP method.
156  */
157  public void pushAdd(CollectorRegistry registry, String job) throws IOException {
158    doRequest(registry, job, null, "POST");
159  }
160
161  /**
162   * Pushes all metrics in a Collector, replacing only previously pushed metrics of the same name and job and no grouping key.
163   * <p>
164   * This is useful for pushing a single Gauge.
165   * <p>
166   * This uses the POST HTTP method.
167  */
168  public void pushAdd(Collector collector, String job) throws IOException {
169    CollectorRegistry registry = new CollectorRegistry();
170    collector.register(registry);
171    pushAdd(registry, job);
172  }
173
174  /**
175   * Pushes all metrics in a registry, replacing only previously pushed metrics of the same name, job and grouping key.
176   * <p>
177   * This uses the POST HTTP method.
178  */
179  public void pushAdd(CollectorRegistry registry, String job, Map<String, String> groupingKey) throws IOException {
180    doRequest(registry, job, groupingKey, "POST");
181  }
182
183  /**
184   * Pushes all metrics in a Collector, replacing only previously pushed metrics of the same name, job and grouping key.
185   * <p>
186   * This is useful for pushing a single Gauge.
187   * <p>
188   * This uses the POST HTTP method.
189  */
190  public void pushAdd(Collector collector, String job, Map<String, String> groupingKey) throws IOException {
191    CollectorRegistry registry = new CollectorRegistry();
192    collector.register(registry);
193    pushAdd(registry, job, groupingKey);
194  }
195
196
197  /**
198   * Deletes metrics from the Pushgateway.
199   * <p>
200   * Deletes metrics with no grouping key and the provided job.
201   * This uses the DELETE HTTP method.
202  */
203  public void delete(String job) throws IOException {
204    doRequest(null, job, null, "DELETE");
205  }
206
207  /**
208   * Deletes metrics from the Pushgateway.
209   * <p>
210   * Deletes metrics with the provided job and grouping key.
211   * This uses the DELETE HTTP method.
212  */
213  public void delete(String job, Map<String, String> groupingKey) throws IOException {
214    doRequest(null, job, groupingKey, "DELETE");
215  }
216
217  void doRequest(CollectorRegistry registry, String job, Map<String, String> groupingKey, String method) throws IOException {
218    String url = gatewayBaseURL;
219    if (job.contains("/")) {
220      url += "job@base64/" + base64url(job);
221    } else {
222      url += "job/" + URLEncoder.encode(job, "UTF-8");
223    }
224
225    if (groupingKey != null) {
226      for (Map.Entry<String, String> entry: groupingKey.entrySet()) {
227        if (entry.getValue().isEmpty()) {
228          url += "/" + entry.getKey() + "@base64/=";
229        } else if (entry.getValue().contains("/")) {
230          url += "/" + entry.getKey() + "@base64/" + base64url(entry.getValue());
231        } else {
232          url += "/" + entry.getKey() + "/" + URLEncoder.encode(entry.getValue(), "UTF-8");
233        }
234      }
235    }
236    HttpURLConnection connection = connectionFactory.create(url);
237    connection.setRequestProperty("Content-Type", TextFormat.CONTENT_TYPE_004);
238    if (!method.equals("DELETE")) {
239      connection.setDoOutput(true);
240    }
241    connection.setRequestMethod(method);
242
243    connection.setConnectTimeout(10 * MILLISECONDS_PER_SECOND);
244    connection.setReadTimeout(10 * MILLISECONDS_PER_SECOND);
245    connection.connect();
246
247    try {
248      if (!method.equals("DELETE")) {
249        BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(connection.getOutputStream(), "UTF-8"));
250        TextFormat.write004(writer, registry.metricFamilySamples());
251        writer.flush();
252        writer.close();
253      }
254
255      int response = connection.getResponseCode();
256      if (response/100 != 2) {
257        String errorMessage;
258        InputStream errorStream = connection.getErrorStream();
259        if(errorStream != null) {
260          String errBody = readFromStream(errorStream);
261          errorMessage = "Response code from " + url + " was " + response + ", response body: " + errBody;
262        } else {
263          errorMessage = "Response code from " + url + " was " + response;
264        }
265        throw new IOException(errorMessage);
266      }
267    } finally {
268      connection.disconnect();
269    }
270  }
271
272  private static String base64url(String v) {
273    // Per RFC4648 table 2. We support Java 6, and java.util.Base64 was only added in Java 8,
274    try {
275      return DatatypeConverter.printBase64Binary(v.getBytes("UTF-8")).replace("+", "-").replace("/", "_");
276    } catch (UnsupportedEncodingException e) {
277      throw new RuntimeException(e);  // Unreachable.
278    }
279  }
280
281  /**
282   * Returns a grouping key with the instance label set to the machine's IP address.
283   * <p>
284   * This is a convenience function, and should only be used where you want to
285   * push per-instance metrics rather than cluster/job level metrics.
286  */
287  public static Map<String, String> instanceIPGroupingKey() throws UnknownHostException {
288    Map<String, String> groupingKey = new HashMap<String, String>();
289    groupingKey.put("instance", InetAddress.getLocalHost().getHostAddress());
290    return groupingKey;
291  }
292
293  private static String readFromStream(InputStream is) throws IOException {
294    ByteArrayOutputStream result = new ByteArrayOutputStream();
295    byte[] buffer = new byte[1024];
296    int length;
297    while ((length = is.read(buffer)) != -1) {
298      result.write(buffer, 0, length);
299    }
300    return result.toString("UTF-8");
301  }
302}