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   * TODO: Remove this along with other deprecated methods before version 1.0 is released.
098   *
099   * @param urlString the String representation of the URL.
100   * @return The URL instance.
101   */
102  private static URL createURLSneakily(final String urlString) {
103    try {
104      return new URL(urlString);
105    } catch (MalformedURLException e) {
106      throw new RuntimeException(e);
107    }
108  }
109
110  /**
111   * Pushes all metrics in a registry, replacing all those with the same job and no grouping key.
112   * <p>
113   * This uses the PUT HTTP method.
114  */
115  public void push(CollectorRegistry registry, String job) throws IOException {
116    doRequest(registry, job, null, "PUT");
117  }
118
119  /**
120   * Pushes all metrics in a Collector, replacing all those with the same job and no grouping key.
121   * <p>
122   * This is useful for pushing a single Gauge.
123   * <p>
124   * This uses the PUT HTTP method.
125  */
126  public void push(Collector collector, String job) throws IOException {
127    CollectorRegistry registry = new CollectorRegistry();
128    collector.register(registry);
129    push(registry, job);
130  }
131
132  /**
133   * Pushes all metrics in a registry, replacing all those with the same job and grouping key.
134   * <p>
135   * This uses the PUT HTTP method.
136  */
137  public void push(CollectorRegistry registry, String job, Map<String, String> groupingKey) throws IOException {
138    doRequest(registry, job, groupingKey, "PUT");
139  }
140
141  /**
142   * Pushes all metrics in a Collector, replacing all those with the same job and grouping key.
143   * <p>
144   * This is useful for pushing a single Gauge.
145   * <p>
146   * This uses the PUT HTTP method.
147  */
148  public void push(Collector collector, String job, Map<String, String> groupingKey) throws IOException {
149    CollectorRegistry registry = new CollectorRegistry();
150    collector.register(registry);
151    push(registry, job, groupingKey);
152  }
153
154  /**
155   * Pushes all metrics in a registry, replacing only previously pushed metrics of the same name and job and no grouping key.
156   * <p>
157   * This uses the POST HTTP method.
158  */
159  public void pushAdd(CollectorRegistry registry, String job) throws IOException {
160    doRequest(registry, job, null, "POST");
161  }
162
163  /**
164   * Pushes all metrics in a Collector, replacing only previously pushed metrics of the same name and job and no grouping key.
165   * <p>
166   * This is useful for pushing a single Gauge.
167   * <p>
168   * This uses the POST HTTP method.
169  */
170  public void pushAdd(Collector collector, String job) throws IOException {
171    CollectorRegistry registry = new CollectorRegistry();
172    collector.register(registry);
173    pushAdd(registry, job);
174  }
175
176  /**
177   * Pushes all metrics in a registry, replacing only previously pushed metrics of the same name, job and grouping key.
178   * <p>
179   * This uses the POST HTTP method.
180  */
181  public void pushAdd(CollectorRegistry registry, String job, Map<String, String> groupingKey) throws IOException {
182    doRequest(registry, job, groupingKey, "POST");
183  }
184
185  /**
186   * Pushes all metrics in a Collector, replacing only previously pushed metrics of the same name, job and grouping key.
187   * <p>
188   * This is useful for pushing a single Gauge.
189   * <p>
190   * This uses the POST HTTP method.
191  */
192  public void pushAdd(Collector collector, String job, Map<String, String> groupingKey) throws IOException {
193    CollectorRegistry registry = new CollectorRegistry();
194    collector.register(registry);
195    pushAdd(registry, job, groupingKey);
196  }
197
198
199  /**
200   * Deletes metrics from the Pushgateway.
201   * <p>
202   * Deletes metrics with no grouping key and the provided job.
203   * This uses the DELETE HTTP method.
204  */
205  public void delete(String job) throws IOException {
206    doRequest(null, job, null, "DELETE");
207  }
208
209  /**
210   * Deletes metrics from the Pushgateway.
211   * <p>
212   * Deletes metrics with the provided job and grouping key.
213   * This uses the DELETE HTTP method.
214  */
215  public void delete(String job, Map<String, String> groupingKey) throws IOException {
216    doRequest(null, job, groupingKey, "DELETE");
217  }
218
219
220  /**
221   * Pushes all metrics in a registry, replacing all those with the same job and instance.
222   * <p>
223   * This uses the PUT HTTP method.
224   * @deprecated use {@link #push(CollectorRegistry, String, Map)}
225  */
226  @Deprecated
227  public void push(CollectorRegistry registry, String job, String instance) throws IOException {
228    push(registry, job, Collections.singletonMap("instance", instance));
229  }
230
231  /**
232   * Pushes all metrics in a Collector, replacing all those with the same job and instance.
233   * <p>
234   * This is useful for pushing a single Gauge.
235   * <p>
236   * This uses the PUT HTTP method.
237   * @deprecated use {@link #push(Collector, String, Map)}
238  */
239  @Deprecated
240  public void push(Collector collector, String job, String instance) throws IOException {
241    push(collector, job, Collections.singletonMap("instance", instance));
242  }
243
244  /**
245   * Pushes all metrics in a registry, replacing only previously pushed metrics of the same name.
246   * <p>
247   * This uses the POST HTTP method.
248   * @deprecated use {@link #pushAdd(CollectorRegistry, String, Map)}
249  */
250  @Deprecated
251  public void pushAdd(CollectorRegistry registry, String job, String instance) throws IOException {
252    pushAdd(registry, job, Collections.singletonMap("instance", instance));
253  }
254
255  /**
256   * Pushes all metrics in a Collector, replacing only previously pushed metrics of the same name.
257   * <p>
258   * This is useful for pushing a single Gauge.
259   * <p>
260   * This uses the POST HTTP method.
261   * @deprecated use {@link #pushAdd(Collector, String, Map)}
262  */
263  @Deprecated
264  public void pushAdd(Collector collector, String job, String instance) throws IOException {
265    pushAdd(collector, job, Collections.singletonMap("instance", instance));
266  }
267
268  /**
269   * Deletes metrics from the Pushgateway.
270   * <p>
271   * This uses the DELETE HTTP method.
272   * @deprecated use {@link #delete(String, Map)}
273  */
274  @Deprecated
275  public void delete(String job, String instance) throws IOException {
276    delete(job, Collections.singletonMap("instance", instance));
277  }
278
279  void doRequest(CollectorRegistry registry, String job, Map<String, String> groupingKey, String method) throws IOException {
280    String url = gatewayBaseURL;
281    if (job.contains("/")) {
282      url += "job@base64/" + base64url(job);
283    } else {
284      url += "job/" + URLEncoder.encode(job, "UTF-8");
285    }
286
287    if (groupingKey != null) {
288      for (Map.Entry<String, String> entry: groupingKey.entrySet()) {
289        if (entry.getValue().contains("/")) {
290          url += "/" + entry.getKey() + "@base64/" + base64url(entry.getValue());
291        } else {
292          url += "/" + entry.getKey() + "/" + URLEncoder.encode(entry.getValue(), "UTF-8");
293        }
294      }
295    }
296    HttpURLConnection connection = connectionFactory.create(url);
297    connection.setRequestProperty("Content-Type", TextFormat.CONTENT_TYPE_004);
298    if (!method.equals("DELETE")) {
299      connection.setDoOutput(true);
300    }
301    connection.setRequestMethod(method);
302
303    connection.setConnectTimeout(10 * MILLISECONDS_PER_SECOND);
304    connection.setReadTimeout(10 * MILLISECONDS_PER_SECOND);
305    connection.connect();
306
307    try {
308      if (!method.equals("DELETE")) {
309        BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(connection.getOutputStream(), "UTF-8"));
310        TextFormat.write004(writer, registry.metricFamilySamples());
311        writer.flush();
312        writer.close();
313      }
314
315      int response = connection.getResponseCode();
316      if (response != HttpURLConnection.HTTP_ACCEPTED) {
317        String errorMessage;
318        InputStream errorStream = connection.getErrorStream();
319        if(response >= 400 && errorStream != null) {
320          String errBody = readFromStream(errorStream);
321          errorMessage = "Response code from " + url + " was " + response + ", response body: " + errBody;
322        } else {
323          errorMessage = "Response code from " + url + " was " + response;
324        }
325        throw new IOException(errorMessage);
326      }
327    } finally {
328      connection.disconnect();
329    }
330  }
331
332  private static String base64url(String v) {
333    // Per RFC4648 table 2. We support Java 6, and java.util.Base64 was only added in Java 8,
334    try {
335      return DatatypeConverter.printBase64Binary(v.getBytes("UTF-8")).replace("+", "-").replace("/", "_");
336    } catch (UnsupportedEncodingException e) {
337      throw new RuntimeException(e);  // Unreachable.
338    }
339  }
340
341  /**
342   * Returns a grouping key with the instance label set to the machine's IP address.
343   * <p>
344   * This is a convenience function, and should only be used where you want to
345   * push per-instance metrics rather than cluster/job level metrics.
346  */
347  public static Map<String, String> instanceIPGroupingKey() throws UnknownHostException {
348    Map<String, String> groupingKey = new HashMap<String, String>();
349    groupingKey.put("instance", InetAddress.getLocalHost().getHostAddress());
350    return groupingKey;
351  }
352
353  private static String readFromStream(InputStream is) throws IOException {
354    ByteArrayOutputStream result = new ByteArrayOutputStream();
355    byte[] buffer = new byte[1024];
356    int length;
357    while ((length = is.read(buffer)) != -1) {
358      result.write(buffer, 0, length);
359    }
360    return result.toString("UTF-8");
361  }
362}