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