001package io.prometheus.client.exporter;
002
003import com.sun.net.httpserver.Authenticator;
004import com.sun.net.httpserver.HttpContext;
005import com.sun.net.httpserver.HttpExchange;
006import com.sun.net.httpserver.HttpHandler;
007import com.sun.net.httpserver.HttpServer;
008import com.sun.net.httpserver.HttpsConfigurator;
009import com.sun.net.httpserver.HttpsServer;
010import io.prometheus.client.CollectorRegistry;
011import io.prometheus.client.Predicate;
012import io.prometheus.client.SampleNameFilter;
013import io.prometheus.client.Supplier;
014import io.prometheus.client.exporter.common.TextFormat;
015
016import java.io.ByteArrayOutputStream;
017import java.io.Closeable;
018import java.io.IOException;
019import java.io.OutputStreamWriter;
020import java.net.HttpURLConnection;
021import java.net.InetAddress;
022import java.net.InetSocketAddress;
023import java.net.URLDecoder;
024import java.nio.charset.Charset;
025import java.util.HashSet;
026import java.util.List;
027import java.util.Set;
028import java.util.concurrent.ExecutionException;
029import java.util.concurrent.ExecutorService;
030import java.util.concurrent.Executors;
031import java.util.concurrent.FutureTask;
032import java.util.concurrent.ThreadFactory;
033import java.util.concurrent.ThreadPoolExecutor;
034import java.util.concurrent.atomic.AtomicInteger;
035import java.util.zip.GZIPOutputStream;
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 ExecutorService executorService = null;
203        private CollectorRegistry registry = CollectorRegistry.defaultRegistry;
204        private boolean daemon = false;
205        private Predicate<String> sampleNameFilter;
206        private Supplier<Predicate<String>> sampleNameFilterSupplier;
207        private Authenticator authenticator;
208        private HttpsConfigurator httpsConfigurator;
209
210        /**
211         * Port to bind to. Must not be called together with {@link #withInetSocketAddress(InetSocketAddress)}
212         * or {@link #withHttpServer(HttpServer)}. Default is 0, indicating that a random port will be selected.
213         */
214        public Builder withPort(int port) {
215            this.port = port;
216            return this;
217        }
218
219        /**
220         * Use this hostname to resolve the IP address to bind to. Must not be called together with
221         * {@link #withInetAddress(InetAddress)} or {@link #withInetSocketAddress(InetSocketAddress)}
222         * or {@link #withHttpServer(HttpServer)}.
223         * Default is empty, indicating that the HTTPServer binds to the wildcard address.
224         */
225        public Builder withHostname(String hostname) {
226            this.hostname = hostname;
227            return this;
228        }
229
230        /**
231         * Bind to this IP address. Must not be called together with {@link #withHostname(String)} or
232         * {@link #withInetSocketAddress(InetSocketAddress)} or {@link #withHttpServer(HttpServer)}.
233         * Default is empty, indicating that the HTTPServer binds to the wildcard address.
234         */
235        public Builder withInetAddress(InetAddress address) {
236            this.inetAddress = address;
237            return this;
238        }
239
240        /**
241         * Listen on this address. Must not be called together with {@link #withPort(int)},
242         * {@link #withHostname(String)}, {@link #withInetAddress(InetAddress)}, or {@link #withHttpServer(HttpServer)}.
243         */
244        public Builder withInetSocketAddress(InetSocketAddress address) {
245            this.inetSocketAddress = address;
246            return this;
247        }
248
249        /**
250         * Use this httpServer. The {@code httpServer} is expected to already be bound to an address.
251         * Must not be called together with {@link #withPort(int)}, or {@link #withHostname(String)},
252         * or {@link #withInetAddress(InetAddress)}, or {@link #withInetSocketAddress(InetSocketAddress)},
253         * or {@link #withExecutorService(ExecutorService)}.
254         */
255        public Builder withHttpServer(HttpServer httpServer) {
256            this.httpServer = httpServer;
257            return this;
258        }
259
260        /**
261         * Optional: ExecutorService used by the {@code httpServer}.
262         * Must not be called together with the {@link #withHttpServer(HttpServer)}.
263         *
264         * @param executorService
265         * @return
266         */
267        public Builder withExecutorService(ExecutorService executorService) {
268            this.executorService = executorService;
269            return this;
270        }
271
272        /**
273         * By default, the {@link HTTPServer} uses non-daemon threads. Set this to {@code true} to
274         * run the {@link HTTPServer} with daemon threads.
275         */
276        public Builder withDaemonThreads(boolean daemon) {
277            this.daemon = daemon;
278            return this;
279        }
280
281        /**
282         * Optional: Only export time series where {@code sampleNameFilter.test(name)} returns true.
283         * <p>
284         * Use this if the sampleNameFilter remains the same throughout the lifetime of the HTTPServer.
285         * If the sampleNameFilter changes during runtime, use {@link #withSampleNameFilterSupplier(Supplier)}.
286         */
287        public Builder withSampleNameFilter(Predicate<String> sampleNameFilter) {
288            this.sampleNameFilter = sampleNameFilter;
289            return this;
290        }
291
292        /**
293         * Optional: Only export time series where {@code sampleNameFilter.test(name)} returns true.
294         * <p>
295         * Use this if the sampleNameFilter may change during runtime, like for example if you have a
296         * hot reload mechanism for your filter config.
297         * If the sampleNameFilter remains the same throughout the lifetime of the HTTPServer,
298         * use {@link #withSampleNameFilter(Predicate)} instead.
299         */
300        public Builder withSampleNameFilterSupplier(Supplier<Predicate<String>> sampleNameFilterSupplier) {
301            this.sampleNameFilterSupplier = sampleNameFilterSupplier;
302            return this;
303        }
304
305        /**
306         * Optional: Default is {@link CollectorRegistry#defaultRegistry}.
307         */
308        public Builder withRegistry(CollectorRegistry registry) {
309            this.registry = registry;
310            return this;
311        }
312
313        /**
314         * Optional: {@link Authenticator} to use to support authentication.
315         */
316        public Builder withAuthenticator(Authenticator authenticator) {
317            this.authenticator = authenticator;
318            return this;
319        }
320
321        /**
322         * Optional: {@link HttpsConfigurator} to use to support TLS/SSL
323         */
324        public Builder withHttpsConfigurator(HttpsConfigurator configurator) {
325            this.httpsConfigurator = configurator;
326            return this;
327        }
328
329        /**
330         * Build the HTTPServer
331         * @throws IOException
332         */
333        public HTTPServer build() throws IOException {
334            if (sampleNameFilter != null) {
335                assertNull(sampleNameFilterSupplier, "cannot configure 'sampleNameFilter' and 'sampleNameFilterSupplier' at the same time");
336                sampleNameFilterSupplier = SampleNameFilterSupplier.of(sampleNameFilter);
337            }
338
339            if (httpServer != null) {
340                assertNull(executorService, "cannot configure 'httpServer' and `executorService'");
341                assertZero(port, "cannot configure 'httpServer' and 'port' at the same time");
342                assertNull(hostname, "cannot configure 'httpServer' and 'hostname' at the same time");
343                assertNull(inetAddress, "cannot configure 'httpServer' and 'inetAddress' at the same time");
344                assertNull(inetSocketAddress, "cannot configure 'httpServer' and 'inetSocketAddress' at the same time");
345                assertNull(httpsConfigurator, "cannot configure 'httpServer' and 'httpsConfigurator' at the same time");
346                return new HTTPServer(executorService, httpServer, registry, daemon, sampleNameFilterSupplier, authenticator);
347            } else if (inetSocketAddress != null) {
348                assertZero(port, "cannot configure 'inetSocketAddress' and 'port' at the same time");
349                assertNull(hostname, "cannot configure 'inetSocketAddress' and 'hostname' at the same time");
350                assertNull(inetAddress, "cannot configure 'inetSocketAddress' and 'inetAddress' at the same time");
351            } else if (inetAddress != null) {
352                assertNull(hostname, "cannot configure 'inetAddress' and 'hostname' at the same time");
353                inetSocketAddress = new InetSocketAddress(inetAddress, port);
354            } else if (hostname != null) {
355                inetSocketAddress = new InetSocketAddress(hostname, port);
356            } else {
357                inetSocketAddress = new InetSocketAddress(port);
358            }
359
360            HttpServer httpServer = null;
361            if (httpsConfigurator != null) {
362                httpServer = HttpsServer.create(inetSocketAddress, 3);
363                ((HttpsServer)httpServer).setHttpsConfigurator(httpsConfigurator);
364            } else {
365                httpServer = HttpServer.create(inetSocketAddress, 3);
366            }
367
368            return new HTTPServer(executorService, httpServer, registry, daemon, sampleNameFilterSupplier, authenticator);
369        }
370
371        private void assertNull(Object o, String msg) {
372            if (o != null) {
373                throw new IllegalStateException(msg);
374            }
375        }
376
377        private void assertZero(int i, String msg) {
378            if (i != 0) {
379                throw new IllegalStateException(msg);
380            }
381        }
382    }
383
384    /**
385     * Start an HTTP server serving Prometheus metrics from the given registry using the given {@link HttpServer}.
386     * The {@code httpServer} is expected to already be bound to an address
387     */
388    public HTTPServer(HttpServer httpServer, CollectorRegistry registry, boolean daemon) throws IOException {
389        this(null, httpServer, registry, daemon, null, null);
390    }
391
392    /**
393     * Start an HTTP server serving Prometheus metrics from the given registry.
394     */
395    public HTTPServer(InetSocketAddress addr, CollectorRegistry registry, boolean daemon) throws IOException {
396        this(HttpServer.create(addr, 3), registry, daemon);
397    }
398
399    /**
400     * Start an HTTP server serving Prometheus metrics from the given registry using non-daemon threads.
401     */
402    public HTTPServer(InetSocketAddress addr, CollectorRegistry registry) throws IOException {
403        this(addr, registry, false);
404    }
405
406    /**
407     * Start an HTTP server serving the default Prometheus registry.
408     */
409    public HTTPServer(int port, boolean daemon) throws IOException {
410        this(new InetSocketAddress(port), CollectorRegistry.defaultRegistry, daemon);
411    }
412
413    /**
414     * Start an HTTP server serving the default Prometheus registry using non-daemon threads.
415     */
416    public HTTPServer(int port) throws IOException {
417        this(port, false);
418    }
419
420    /**
421     * Start an HTTP server serving the default Prometheus registry.
422     */
423    public HTTPServer(String host, int port, boolean daemon) throws IOException {
424        this(new InetSocketAddress(host, port), CollectorRegistry.defaultRegistry, daemon);
425    }
426
427    /**
428     * Start an HTTP server serving the default Prometheus registry using non-daemon threads.
429     */
430    public HTTPServer(String host, int port) throws IOException {
431        this(new InetSocketAddress(host, port), CollectorRegistry.defaultRegistry, false);
432    }
433
434    private HTTPServer(ExecutorService executorService, HttpServer httpServer, CollectorRegistry registry, boolean daemon, Supplier<Predicate<String>> sampleNameFilterSupplier, Authenticator authenticator) {
435        if (httpServer.getAddress() == null)
436            throw new IllegalArgumentException("HttpServer hasn't been bound to an address");
437
438        server = httpServer;
439        HttpHandler mHandler = new HTTPMetricHandler(registry, sampleNameFilterSupplier);
440        HttpContext mContext = server.createContext("/", mHandler);
441        if (authenticator != null) {
442            mContext.setAuthenticator(authenticator);
443        }
444        mContext = server.createContext("/metrics", mHandler);
445        if (authenticator != null) {
446            mContext.setAuthenticator(authenticator);
447        }
448        mContext = server.createContext("/-/healthy", mHandler);
449        if (authenticator != null) {
450            mContext.setAuthenticator(authenticator);
451        }
452        if (executorService != null) {
453            this.executorService = executorService;
454        } else {
455            ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(5, NamedDaemonThreadFactory.defaultThreadFactory(daemon));
456            executor.setCorePoolSize(1);
457            this.executorService = executor;
458        }
459        server.setExecutor(this.executorService);
460        start(daemon);
461    }
462
463    /**
464     * Start an HTTP server by making sure that its background thread inherit proper daemon flag.
465     */
466    private void start(boolean daemon) {
467        if (daemon == Thread.currentThread().isDaemon()) {
468            server.start();
469        } else {
470            FutureTask<Void> startTask = new FutureTask<Void>(new Runnable() {
471                @Override
472                public void run() {
473                    server.start();
474                }
475            }, null);
476            NamedDaemonThreadFactory.defaultThreadFactory(daemon).newThread(startTask).start();
477            try {
478                startTask.get();
479            } catch (ExecutionException e) {
480                throw new RuntimeException("Unexpected exception on starting HTTPSever", e);
481            } catch (InterruptedException e) {
482                // This is possible only if the current tread has been interrupted,
483                // but in real use cases this should not happen.
484                // In any case, there is nothing to do, except to propagate interrupted flag.
485                Thread.currentThread().interrupt();
486            }
487        }
488    }
489
490    /**
491     * Stop the HTTP server.
492     * @deprecated renamed to close(), so that the HTTPServer can be used in try-with-resources.
493     */
494    public void stop() {
495        close();
496    }
497
498    /**
499     * Stop the HTTPServer.
500     */
501    @Override
502    public void close() {
503        server.stop(0);
504        executorService.shutdown(); // Free any (parked/idle) threads in pool
505    }
506
507    /**
508     * Gets the port number.
509     */
510    public int getPort() {
511        return server.getAddress().getPort();
512    }
513}