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