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