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                t.getResponseHeaders().set("Content-Length", String.valueOf(contentLength));
120                if (t.getRequestMethod().equals("HEAD")) {
121                    contentLength = -1;
122                }
123                t.sendResponseHeaders(HttpURLConnection.HTTP_OK, contentLength);
124                response.writeTo(t.getResponseBody());
125            }
126            t.close();
127        }
128    }
129
130    protected static boolean shouldUseCompression(HttpExchange exchange) {
131        List<String> encodingHeaders = exchange.getRequestHeaders().get("Accept-Encoding");
132        if (encodingHeaders == null) return false;
133
134        for (String encodingHeader : encodingHeaders) {
135            String[] encodings = encodingHeader.split(",");
136            for (String encoding : encodings) {
137                if (encoding.trim().equalsIgnoreCase("gzip")) {
138                    return true;
139                }
140            }
141        }
142        return false;
143    }
144
145    protected static Set<String> parseQuery(String query) throws IOException {
146        Set<String> names = new HashSet<String>();
147        if (query != null) {
148            String[] pairs = query.split("&");
149            for (String pair : pairs) {
150                int idx = pair.indexOf("=");
151                if (idx != -1 && URLDecoder.decode(pair.substring(0, idx), "UTF-8").equals("name[]")) {
152                    names.add(URLDecoder.decode(pair.substring(idx + 1), "UTF-8"));
153                }
154            }
155        }
156        return names;
157    }
158
159
160    static class NamedDaemonThreadFactory implements ThreadFactory {
161        private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1);
162
163        private final int poolNumber = POOL_NUMBER.getAndIncrement();
164        private final AtomicInteger threadNumber = new AtomicInteger(1);
165        private final ThreadFactory delegate;
166        private final boolean daemon;
167
168        NamedDaemonThreadFactory(ThreadFactory delegate, boolean daemon) {
169            this.delegate = delegate;
170            this.daemon = daemon;
171        }
172
173        @Override
174        public Thread newThread(Runnable r) {
175            Thread t = delegate.newThread(r);
176            t.setName(String.format("prometheus-http-%d-%d", poolNumber, threadNumber.getAndIncrement()));
177            t.setDaemon(daemon);
178            return t;
179        }
180
181        static ThreadFactory defaultThreadFactory(boolean daemon) {
182            return new NamedDaemonThreadFactory(Executors.defaultThreadFactory(), daemon);
183        }
184    }
185
186    protected final HttpServer server;
187    protected final ExecutorService executorService;
188
189    /**
190     * We keep the original constructors of {@link HTTPServer} for compatibility, but new configuration
191     * parameters like {@code sampleNameFilter} must be configured using the Builder.
192     */
193    public static class Builder {
194
195        private int port = 0;
196        private String hostname = null;
197        private InetAddress inetAddress = null;
198        private InetSocketAddress inetSocketAddress = null;
199        private HttpServer httpServer = null;
200        private CollectorRegistry registry = CollectorRegistry.defaultRegistry;
201        private boolean daemon = false;
202        private Predicate<String> sampleNameFilter;
203        private Supplier<Predicate<String>> sampleNameFilterSupplier;
204        private Authenticator authenticator;
205        private HttpsConfigurator httpsConfigurator;
206
207        /**
208         * Port to bind to. Must not be called together with {@link #withInetSocketAddress(InetSocketAddress)}
209         * or {@link #withHttpServer(HttpServer)}. Default is 0, indicating that a random port will be selected.
210         */
211        public Builder withPort(int port) {
212            this.port = port;
213            return this;
214        }
215
216        /**
217         * Use this hostname to resolve the IP address to bind to. Must not be called together with
218         * {@link #withInetAddress(InetAddress)} or {@link #withInetSocketAddress(InetSocketAddress)}
219         * or {@link #withHttpServer(HttpServer)}.
220         * Default is empty, indicating that the HTTPServer binds to the wildcard address.
221         */
222        public Builder withHostname(String hostname) {
223            this.hostname = hostname;
224            return this;
225        }
226
227        /**
228         * Bind to this IP address. Must not be called together with {@link #withHostname(String)} or
229         * {@link #withInetSocketAddress(InetSocketAddress)} or {@link #withHttpServer(HttpServer)}.
230         * Default is empty, indicating that the HTTPServer binds to the wildcard address.
231         */
232        public Builder withInetAddress(InetAddress address) {
233            this.inetAddress = address;
234            return this;
235        }
236
237        /**
238         * Listen on this address. Must not be called together with {@link #withPort(int)},
239         * {@link #withHostname(String)}, {@link #withInetAddress(InetAddress)}, or {@link #withHttpServer(HttpServer)}.
240         */
241        public Builder withInetSocketAddress(InetSocketAddress address) {
242            this.inetSocketAddress = address;
243            return this;
244        }
245
246        /**
247         * Use this httpServer. The {@code httpServer} is expected to already be bound to an address.
248         * Must not be called together with {@link #withPort(int)}, or {@link #withHostname(String)},
249         * or {@link #withInetAddress(InetAddress)}, or {@link #withInetSocketAddress(InetSocketAddress)}.
250         */
251        public Builder withHttpServer(HttpServer httpServer) {
252            this.httpServer = httpServer;
253            return this;
254        }
255
256        /**
257         * By default, the {@link HTTPServer} uses non-daemon threads. Set this to {@code true} to
258         * run the {@link HTTPServer} with daemon threads.
259         */
260        public Builder withDaemonThreads(boolean daemon) {
261            this.daemon = daemon;
262            return this;
263        }
264
265        /**
266         * Optional: Only export time series where {@code sampleNameFilter.test(name)} returns true.
267         * <p>
268         * Use this if the sampleNameFilter remains the same throughout the lifetime of the HTTPServer.
269         * If the sampleNameFilter changes during runtime, use {@link #withSampleNameFilterSupplier(Supplier)}.
270         */
271        public Builder withSampleNameFilter(Predicate<String> sampleNameFilter) {
272            this.sampleNameFilter = sampleNameFilter;
273            return this;
274        }
275
276        /**
277         * Optional: Only export time series where {@code sampleNameFilter.test(name)} returns true.
278         * <p>
279         * Use this if the sampleNameFilter may change during runtime, like for example if you have a
280         * hot reload mechanism for your filter config.
281         * If the sampleNameFilter remains the same throughout the lifetime of the HTTPServer,
282         * use {@link #withSampleNameFilter(Predicate)} instead.
283         */
284        public Builder withSampleNameFilterSupplier(Supplier<Predicate<String>> sampleNameFilterSupplier) {
285            this.sampleNameFilterSupplier = sampleNameFilterSupplier;
286            return this;
287        }
288
289        /**
290         * Optional: Default is {@link CollectorRegistry#defaultRegistry}.
291         */
292        public Builder withRegistry(CollectorRegistry registry) {
293            this.registry = registry;
294            return this;
295        }
296
297        /**
298         * Optional: {@link Authenticator} to use to support authentication.
299         */
300        public Builder withAuthenticator(Authenticator authenticator) {
301            this.authenticator = authenticator;
302            return this;
303        }
304
305        /**
306         * Optional: {@link HttpsConfigurator} to use to support TLS/SSL
307         */
308        public Builder withHttpsConfigurator(HttpsConfigurator configurator) {
309            this.httpsConfigurator = configurator;
310            return this;
311        }
312
313        /**
314         * Build the HTTPServer
315         * @throws IOException
316         */
317        public HTTPServer build() throws IOException {
318            if (sampleNameFilter != null) {
319                assertNull(sampleNameFilterSupplier, "cannot configure 'sampleNameFilter' and 'sampleNameFilterSupplier' at the same time");
320                sampleNameFilterSupplier = SampleNameFilterSupplier.of(sampleNameFilter);
321            }
322
323            if (httpServer != null) {
324                assertZero(port, "cannot configure 'httpServer' and 'port' at the same time");
325                assertNull(hostname, "cannot configure 'httpServer' and 'hostname' at the same time");
326                assertNull(inetAddress, "cannot configure 'httpServer' and 'inetAddress' at the same time");
327                assertNull(inetSocketAddress, "cannot configure 'httpServer' and 'inetSocketAddress' at the same time");
328                assertNull(httpsConfigurator, "cannot configure 'httpServer' and 'httpsConfigurator' at the same time");
329                return new HTTPServer(httpServer, registry, daemon, sampleNameFilterSupplier, authenticator);
330            } else if (inetSocketAddress != null) {
331                assertZero(port, "cannot configure 'inetSocketAddress' and 'port' at the same time");
332                assertNull(hostname, "cannot configure 'inetSocketAddress' and 'hostname' at the same time");
333                assertNull(inetAddress, "cannot configure 'inetSocketAddress' and 'inetAddress' at the same time");
334            } else if (inetAddress != null) {
335                assertNull(hostname, "cannot configure 'inetAddress' and 'hostname' at the same time");
336                inetSocketAddress = new InetSocketAddress(inetAddress, port);
337            } else if (hostname != null) {
338                inetSocketAddress = new InetSocketAddress(hostname, port);
339            } else {
340                inetSocketAddress = new InetSocketAddress(port);
341            }
342
343            HttpServer httpServer = null;
344            if (httpsConfigurator != null) {
345                httpServer = HttpsServer.create(inetSocketAddress, 3);
346                ((HttpsServer)httpServer).setHttpsConfigurator(httpsConfigurator);
347            } else {
348                httpServer = HttpServer.create(inetSocketAddress, 3);
349            }
350
351            return new HTTPServer(httpServer, registry, daemon, sampleNameFilterSupplier, authenticator);
352        }
353
354        private void assertNull(Object o, String msg) {
355            if (o != null) {
356                throw new IllegalStateException(msg);
357            }
358        }
359
360        private void assertZero(int i, String msg) {
361            if (i != 0) {
362                throw new IllegalStateException(msg);
363            }
364        }
365    }
366
367    /**
368     * Start an HTTP server serving Prometheus metrics from the given registry using the given {@link HttpServer}.
369     * The {@code httpServer} is expected to already be bound to an address
370     */
371    public HTTPServer(HttpServer httpServer, CollectorRegistry registry, boolean daemon) throws IOException {
372        this(httpServer, registry, daemon, null, null);
373    }
374
375    /**
376     * Start an HTTP server serving Prometheus metrics from the given registry.
377     */
378    public HTTPServer(InetSocketAddress addr, CollectorRegistry registry, boolean daemon) throws IOException {
379        this(HttpServer.create(addr, 3), registry, daemon);
380    }
381
382    /**
383     * Start an HTTP server serving Prometheus metrics from the given registry using non-daemon threads.
384     */
385    public HTTPServer(InetSocketAddress addr, CollectorRegistry registry) throws IOException {
386        this(addr, registry, false);
387    }
388
389    /**
390     * Start an HTTP server serving the default Prometheus registry.
391     */
392    public HTTPServer(int port, boolean daemon) throws IOException {
393        this(new InetSocketAddress(port), CollectorRegistry.defaultRegistry, daemon);
394    }
395
396    /**
397     * Start an HTTP server serving the default Prometheus registry using non-daemon threads.
398     */
399    public HTTPServer(int port) throws IOException {
400        this(port, false);
401    }
402
403    /**
404     * Start an HTTP server serving the default Prometheus registry.
405     */
406    public HTTPServer(String host, int port, boolean daemon) throws IOException {
407        this(new InetSocketAddress(host, port), CollectorRegistry.defaultRegistry, daemon);
408    }
409
410    /**
411     * Start an HTTP server serving the default Prometheus registry using non-daemon threads.
412     */
413    public HTTPServer(String host, int port) throws IOException {
414        this(new InetSocketAddress(host, port), CollectorRegistry.defaultRegistry, false);
415    }
416
417    private HTTPServer(HttpServer httpServer, CollectorRegistry registry, boolean daemon, Supplier<Predicate<String>> sampleNameFilterSupplier, Authenticator authenticator) {
418        if (httpServer.getAddress() == null)
419            throw new IllegalArgumentException("HttpServer hasn't been bound to an address");
420
421        server = httpServer;
422        HttpHandler mHandler = new HTTPMetricHandler(registry, sampleNameFilterSupplier);
423        HttpContext mContext = server.createContext("/", mHandler);
424        if (authenticator != null) {
425            mContext.setAuthenticator(authenticator);
426        }
427        mContext = server.createContext("/metrics", mHandler);
428        if (authenticator != null) {
429            mContext.setAuthenticator(authenticator);
430        }
431        mContext = server.createContext("/-/healthy", mHandler);
432        if (authenticator != null) {
433            mContext.setAuthenticator(authenticator);
434        }
435        executorService = Executors.newFixedThreadPool(5, NamedDaemonThreadFactory.defaultThreadFactory(daemon));
436        server.setExecutor(executorService);
437        start(daemon);
438    }
439
440    /**
441     * Start an HTTP server by making sure that its background thread inherit proper daemon flag.
442     */
443    private void start(boolean daemon) {
444        if (daemon == Thread.currentThread().isDaemon()) {
445            server.start();
446        } else {
447            FutureTask<Void> startTask = new FutureTask<Void>(new Runnable() {
448                @Override
449                public void run() {
450                    server.start();
451                }
452            }, null);
453            NamedDaemonThreadFactory.defaultThreadFactory(daemon).newThread(startTask).start();
454            try {
455                startTask.get();
456            } catch (ExecutionException e) {
457                throw new RuntimeException("Unexpected exception on starting HTTPSever", e);
458            } catch (InterruptedException e) {
459                // This is possible only if the current tread has been interrupted,
460                // but in real use cases this should not happen.
461                // In any case, there is nothing to do, except to propagate interrupted flag.
462                Thread.currentThread().interrupt();
463            }
464        }
465    }
466
467    /**
468     * Stop the HTTP server.
469     * @deprecated renamed to close(), so that the HTTPServer can be used in try-with-resources.
470     */
471    public void stop() {
472        close();
473    }
474
475    /**
476     * Stop the HTTPServer.
477     */
478    @Override
479    public void close() {
480        server.stop(0);
481        executorService.shutdown(); // Free any (parked/idle) threads in pool
482    }
483
484    /**
485     * Gets the port number.
486     */
487    public int getPort() {
488        return server.getAddress().getPort();
489    }
490}