@@ -48,6 +59,11 @@
simpleclient_common
0.0.18-SNAPSHOT
+
+ org.springframework
+ spring-web
+ 4.2.5.RELEASE
+
org.springframework.boot
spring-boot-actuator
@@ -61,6 +77,12 @@
4.12
test
+
+ org.cthul
+ cthul-matchers
+ 1.1.0
+ test
+
org.springframework.boot
spring-boot-starter-test
@@ -69,7 +91,7 @@
org.springframework.boot
- spring-boot-starter-actuator
+ spring-boot-starter-web
1.3.3.RELEASE
test
diff --git a/simpleclient_spring_boot/src/main/java/io/prometheus/client/spring/boot/EnablePrometheusEndpoint.java b/simpleclient_spring_boot/src/main/java/io/prometheus/client/spring/boot/EnablePrometheusEndpoint.java
new file mode 100644
index 000000000..3b2e893f7
--- /dev/null
+++ b/simpleclient_spring_boot/src/main/java/io/prometheus/client/spring/boot/EnablePrometheusEndpoint.java
@@ -0,0 +1,44 @@
+package io.prometheus.client.spring.boot;
+
+import org.springframework.context.annotation.Import;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Enable an endpoint that exposes Prometheus metrics from its default collector.
+ *
+ * Usage:
+ *
Just add this annotation to the main class of your Spring Boot application, e.g.:
+ *
+ * {@literal @}SpringBootApplication
+ * {@literal @}EnablePrometheusEndpoint
+ * public class Application {
+ *
+ * public static void main(String[] args) {
+ * SpringApplication.run(Application.class, args);
+ * }
+ * }
+ *
+ *
+ * Configuration:
+ *
You can customize this endpoint at runtime using the following spring properties:
+ *
+ * - {@code endpoints.prometheus.id} (default: "prometheus")
+ * - {@code endpoints.prometheus.enabled} (default: {@code true})
+ * - {@code endpoints.prometheus.sensitive} (default: {@code true})
+ *
+ *
+ * @author Marco Aust
+ * @author Eliezio Oliveira
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Import(PrometheusConfiguration.class)
+public @interface EnablePrometheusEndpoint {
+
+}
diff --git a/simpleclient_spring_boot/src/main/java/io/prometheus/client/spring/boot/PrometheusConfiguration.java b/simpleclient_spring_boot/src/main/java/io/prometheus/client/spring/boot/PrometheusConfiguration.java
new file mode 100644
index 000000000..f6666f984
--- /dev/null
+++ b/simpleclient_spring_boot/src/main/java/io/prometheus/client/spring/boot/PrometheusConfiguration.java
@@ -0,0 +1,14 @@
+package io.prometheus.client.spring.boot;
+
+import io.prometheus.client.CollectorRegistry;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+class PrometheusConfiguration {
+
+ @Bean
+ public PrometheusEndpoint prometheusEndpoint() {
+ return new PrometheusEndpoint(CollectorRegistry.defaultRegistry);
+ }
+}
diff --git a/simpleclient_spring_boot/src/main/java/io/prometheus/client/spring/boot/PrometheusEndpoint.java b/simpleclient_spring_boot/src/main/java/io/prometheus/client/spring/boot/PrometheusEndpoint.java
new file mode 100644
index 000000000..5ad70f9bc
--- /dev/null
+++ b/simpleclient_spring_boot/src/main/java/io/prometheus/client/spring/boot/PrometheusEndpoint.java
@@ -0,0 +1,38 @@
+package io.prometheus.client.spring.boot;
+
+import io.prometheus.client.CollectorRegistry;
+import io.prometheus.client.exporter.common.TextFormat;
+import org.springframework.boot.actuate.endpoint.AbstractEndpoint;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.http.ResponseEntity;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.Writer;
+
+import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
+
+@ConfigurationProperties("endpoints.prometheus")
+class PrometheusEndpoint extends AbstractEndpoint> {
+
+ private final CollectorRegistry collectorRegistry;
+
+ PrometheusEndpoint(CollectorRegistry collectorRegistry) {
+ super("prometheus");
+ this.collectorRegistry = collectorRegistry;
+ }
+
+ @Override
+ public ResponseEntity invoke() {
+ try {
+ Writer writer = new StringWriter();
+ TextFormat.write004(writer, collectorRegistry.metricFamilySamples());
+ return ResponseEntity.ok()
+ .header(CONTENT_TYPE, TextFormat.CONTENT_TYPE_004)
+ .body(writer.toString());
+ } catch (IOException e) {
+ // This actually never happens since StringWriter::write() doesn't throw any IOException
+ throw new RuntimeException("Writing metrics failed", e);
+ }
+ }
+}
diff --git a/simpleclient_spring_boot/src/test/java/io/prometheus/client/matchers/CustomMatchers.java b/simpleclient_spring_boot/src/test/java/io/prometheus/client/matchers/CustomMatchers.java
new file mode 100644
index 000000000..dddec7b61
--- /dev/null
+++ b/simpleclient_spring_boot/src/test/java/io/prometheus/client/matchers/CustomMatchers.java
@@ -0,0 +1,45 @@
+package io.prometheus.client.matchers;
+
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.core.IsCollectionContaining;
+
+/**
+ * @author BretC
+ *
+ * @see this StackOverflow answer
+ *
+ * Licensed under Creative Commons BY-SA 3.0
+ */
+public final class CustomMatchers {
+
+ private CustomMatchers() {
+ }
+
+ public static Matcher> exactlyNItems(final int n, final Matcher super T> elementMatcher) {
+ return new IsCollectionContaining(elementMatcher) {
+ @Override
+ protected boolean matchesSafely(Iterable super T> collection, Description mismatchDescription) {
+ int count = 0;
+ boolean isPastFirst = false;
+
+ for (Object item : collection) {
+
+ if (elementMatcher.matches(item)) {
+ count++;
+ }
+ if (isPastFirst) {
+ mismatchDescription.appendText(", ");
+ }
+ elementMatcher.describeMismatch(item, mismatchDescription);
+ isPastFirst = true;
+ }
+
+ if (count != n) {
+ mismatchDescription.appendText(". Expected exactly " + n + " but got " + count);
+ }
+ return count == n;
+ }
+ };
+ }
+}
diff --git a/simpleclient_spring_boot/src/test/java/io/prometheus/client/spring/boot/DummyBootApplication.java b/simpleclient_spring_boot/src/test/java/io/prometheus/client/spring/boot/DummyBootApplication.java
new file mode 100644
index 000000000..a011d137d
--- /dev/null
+++ b/simpleclient_spring_boot/src/test/java/io/prometheus/client/spring/boot/DummyBootApplication.java
@@ -0,0 +1,12 @@
+package io.prometheus.client.spring.boot;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Dummy class to satisfy Spring Boot Test requirement of a class annotated either with {code @SpringBootApplication} or
+ * {code @SpringBootConfiguration}.
+ */
+@SpringBootApplication
+class DummyBootApplication {
+
+}
diff --git a/simpleclient_spring_boot/src/test/java/io/prometheus/client/spring/boot/PrometheusEndpointTest.java b/simpleclient_spring_boot/src/test/java/io/prometheus/client/spring/boot/PrometheusEndpointTest.java
new file mode 100644
index 000000000..682eef40f
--- /dev/null
+++ b/simpleclient_spring_boot/src/test/java/io/prometheus/client/spring/boot/PrometheusEndpointTest.java
@@ -0,0 +1,61 @@
+package io.prometheus.client.spring.boot;
+
+import io.prometheus.client.Counter;
+import io.prometheus.client.matchers.CustomMatchers;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.SpringApplicationConfiguration;
+import org.springframework.boot.test.TestRestTemplate;
+import org.springframework.boot.test.WebIntegrationTest;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.cthul.matchers.CthulMatchers.matchesPattern;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(SpringJUnit4ClassRunner.class)
+@SpringApplicationConfiguration(DummyBootApplication.class)
+@WebIntegrationTest(randomPort = true)
+@EnablePrometheusEndpoint
+public class PrometheusEndpointTest {
+
+ @Value("${local.server.port}")
+ int localServerPort;
+
+ RestTemplate template = new TestRestTemplate();
+
+ @Test
+ public void testMetricsExportedThroughPrometheusEndpoint() {
+ // given:
+ final Counter promCounter = Counter.build()
+ .name("foo_bar")
+ .help("a simple prometheus counter")
+ .labelNames("label1", "label2")
+ .register();
+
+ // when:
+ promCounter.labels("val1", "val2").inc(3);
+ ResponseEntity metricsResponse = template.getForEntity(getBaseUrl() + "/prometheus", String.class);
+
+ // then:
+ assertEquals(HttpStatus.OK, metricsResponse.getStatusCode());
+ assertTrue(MediaType.TEXT_PLAIN.isCompatibleWith(metricsResponse.getHeaders().getContentType()));
+
+ List responseLines = Arrays.asList(metricsResponse.getBody().split("\n"));
+ assertThat(responseLines, CustomMatchers.exactlyNItems(1,
+ matchesPattern("foo_bar\\{label1=\"val1\",label2=\"val2\",?\\} 3.0")));
+ }
+
+ private String getBaseUrl() {
+ return "http://localhost:" + localServerPort;
+ }
+}