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}