001package io.prometheus.client.exporter; 002 003import com.sun.net.httpserver.HttpsConfigurator; 004import com.sun.net.httpserver.HttpsServer; 005import io.prometheus.client.CollectorRegistry; 006import io.prometheus.client.SampleNameFilter; 007import io.prometheus.client.Predicate; 008import io.prometheus.client.Supplier; 009import io.prometheus.client.exporter.common.TextFormat; 010 011import java.io.ByteArrayOutputStream; 012import java.io.Closeable; 013import java.io.IOException; 014import java.io.OutputStreamWriter; 015import java.net.HttpURLConnection; 016import java.net.InetAddress; 017import java.net.InetSocketAddress; 018import java.net.URLDecoder; 019import java.nio.charset.Charset; 020import java.util.HashSet; 021import java.util.List; 022import java.util.Set; 023import java.util.concurrent.ExecutionException; 024import java.util.concurrent.ExecutorService; 025import java.util.concurrent.Executors; 026import java.util.concurrent.FutureTask; 027import java.util.concurrent.ThreadFactory; 028import java.util.concurrent.atomic.AtomicInteger; 029import java.util.zip.GZIPOutputStream; 030 031import com.sun.net.httpserver.Authenticator; 032import com.sun.net.httpserver.HttpContext; 033import com.sun.net.httpserver.HttpExchange; 034import com.sun.net.httpserver.HttpHandler; 035import com.sun.net.httpserver.HttpServer; 036 037/** 038 * Expose Prometheus metrics using a plain Java HttpServer. 039 * <p> 040 * Example Usage: 041 * <pre> 042 * {@code 043 * HTTPServer server = new HTTPServer(1234); 044 * } 045 * </pre> 046 * */ 047public class HTTPServer implements Closeable { 048 049 static { 050 if (!System.getProperties().containsKey("sun.net.httpserver.maxReqTime")) { 051 System.setProperty("sun.net.httpserver.maxReqTime", "60"); 052 } 053 054 if (!System.getProperties().containsKey("sun.net.httpserver.maxRspTime")) { 055 System.setProperty("sun.net.httpserver.maxRspTime", "600"); 056 } 057 } 058 059 private static class LocalByteArray extends ThreadLocal<ByteArrayOutputStream> { 060 @Override 061 protected ByteArrayOutputStream initialValue() 062 { 063 return new ByteArrayOutputStream(1 << 20); 064 } 065 } 066 067 /** 068 * Handles Metrics collections from the given registry. 069 */ 070 public static class HTTPMetricHandler implements HttpHandler { 071 private final CollectorRegistry registry; 072 private final LocalByteArray response = new LocalByteArray(); 073 private final Supplier<Predicate<String>> sampleNameFilterSupplier; 074 private final static String HEALTHY_RESPONSE = "Exporter is Healthy."; 075 076 public HTTPMetricHandler(CollectorRegistry registry) { 077 this(registry, null); 078 } 079 080 public HTTPMetricHandler(CollectorRegistry registry, Supplier<Predicate<String>> sampleNameFilterSupplier) { 081 this.registry = registry; 082 this.sampleNameFilterSupplier = sampleNameFilterSupplier; 083 } 084 085 @Override 086 public void handle(HttpExchange t) throws IOException { 087 String query = t.getRequestURI().getRawQuery(); 088 String contextPath = t.getHttpContext().getPath(); 089 ByteArrayOutputStream response = this.response.get(); 090 response.reset(); 091 OutputStreamWriter osw = new OutputStreamWriter(response, Charset.forName("UTF-8")); 092 if ("/-/healthy".equals(contextPath)) { 093 osw.write(HEALTHY_RESPONSE); 094 } else { 095 String contentType = TextFormat.chooseContentType(t.getRequestHeaders().getFirst("Accept")); 096 t.getResponseHeaders().set("Content-Type", contentType); 097 Predicate<String> filter = sampleNameFilterSupplier == null ? null : sampleNameFilterSupplier.get(); 098 filter = SampleNameFilter.restrictToNamesEqualTo(filter, parseQuery(query)); 099 if (filter == null) { 100 TextFormat.writeFormat(contentType, osw, registry.metricFamilySamples()); 101 } else { 102 TextFormat.writeFormat(contentType, osw, registry.filteredMetricFamilySamples(filter)); 103 } 104 } 105 106 osw.close(); 107 108 if (shouldUseCompression(t)) { 109 t.getResponseHeaders().set("Content-Encoding", "gzip"); 110 t.sendResponseHeaders(HttpURLConnection.HTTP_OK, 0); 111 final GZIPOutputStream os = new GZIPOutputStream(t.getResponseBody()); 112 try { 113 response.writeTo(os); 114 } finally { 115 os.close(); 116 } 117 } else { 118 long contentLength = response.size(); 119 if (contentLength > 0) { 120 t.getResponseHeaders().set("Content-Length", String.valueOf(contentLength)); 121 } 122 if (t.getRequestMethod().equals("HEAD")) { 123 contentLength = -1; 124 } 125 t.sendResponseHeaders(HttpURLConnection.HTTP_OK, contentLength); 126 response.writeTo(t.getResponseBody()); 127 } 128 t.close(); 129 } 130 } 131 132 protected static boolean shouldUseCompression(HttpExchange exchange) { 133 List<String> encodingHeaders = exchange.getRequestHeaders().get("Accept-Encoding"); 134 if (encodingHeaders == null) return false; 135 136 for (String encodingHeader : encodingHeaders) { 137 String[] encodings = encodingHeader.split(","); 138 for (String encoding : encodings) { 139 if (encoding.trim().equalsIgnoreCase("gzip")) { 140 return true; 141 } 142 } 143 } 144 return false; 145 } 146 147 protected static Set<String> parseQuery(String query) throws IOException { 148 Set<String> names = new HashSet<String>(); 149 if (query != null) { 150 String[] pairs = query.split("&"); 151 for (String pair : pairs) { 152 int idx = pair.indexOf("="); 153 if (idx != -1 && URLDecoder.decode(pair.substring(0, idx), "UTF-8").equals("name[]")) { 154 names.add(URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); 155 } 156 } 157 } 158 return names; 159 } 160 161 162 static class NamedDaemonThreadFactory implements ThreadFactory { 163 private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1); 164 165 private final int poolNumber = POOL_NUMBER.getAndIncrement(); 166 private final AtomicInteger threadNumber = new AtomicInteger(1); 167 private final ThreadFactory delegate; 168 private final boolean daemon; 169 170 NamedDaemonThreadFactory(ThreadFactory delegate, boolean daemon) { 171 this.delegate = delegate; 172 this.daemon = daemon; 173 } 174 175 @Override 176 public Thread newThread(Runnable r) { 177 Thread t = delegate.newThread(r); 178 t.setName(String.format("prometheus-http-%d-%d", poolNumber, threadNumber.getAndIncrement())); 179 t.setDaemon(daemon); 180 return t; 181 } 182 183 static ThreadFactory defaultThreadFactory(boolean daemon) { 184 return new NamedDaemonThreadFactory(Executors.defaultThreadFactory(), daemon); 185 } 186 } 187 188 protected final HttpServer server; 189 protected final ExecutorService executorService; 190 191 /** 192 * We keep the original constructors of {@link HTTPServer} for compatibility, but new configuration 193 * parameters like {@code sampleNameFilter} must be configured using the Builder. 194 */ 195 public static class Builder { 196 197 private int port = 0; 198 private String hostname = null; 199 private InetAddress inetAddress = null; 200 private InetSocketAddress inetSocketAddress = null; 201 private HttpServer httpServer = null; 202 private CollectorRegistry registry = CollectorRegistry.defaultRegistry; 203 private boolean daemon = false; 204 private Predicate<String> sampleNameFilter; 205 private Supplier<Predicate<String>> sampleNameFilterSupplier; 206 private Authenticator authenticator; 207 private HttpsConfigurator httpsConfigurator; 208 209 /** 210 * Port to bind to. Must not be called together with {@link #withInetSocketAddress(InetSocketAddress)} 211 * or {@link #withHttpServer(HttpServer)}. Default is 0, indicating that a random port will be selected. 212 */ 213 public Builder withPort(int port) { 214 this.port = port; 215 return this; 216 } 217 218 /** 219 * Use this hostname to resolve the IP address to bind to. Must not be called together with 220 * {@link #withInetAddress(InetAddress)} or {@link #withInetSocketAddress(InetSocketAddress)} 221 * or {@link #withHttpServer(HttpServer)}. 222 * Default is empty, indicating that the HTTPServer binds to the wildcard address. 223 */ 224 public Builder withHostname(String hostname) { 225 this.hostname = hostname; 226 return this; 227 } 228 229 /** 230 * Bind to this IP address. Must not be called together with {@link #withHostname(String)} or 231 * {@link #withInetSocketAddress(InetSocketAddress)} or {@link #withHttpServer(HttpServer)}. 232 * Default is empty, indicating that the HTTPServer binds to the wildcard address. 233 */ 234 public Builder withInetAddress(InetAddress address) { 235 this.inetAddress = address; 236 return this; 237 } 238 239 /** 240 * Listen on this address. Must not be called together with {@link #withPort(int)}, 241 * {@link #withHostname(String)}, {@link #withInetAddress(InetAddress)}, or {@link #withHttpServer(HttpServer)}. 242 */ 243 public Builder withInetSocketAddress(InetSocketAddress address) { 244 this.inetSocketAddress = address; 245 return this; 246 } 247 248 /** 249 * Use this httpServer. The {@code httpServer} is expected to already be bound to an address. 250 * Must not be called together with {@link #withPort(int)}, or {@link #withHostname(String)}, 251 * or {@link #withInetAddress(InetAddress)}, or {@link #withInetSocketAddress(InetSocketAddress)}. 252 */ 253 public Builder withHttpServer(HttpServer httpServer) { 254 this.httpServer = httpServer; 255 return this; 256 } 257 258 /** 259 * By default, the {@link HTTPServer} uses non-daemon threads. Set this to {@code true} to 260 * run the {@link HTTPServer} with daemon threads. 261 */ 262 public Builder withDaemonThreads(boolean daemon) { 263 this.daemon = daemon; 264 return this; 265 } 266 267 /** 268 * Optional: Only export time series where {@code sampleNameFilter.test(name)} returns true. 269 * <p> 270 * Use this if the sampleNameFilter remains the same throughout the lifetime of the HTTPServer. 271 * If the sampleNameFilter changes during runtime, use {@link #withSampleNameFilterSupplier(Supplier)}. 272 */ 273 public Builder withSampleNameFilter(Predicate<String> sampleNameFilter) { 274 this.sampleNameFilter = sampleNameFilter; 275 return this; 276 } 277 278 /** 279 * Optional: Only export time series where {@code sampleNameFilter.test(name)} returns true. 280 * <p> 281 * Use this if the sampleNameFilter may change during runtime, like for example if you have a 282 * hot reload mechanism for your filter config. 283 * If the sampleNameFilter remains the same throughout the lifetime of the HTTPServer, 284 * use {@link #withSampleNameFilter(Predicate)} instead. 285 */ 286 public Builder withSampleNameFilterSupplier(Supplier<Predicate<String>> sampleNameFilterSupplier) { 287 this.sampleNameFilterSupplier = sampleNameFilterSupplier; 288 return this; 289 } 290 291 /** 292 * Optional: Default is {@link CollectorRegistry#defaultRegistry}. 293 */ 294 public Builder withRegistry(CollectorRegistry registry) { 295 this.registry = registry; 296 return this; 297 } 298 299 /** 300 * Optional: {@link Authenticator} to use to support authentication. 301 */ 302 public Builder withAuthenticator(Authenticator authenticator) { 303 this.authenticator = authenticator; 304 return this; 305 } 306 307 /** 308 * Optional: {@link HttpsConfigurator} to use to support TLS/SSL 309 */ 310 public Builder withHttpsConfigurator(HttpsConfigurator configurator) { 311 this.httpsConfigurator = configurator; 312 return this; 313 } 314 315 /** 316 * Build the HTTPServer 317 * @throws IOException 318 */ 319 public HTTPServer build() throws IOException { 320 if (sampleNameFilter != null) { 321 assertNull(sampleNameFilterSupplier, "cannot configure 'sampleNameFilter' and 'sampleNameFilterSupplier' at the same time"); 322 sampleNameFilterSupplier = SampleNameFilterSupplier.of(sampleNameFilter); 323 } 324 325 if (httpServer != null) { 326 assertZero(port, "cannot configure 'httpServer' and 'port' at the same time"); 327 assertNull(hostname, "cannot configure 'httpServer' and 'hostname' at the same time"); 328 assertNull(inetAddress, "cannot configure 'httpServer' and 'inetAddress' at the same time"); 329 assertNull(inetSocketAddress, "cannot configure 'httpServer' and 'inetSocketAddress' at the same time"); 330 assertNull(httpsConfigurator, "cannot configure 'httpServer' and 'httpsConfigurator' at the same time"); 331 return new HTTPServer(httpServer, registry, daemon, sampleNameFilterSupplier, authenticator); 332 } else if (inetSocketAddress != null) { 333 assertZero(port, "cannot configure 'inetSocketAddress' and 'port' at the same time"); 334 assertNull(hostname, "cannot configure 'inetSocketAddress' and 'hostname' at the same time"); 335 assertNull(inetAddress, "cannot configure 'inetSocketAddress' and 'inetAddress' at the same time"); 336 } else if (inetAddress != null) { 337 assertNull(hostname, "cannot configure 'inetAddress' and 'hostname' at the same time"); 338 inetSocketAddress = new InetSocketAddress(inetAddress, port); 339 } else if (hostname != null) { 340 inetSocketAddress = new InetSocketAddress(hostname, port); 341 } else { 342 inetSocketAddress = new InetSocketAddress(port); 343 } 344 345 HttpServer httpServer = null; 346 if (httpsConfigurator != null) { 347 httpServer = HttpsServer.create(inetSocketAddress, 3); 348 ((HttpsServer)httpServer).setHttpsConfigurator(httpsConfigurator); 349 } else { 350 httpServer = HttpServer.create(inetSocketAddress, 3); 351 } 352 353 return new HTTPServer(httpServer, registry, daemon, sampleNameFilterSupplier, authenticator); 354 } 355 356 private void assertNull(Object o, String msg) { 357 if (o != null) { 358 throw new IllegalStateException(msg); 359 } 360 } 361 362 private void assertZero(int i, String msg) { 363 if (i != 0) { 364 throw new IllegalStateException(msg); 365 } 366 } 367 } 368 369 /** 370 * Start an HTTP server serving Prometheus metrics from the given registry using the given {@link HttpServer}. 371 * The {@code httpServer} is expected to already be bound to an address 372 */ 373 public HTTPServer(HttpServer httpServer, CollectorRegistry registry, boolean daemon) throws IOException { 374 this(httpServer, registry, daemon, null, null); 375 } 376 377 /** 378 * Start an HTTP server serving Prometheus metrics from the given registry. 379 */ 380 public HTTPServer(InetSocketAddress addr, CollectorRegistry registry, boolean daemon) throws IOException { 381 this(HttpServer.create(addr, 3), registry, daemon); 382 } 383 384 /** 385 * Start an HTTP server serving Prometheus metrics from the given registry using non-daemon threads. 386 */ 387 public HTTPServer(InetSocketAddress addr, CollectorRegistry registry) throws IOException { 388 this(addr, registry, false); 389 } 390 391 /** 392 * Start an HTTP server serving the default Prometheus registry. 393 */ 394 public HTTPServer(int port, boolean daemon) throws IOException { 395 this(new InetSocketAddress(port), CollectorRegistry.defaultRegistry, daemon); 396 } 397 398 /** 399 * Start an HTTP server serving the default Prometheus registry using non-daemon threads. 400 */ 401 public HTTPServer(int port) throws IOException { 402 this(port, false); 403 } 404 405 /** 406 * Start an HTTP server serving the default Prometheus registry. 407 */ 408 public HTTPServer(String host, int port, boolean daemon) throws IOException { 409 this(new InetSocketAddress(host, port), CollectorRegistry.defaultRegistry, daemon); 410 } 411 412 /** 413 * Start an HTTP server serving the default Prometheus registry using non-daemon threads. 414 */ 415 public HTTPServer(String host, int port) throws IOException { 416 this(new InetSocketAddress(host, port), CollectorRegistry.defaultRegistry, false); 417 } 418 419 private HTTPServer(HttpServer httpServer, CollectorRegistry registry, boolean daemon, Supplier<Predicate<String>> sampleNameFilterSupplier, Authenticator authenticator) { 420 if (httpServer.getAddress() == null) 421 throw new IllegalArgumentException("HttpServer hasn't been bound to an address"); 422 423 server = httpServer; 424 HttpHandler mHandler = new HTTPMetricHandler(registry, sampleNameFilterSupplier); 425 HttpContext mContext = server.createContext("/", mHandler); 426 if (authenticator != null) { 427 mContext.setAuthenticator(authenticator); 428 } 429 mContext = server.createContext("/metrics", mHandler); 430 if (authenticator != null) { 431 mContext.setAuthenticator(authenticator); 432 } 433 mContext = server.createContext("/-/healthy", mHandler); 434 if (authenticator != null) { 435 mContext.setAuthenticator(authenticator); 436 } 437 executorService = Executors.newFixedThreadPool(5, NamedDaemonThreadFactory.defaultThreadFactory(daemon)); 438 server.setExecutor(executorService); 439 start(daemon); 440 } 441 442 /** 443 * Start an HTTP server by making sure that its background thread inherit proper daemon flag. 444 */ 445 private void start(boolean daemon) { 446 if (daemon == Thread.currentThread().isDaemon()) { 447 server.start(); 448 } else { 449 FutureTask<Void> startTask = new FutureTask<Void>(new Runnable() { 450 @Override 451 public void run() { 452 server.start(); 453 } 454 }, null); 455 NamedDaemonThreadFactory.defaultThreadFactory(daemon).newThread(startTask).start(); 456 try { 457 startTask.get(); 458 } catch (ExecutionException e) { 459 throw new RuntimeException("Unexpected exception on starting HTTPSever", e); 460 } catch (InterruptedException e) { 461 // This is possible only if the current tread has been interrupted, 462 // but in real use cases this should not happen. 463 // In any case, there is nothing to do, except to propagate interrupted flag. 464 Thread.currentThread().interrupt(); 465 } 466 } 467 } 468 469 /** 470 * Stop the HTTP server. 471 * @deprecated renamed to close(), so that the HTTPServer can be used in try-with-resources. 472 */ 473 public void stop() { 474 close(); 475 } 476 477 /** 478 * Stop the HTTPServer. 479 */ 480 @Override 481 public void close() { 482 server.stop(0); 483 executorService.shutdown(); // Free any (parked/idle) threads in pool 484 } 485 486 /** 487 * Gets the port number. 488 */ 489 public int getPort() { 490 return server.getAddress().getPort(); 491 } 492}