fields,
+ final long timestamp) {
+ Preconditions.checkArgument(!name.isEmpty(), "InfluxDbMeasurement must contain a non-empty name");
+ Preconditions.checkArgument(!fields.isEmpty(), "InfluxDbMeasurement must contain at least one field");
+ return new AutoValue_InfluxDbMeasurement(name, tags, fields, timestamp);
+ }
+
+ /**
+ * Returns the measurement in InfluxDB line notation with the provided timestamp precision.
+ *
+ * Does not support sub-millisecond timestamp precision.
+ */
+ public String toLine() {
+ final StringBuilder sb = new StringBuilder();
+ sb.append(name());
+
+ if (!tags().isEmpty()) {
+ sb.append(',');
+ sb.append(joinPairs(tags()));
+ }
+
+ if (!fields().isEmpty()) {
+ sb.append(' ');
+ sb.append(joinPairs(fields()));
+ }
+
+ sb.append(' ');
+ sb.append(TimeUnit.NANOSECONDS.convert(timestamp(), TimeUnit.MILLISECONDS));
+ return sb.toString();
+ }
+
+ @Override
+ public String toString() {
+ return toLine();
+ }
+
+ /**
+ * Returns a comma-separated key-value formatting of {@code pairs}.
+ *
+ *
e.g. "k1=v1,k2=v2,k3=v3"
+ */
+ @VisibleForTesting static String joinPairs(final Map pairs) {
+ final List pairStrs = pairs.entrySet()
+ .parallelStream()
+ .map(e -> String.join("=", e.getKey(), e.getValue()))
+ .collect(toList());
+
+ return String.join(",", pairStrs);
+ }
+
+ // ===================================================================================================================
+ // Builder
+
+ /**
+ * A builder for {@link InfluxDbMeasurement}.
+ */
+ public static class Builder {
+ private final String name;
+ private final long timestamp;
+ private final Map tags = new HashMap<>();
+ private final Map fields = new HashMap<>();
+
+ public Builder(final String name, final long timestamp) {
+ this.name = name;
+ this.timestamp = timestamp;
+ }
+
+ public boolean isValid() {
+ return !name.isEmpty() && !fields.isEmpty();
+ }
+
+ /**
+ * Adds all key-value pairs to the tags map.
+ * @param items
+ * @return
+ */
+ public Builder putTags(final Map items) {
+ tags.putAll(items);
+ return this;
+ }
+
+ /**
+ * Adds the key-value pair to the tags map.
+ */
+ public Builder putTag(final String key, final String value) {
+ tags.put(key, value);
+ return this;
+ }
+
+ /**
+ * Adds all key-value pairs to the fields map.
+ *
+ * @throws IllegalArgumentException if any value is not a String or primitive.
+ */
+ public Builder putFields(final Map fields) {
+ fields.forEach(this::putField);
+ return this;
+ }
+
+ /**
+ * Adds all key-value pairs to the fields map.
+ */
+ public Builder tryPutFields(final Map fields,
+ final Consumer exceptionHandler) {
+ for(final Map.Entry field: fields.entrySet()) {
+ try {
+ putField(field.getKey(), field.getValue());
+ } catch (IllegalArgumentException e) {
+ exceptionHandler.accept(e);
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Adds the given key-value pair to the fields map.
+ *
+ * @throws IllegalArgumentException if any value is not a String or primitive.
+ */
+ public Builder putField(final String key, final T value) {
+ if (value == null) {
+ return this;
+ }
+
+ if (value instanceof Float) {
+ final float f = (Float) value;
+ if (!Float.isNaN(f) && !Float.isInfinite(f)) {
+ fields.put(key, String.valueOf(f));
+ }
+ } else if (value instanceof Double) {
+ final double d = (Double) value;
+ if (!Double.isNaN(d) && !Double.isInfinite(d)) {
+ fields.put(key, String.valueOf(d));
+ }
+ } else if (value instanceof Integer || value instanceof Long) {
+ fields.put(key, String.format("%di", ((Number) value).longValue()));
+ } else if (value instanceof String || value instanceof Boolean) {
+ fields.put(key, value.toString());
+ } else {
+ throw new IllegalArgumentException(
+ String.format("InfluxDbMeasurement field '%s' must be String or primitive: %s", key, value)
+ );
+ }
+
+ return this;
+ }
+
+ public InfluxDbMeasurement build() {
+ return InfluxDbMeasurement.create(name, tags, fields, timestamp);
+ }
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/InfluxDbMeasurementReporter.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/InfluxDbMeasurementReporter.java
new file mode 100644
index 0000000..c02ff3a
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/InfluxDbMeasurementReporter.java
@@ -0,0 +1,77 @@
+package com.kickstarter.dropwizard.metrics.influxdb;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.ScheduledReporter;
+import com.codahale.metrics.Timer;
+import com.google.common.collect.ImmutableList;
+import com.kickstarter.dropwizard.metrics.influxdb.io.Sender;
+import com.kickstarter.dropwizard.metrics.influxdb.transformer.DropwizardMeasurement;
+import com.kickstarter.dropwizard.metrics.influxdb.transformer.DropwizardTransformer;
+
+import java.time.Clock;
+import java.util.SortedMap;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A Dropwizard {@link ScheduledReporter} for reporting measurements in InfluxDB line format.
+ *
+ *
Supports global tags, tagged templating, counter/gauge grouping,
+ * and per-metric tagging via {@link DropwizardMeasurement#toString}.
+ */
+public class InfluxDbMeasurementReporter extends ScheduledReporter {
+ private final Clock clock;
+ private final Sender sender;
+ private final DropwizardTransformer transformer;
+
+ public InfluxDbMeasurementReporter(final Sender sender,
+ final MetricRegistry registry,
+ final MetricFilter filter,
+ final TimeUnit rateUnit,
+ final TimeUnit durationUnit,
+ final Clock clock,
+ final DropwizardTransformer transformer,
+ final ScheduledExecutorService executor) {
+ super(registry, "influxdb-measurement-reporter", filter, rateUnit, durationUnit, executor);
+ this.clock = clock;
+ this.sender = sender;
+ this.transformer = transformer;
+ }
+
+ public InfluxDbMeasurementReporter(final Sender sender,
+ final MetricRegistry registry,
+ final MetricFilter filter,
+ final TimeUnit rateUnit,
+ final TimeUnit durationUnit,
+ final Clock clock,
+ final DropwizardTransformer transformer) {
+ super(registry, "influxdb-measurement-reporter", filter, rateUnit, durationUnit);
+ this.clock = clock;
+ this.sender = sender;
+ this.transformer = transformer;
+ }
+
+ @Override
+ public void report(final SortedMap gauges,
+ final SortedMap counters,
+ final SortedMap histograms,
+ final SortedMap meters,
+ final SortedMap timers) {
+ final long timestamp = clock.instant().toEpochMilli();
+
+ final ImmutableList influxDbMeasurements = ImmutableList.builder()
+ .addAll(transformer.fromGauges(gauges, timestamp))
+ .addAll(transformer.fromCounters(counters, timestamp))
+ .addAll(transformer.fromHistograms(histograms, timestamp))
+ .addAll(transformer.fromMeters(meters, timestamp))
+ .addAll(transformer.fromTimers(timers, timestamp))
+ .build();
+
+ sender.send(influxDbMeasurements);
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/InfluxDbMeasurementReporterFactory.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/InfluxDbMeasurementReporterFactory.java
new file mode 100644
index 0000000..ff18ce1
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/InfluxDbMeasurementReporterFactory.java
@@ -0,0 +1,117 @@
+package com.kickstarter.dropwizard.metrics.influxdb;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.ScheduledReporter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.google.common.annotations.VisibleForTesting;
+import com.kickstarter.dropwizard.metrics.influxdb.io.InfluxDbHttpWriter;
+import com.kickstarter.dropwizard.metrics.influxdb.io.InfluxDbWriter;
+import com.kickstarter.dropwizard.metrics.influxdb.io.Sender;
+import com.kickstarter.dropwizard.metrics.influxdb.transformer.DropwizardMeasurementParser;
+import com.kickstarter.dropwizard.metrics.influxdb.transformer.DropwizardTransformer;
+import com.kickstarter.dropwizard.metrics.influxdb.transformer.TaggedPattern;
+import io.dropwizard.metrics.BaseReporterFactory;
+import javax.validation.constraints.NotNull;
+
+import java.time.Clock;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A factory for {@link InfluxDbMeasurementReporter} instances.
+ *
+ * Configuration Parameters:
+ *
+ *
+ * Name |
+ * Default |
+ * Description |
+ *
+ *
+ * globalTags |
+ * None |
+ * tags for all metrics reported to InfluxDb. |
+ *
+ *
+ * metricTemplates |
+ * None |
+ * tagged metric templates for converting names passed through MetricRegistry. |
+ *
+ *
+ * groupGauges |
+ * true |
+ * A boolean to signal whether to group gauges when reporting. |
+ *
+ *
+ * groupCounters |
+ * true |
+ * A boolean to signal whether to group counters when reporting. |
+ *
+ *
+ * sender |
+ * http |
+ * The type and configuration for reporting measurements to a receiver. |
+ *
+ *
+ */
+@JsonTypeName("influxdb")
+public class InfluxDbMeasurementReporterFactory extends BaseReporterFactory {
+ @NotNull
+ @JsonProperty
+ private Map globalTags = new HashMap<>();
+ @VisibleForTesting Map globalTags() {
+ return globalTags;
+ }
+
+ @NotNull
+ @JsonProperty
+ private Map metricTemplates = new HashMap<>();
+ @VisibleForTesting Map metricTemplates() {
+ return metricTemplates;
+ }
+
+ @NotNull
+ @JsonProperty
+ private boolean groupGauges = true;
+ @VisibleForTesting boolean groupGauges() {
+ return groupGauges;
+ }
+
+ @NotNull
+ @JsonProperty
+ private boolean groupCounters = true;
+ @VisibleForTesting boolean groupCounters() {
+ return groupCounters;
+ }
+
+ @NotNull
+ @JsonProperty
+ private InfluxDbWriter.Factory sender = new InfluxDbHttpWriter.Factory();
+ @VisibleForTesting InfluxDbWriter.Factory sender() {
+ return sender;
+ }
+
+ @Override
+ public ScheduledReporter build(final MetricRegistry registry) {
+ final Sender builtSender = new Sender(sender.build(registry));
+ final DropwizardTransformer transformer = new DropwizardTransformer(
+ globalTags,
+ DropwizardMeasurementParser.withTemplates(metricTemplates),
+ groupCounters,
+ groupGauges,
+ getRateUnit(),
+ getDurationUnit()
+ );
+
+ return new InfluxDbMeasurementReporter(
+ builtSender,
+ registry,
+ getFilter(),
+ getRateUnit(),
+ getDurationUnit(),
+ Clock.systemUTC(),
+ transformer
+ );
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/InfluxDbHttpWriter.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/InfluxDbHttpWriter.java
new file mode 100644
index 0000000..fbd4e6b
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/InfluxDbHttpWriter.java
@@ -0,0 +1,110 @@
+package com.kickstarter.dropwizard.metrics.influxdb.io;
+
+import com.codahale.metrics.MetricRegistry;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.dropwizard.client.JerseyClientConfiguration;
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import org.hibernate.validator.constraints.NotBlank;
+import org.hibernate.validator.constraints.Range;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.concurrent.Executors;
+
+/**
+ * An {@link InfluxDbWriter} that writes to an HTTP/S server using a {@link Client}.
+ */
+public class InfluxDbHttpWriter implements InfluxDbWriter {
+ private final Client client;
+ private final WebTarget influxLines;
+
+ public InfluxDbHttpWriter(final Client client, final String endpoint) {
+ this.client = client;
+ this.influxLines = client.target(endpoint);
+ }
+
+ @Override
+ public void writeBytes(final byte[] bytes) {
+ influxLines.request().post(Entity.entity(bytes, MediaType.APPLICATION_OCTET_STREAM_TYPE));
+ }
+
+ @Override
+ public void close() throws IOException {
+ client.close();
+ }
+
+ // ===================================================================================================================
+ // Builder
+
+ /**
+ * A factory for {@link InfluxDbHttpWriter}.
+ *
+ * Configuration Parameters:
+ *
+ *
+ * Name |
+ * Default |
+ * Description |
+ *
+ *
+ * host |
+ * localhost |
+ * the InfluxDB hostname. |
+ *
+ *
+ * port |
+ * 8086 |
+ * the InfluxDB port. |
+ *
+ *
+ * database |
+ * none |
+ * the database to write to. |
+ *
+ *
+ * jersey |
+ * default |
+ * the jersey client configuration. |
+ *
+ *
+ */
+ public static class Factory implements InfluxDbWriter.Factory {
+ @NotBlank
+ @JsonProperty
+ private String host = "localhost";
+
+ @Range(min = 0, max = 49151)
+ @JsonProperty
+ private int port = 8086;
+
+ @JsonProperty
+ private JerseyClientConfiguration jersey = new JerseyClientConfiguration();
+
+ @NotBlank
+ @JsonProperty
+ private String database;
+
+ public InfluxDbWriter build(final MetricRegistry metrics) {
+ final Client client = new io.dropwizard.client.JerseyClientBuilder(metrics)
+ .using(jersey)
+ .using(new ObjectMapper())
+ .using(Executors.newSingleThreadExecutor())
+ .build("influxdb-http-writer");
+
+ try {
+ final String query = "/write?db=" + URLEncoder.encode(database, "UTF-8");
+ final URL endpoint = new URL("http", host, port, query);
+ return new InfluxDbHttpWriter(client, endpoint.toString());
+ } catch (MalformedURLException | UnsupportedEncodingException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/InfluxDbTcpWriter.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/InfluxDbTcpWriter.java
new file mode 100644
index 0000000..a12e97b
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/InfluxDbTcpWriter.java
@@ -0,0 +1,105 @@
+package com.kickstarter.dropwizard.metrics.influxdb.io;
+
+import com.codahale.metrics.MetricRegistry;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.dropwizard.util.Duration;
+import javax.validation.constraints.NotNull;
+import org.hibernate.validator.constraints.NotBlank;
+import org.hibernate.validator.constraints.Range;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.Socket;
+
+/**
+ * An {@link InfluxDbWriter} that writes bytes to TCP sockets.
+ */
+public class InfluxDbTcpWriter implements InfluxDbWriter {
+ private final String host;
+ private final int port;
+ private final Duration timeout;
+ private Socket tcpSocket;
+
+ public InfluxDbTcpWriter(final String host, final int port, final Duration timeout) {
+ this.host = host;
+ this.port = port;
+ this.timeout = timeout;
+ }
+
+ @Override
+ public void writeBytes(final byte[] bytes) throws IOException {
+ if (tcpSocket == null) {
+ tcpSocket = new Socket(host, port);
+ tcpSocket.setSoTimeout((int) timeout.toMilliseconds());
+ }
+
+ final OutputStream outputStream = tcpSocket.getOutputStream();
+ outputStream.write(bytes);
+ outputStream.flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (tcpSocket != null) {
+ tcpSocket.close();
+ tcpSocket = null;
+ }
+ }
+
+ // ===================================================================================================================
+ // Builder
+
+ /**
+ * A factory for {@link InfluxDbTcpWriter}.
+ *
+ * Configuration Parameters:
+ *
+ *
+ * Name |
+ * Default |
+ * Description |
+ *
+ *
+ * host |
+ * localhost |
+ * the consumer hostname. |
+ *
+ *
+ * port |
+ * 8086 |
+ * the consumer port. |
+ *
+ *
+ * timeout |
+ * 500 milliseconds/i> |
+ * the socket timeout duration. |
+ *
+ *
+ */
+ public static class Factory implements InfluxDbWriter.Factory {
+ @NotBlank
+ @JsonProperty
+ private String host = "localhost";
+ public String host() {
+ return host;
+ }
+
+ @Range(min = 0, max = 49151)
+ @JsonProperty
+ private int port = 8086;
+ public int port() {
+ return port;
+ }
+
+ @NotNull
+ @JsonProperty
+ private Duration timeout = Duration.milliseconds(500);
+ public Duration timeout() {
+ return timeout;
+ }
+
+ @Override public InfluxDbWriter build(final MetricRegistry __) {
+ return new InfluxDbTcpWriter(host, port, timeout);
+ }
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/InfluxDbWriter.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/InfluxDbWriter.java
new file mode 100644
index 0000000..e153e29
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/InfluxDbWriter.java
@@ -0,0 +1,33 @@
+package com.kickstarter.dropwizard.metrics.influxdb.io;
+
+import com.codahale.metrics.MetricRegistry;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+import java.io.IOException;
+
+/**
+ * Writes bytes to an InfluxDB input.
+ */
+public interface InfluxDbWriter {
+ /**
+ * Write the given bytes to the connection.
+ *
+ * @throws Exception if an error occurs while writing.
+ */
+ void writeBytes(final byte[] bytes) throws Exception;
+ /**
+ * Close the writer connection, if it is open.
+ *
+ * @throws IOException if an I/O error occurs when closing the connection.
+ */
+ void close() throws IOException;
+
+ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
+ @JsonSubTypes({
+ @JsonSubTypes.Type(value = InfluxDbHttpWriter.Factory.class, name = "http"),
+ @JsonSubTypes.Type(value = InfluxDbTcpWriter.Factory.class, name = "tcp")})
+ interface Factory {
+ InfluxDbWriter build(final MetricRegistry metrics);
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/Sender.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/Sender.java
new file mode 100644
index 0000000..2de4831
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/io/Sender.java
@@ -0,0 +1,81 @@
+package com.kickstarter.dropwizard.metrics.influxdb.io;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.EvictingQueue;
+import com.kickstarter.dropwizard.metrics.influxdb.InfluxDbMeasurement;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.UnsupportedEncodingException;
+import java.util.Collection;
+
+import static java.util.stream.Collectors.toList;
+
+/**
+ * Sends measurements to InfluxDB. Uses an {@link EvictingQueue} to store and retry measurements that have
+ * failed to send, and timestamps measurements at the configured {@code precision}, up to millisecond precision.
+ */
+public class Sender {
+ private static final Logger log = LoggerFactory.getLogger(InfluxDbTcpWriter.class);
+
+ public static final int DEFAULT_QUEUE_SIZE = 5000;
+ private static final String SEPARATOR = "\n";
+
+ private final InfluxDbWriter writer;
+ private final EvictingQueue queuedInfluxDbMeasurements;
+
+ public Sender(final InfluxDbWriter writer) {
+ this(writer, DEFAULT_QUEUE_SIZE);
+ }
+
+ public Sender(final InfluxDbWriter writer, final int queueSize) {
+ this.writer = writer;
+ this.queuedInfluxDbMeasurements = EvictingQueue.create(queueSize);
+ }
+
+ @VisibleForTesting int queuedMeasures() {
+ return queuedInfluxDbMeasurements.size();
+ }
+
+ /**
+ * Sends the provided {@link InfluxDbMeasurement measurements} to InfluxDB.
+ *
+ * @return true if the measurements were successfully sent.
+ */
+ public boolean send(final Collection influxDbMeasurements) {
+ queuedInfluxDbMeasurements.addAll(influxDbMeasurements);
+
+ if (queuedInfluxDbMeasurements.isEmpty()) {
+ return true;
+ }
+
+ final String measureLines = String.join(
+ SEPARATOR,
+ queuedInfluxDbMeasurements.stream()
+ .map(InfluxDbMeasurement::toLine)
+ .collect(toList())
+ ) + SEPARATOR;
+
+ try {
+ final byte[] measureBytes = measureLines.getBytes("UTF-8");
+ writer.writeBytes(measureBytes);
+ queuedInfluxDbMeasurements.clear();
+ return true;
+ } catch (final UnsupportedEncodingException e) {
+ log.warn("failed to send metrics", e);
+ } catch (final Exception e) {
+ log.warn("failed to send metrics", e);
+ try {
+ writer.close();
+ } catch (final Exception e2) {
+ log.warn("failed to close metrics connection", e2);
+ }
+ }
+
+ if (queuedInfluxDbMeasurements.remainingCapacity() == 0) {
+ log.warn("Queued measurements at capacity");
+ }
+
+ return false;
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/DropwizardMeasurement.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/DropwizardMeasurement.java
new file mode 100644
index 0000000..63045d3
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/DropwizardMeasurement.java
@@ -0,0 +1,123 @@
+package com.kickstarter.dropwizard.metrics.influxdb.transformer;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.google.auto.value.AutoValue;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+
+import static java.util.stream.Collectors.toMap;
+
+/**
+ * A Dropwizard measurement associated with a {@link MetricRegistry} component
+ * (e.g. {@link Timer timers}, {@link Counter counters}, and {@link Gauge gauges}).
+ *
+ * Serialized in the following format:
+ *
{@code
+ * measurement_name[,tag1=val1,tag2=val2][ field_name]
+ * }
+ */
+@AutoValue
+public abstract class DropwizardMeasurement {
+ // Serializer constants.
+ private static final String FIELD_SEPARATOR = " ";
+ private static final String TAG_SEPARATOR = ",";
+ private static final String VALUE_SEPARATOR = "=";
+ private static final String ESCAPE_CHARS = " |,|=";
+ private static final String SANITIZER = "-";
+
+ // Fields
+ abstract String name();
+ abstract Map tags();
+
+ // Provided field name for gauges; used for gauge grouping.
+ // This field is only set for non-templated measurements;
+ // templates will be parsed for field names.
+ abstract Optional field();
+
+ /**
+ * Sanitizes InfluxDb escape characters.
+ */
+ private static String sanitize(final String s) {
+ return s.replaceAll(ESCAPE_CHARS, SANITIZER);
+ }
+
+ /**
+ * Creates a new {@link DropwizardMeasurement} with sanitized values.
+ */
+ public static DropwizardMeasurement create(final String name, final Map tags, final Optional field) {
+ final Map sanitizedTags = tags.entrySet()
+ .stream()
+ .collect(toMap(
+ e -> DropwizardMeasurement.sanitize(e.getKey()),
+ e -> DropwizardMeasurement.sanitize(e.getValue())
+ ));
+
+ return new AutoValue_DropwizardMeasurement(sanitize(name), sanitizedTags, field.map(DropwizardMeasurement::sanitize));
+ }
+
+ /**
+ * Deserializes a measurement line string in the form:
+ * {@code
+ * measurement_name[,tag1=val1,tag2=val2][ field_name]
+ * }
+ */
+ public static DropwizardMeasurement fromLine(final String line) {
+ // split measurement-name/tags and field name
+ final String[] initialParts = line.split(FIELD_SEPARATOR);
+ if (initialParts.length > 2) {
+ throw new IllegalArgumentException("too many spaces in measurement line");
+ }
+
+ // parse optional field
+ final Optional field;
+ if (initialParts.length == 2) {
+ field = Optional.of(initialParts[1]);
+ } else {
+ field = Optional.empty();
+ }
+
+ // split measurement-name and tags
+ final String[] parts = initialParts[0].split(TAG_SEPARATOR);
+ final String name = parts[0];
+
+ // parse tags as a key-val map
+ final Map tags = Arrays.asList(parts)
+ .subList(1, parts.length)
+ .stream()
+ .map(s -> s.split(VALUE_SEPARATOR))
+ .filter(arr -> arr.length == 2)
+ .collect(toMap(arr -> arr[0], arr -> arr[1]));
+
+ // if the sizes aren't one-to-one, we filtered some tag parts out.
+ if (tags.size() != parts.length - 1) {
+ throw new IllegalArgumentException("tags must contain exactly one '=' character");
+ }
+
+ return DropwizardMeasurement.create(name, tags, field);
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder();
+ builder.append(name());
+
+ tags().forEach((k, v) -> {
+ builder.append(TAG_SEPARATOR);
+ builder.append(k);
+ builder.append(VALUE_SEPARATOR);
+ builder.append(v);
+ });
+
+ if (field().isPresent()) {
+ builder.append(FIELD_SEPARATOR);
+ builder.append(field().orElse(""));
+ }
+
+ return builder.toString();
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/DropwizardMeasurementParser.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/DropwizardMeasurementParser.java
new file mode 100644
index 0000000..ca5cd02
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/DropwizardMeasurementParser.java
@@ -0,0 +1,94 @@
+package com.kickstarter.dropwizard.metrics.influxdb.transformer;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableMap;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * A dropwizard measurement parser that holds metric templates for mapping between
+ * directory-style dot notation and influx-style tags. If no template matches a
+ * given metric name, it will attempt to parse it as a {@link DropwizardMeasurement} line.
+ *
+ * This transformer caches mappings from metric names to {@link DropwizardMeasurement}
+ * objects to avoid extraneous regex matching and string parsing.
+ */
+public class DropwizardMeasurementParser {
+ /**
+ * Templates for provided Dropwizard metrics. I'm so sorry for these regexes.
+ */
+ private static final ImmutableMap DROPWIZARD_METRIC_MAPPINGS = ImmutableMap.builder()
+ .put("health", new TaggedPattern(".*\\.health\\.(?.*)"))
+ .put("resources", new TaggedPattern(".*\\.resources?\\.(?[A-Za-z]+)\\.(?[A-Za-z]+)", "resource", "method"))
+ .put("jvm", new TaggedPattern("^jvm$"))
+ .put("jvm_attribute", new TaggedPattern("jvm\\.attribute.*?"))
+ .put("jvm_buffers", new TaggedPattern("jvm\\.buffers\\.?(?.*)", "type"))
+ .put("jvm_classloader", new TaggedPattern("jvm\\.classloader.*"))
+ .put("jvm_gc", new TaggedPattern("jvm\\.gc\\.?(?.*)", "metric"))
+ .put("jvm_memory", new TaggedPattern("jvm\\.memory\\.?(?.*)", "metric"))
+ .put("jvm_threads", new TaggedPattern("jvm\\.threads\\.?(?.*)", "metric"))
+ .put("logging", new TaggedPattern("ch\\.qos\\.logback\\.core\\.Appender\\.(?.*)", "level"))
+ .put("raw_sql", new TaggedPattern("org\\.skife\\.jdbi\\.v2\\.DBI\\.raw-sql"))
+ .put("clients", new TaggedPattern("org\\.apache\\.http\\.client\\.HttpClient\\.(?.*)\\.(?.*)$", "client", "metric"))
+ .put("client_connections", new TaggedPattern("org\\.apache\\.http\\.conn\\.HttpClientConnectionManager\\.(?.*)", "client"))
+ .put("connections", new TaggedPattern("org\\.eclipse\\.jetty\\.server\\.HttpConnectionFactory\\.(?[0-9]+).*", "port"))
+ .put("thread_pools", new TaggedPattern("org\\.eclipse\\.jetty\\.util\\.thread\\.QueuedThreadPool\\.(?.*)", "pool"))
+ .put("http_server", new TaggedPattern("io\\.dropwizard\\.jetty\\.MutableServletContextHandler\\.?(?.*)", "metric"))
+ .put("data_sources", new TaggedPattern("io\\.dropwizard\\.db\\.ManagedPooledDataSource\\.(?.*)", "metric"))
+ .build();
+
+ private final ImmutableMap metricTemplates;
+ private final Map cache = new HashMap<>();
+
+ @VisibleForTesting DropwizardMeasurementParser(final ImmutableMap metricTemplates) {
+ this.metricTemplates = metricTemplates;
+ }
+
+ /**
+ * Returns a new {@link DropwizardMeasurementParser} with default {@link #DROPWIZARD_METRIC_MAPPINGS}
+ * and the user-provided {@code metricTemplates}.
+ */
+ public static DropwizardMeasurementParser withTemplates(final Map metricTemplates) {
+ return new DropwizardMeasurementParser(
+ new ImmutableMap.Builder()
+ .putAll(metricTemplates)
+ .putAll(DROPWIZARD_METRIC_MAPPINGS)
+ .build()
+ );
+ }
+
+ /**
+ * Returns a {@link DropwizardMeasurement} from a matched template, or parses it as a measurement line.
+ *
+ * @throws IllegalArgumentException if the measurement does not match a template and is not in the
+ * measurement line format.
+ *
+ * @see DropwizardMeasurement#fromLine(String)
+ */
+ /*package*/ DropwizardMeasurement parse(final String metricName) {
+ return cache.computeIfAbsent(metricName, __ ->
+ templatedMeasurement(metricName)
+ .orElseGet(() -> DropwizardMeasurement.fromLine(metricName))
+ );
+ }
+
+ /**
+ * Searches the configured templates for a match to {@code metricName}.
+ *
+ * @return an Optional-wrapped {@link DropwizardMeasurement} with any matched tags, and no field value.
+ */
+ private Optional templatedMeasurement(final String metricName) {
+ for (final Map.Entry entry : metricTemplates.entrySet()) {
+ final Optional measurement = entry.getValue().tags(metricName)
+ .map(tags -> DropwizardMeasurement.create(entry.getKey(), tags, Optional.empty()));
+
+ if (measurement.isPresent()) {
+ return measurement;
+ }
+ }
+
+ return Optional.empty();
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/DropwizardTransformer.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/DropwizardTransformer.java
new file mode 100644
index 0000000..50ebd72
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/DropwizardTransformer.java
@@ -0,0 +1,344 @@
+package com.kickstarter.dropwizard.metrics.influxdb.transformer;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.Snapshot;
+import com.codahale.metrics.Timer;
+import com.google.common.annotations.VisibleForTesting;
+import com.kickstarter.dropwizard.metrics.influxdb.InfluxDbMeasurement;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import static java.util.stream.Collectors.toList;
+
+/**
+ * A transformer from Dropwizard metric objects to tagged and grouped {@link InfluxDbMeasurement}s.
+ *
+ * Supports global tags, tagged templating, counter/gauge grouping, and per-metric tagging.
+ */
+public class DropwizardTransformer {
+ private static final Logger log = LoggerFactory.getLogger(DropwizardTransformer.class);
+
+ private final Map baseTags;
+ private final DropwizardMeasurementParser parser;
+ private final boolean groupCounters;
+ private final boolean groupGauges;
+
+ private final long rateFactor;
+ private final long durationFactor;
+
+ public DropwizardTransformer(final Map baseTags,
+ final DropwizardMeasurementParser parser,
+ final boolean groupCounters,
+ final boolean groupGauges,
+ final TimeUnit rateUnit,
+ final TimeUnit durationUnit) {
+ this.baseTags = baseTags;
+ this.parser = parser;
+ this.groupCounters = groupCounters;
+ this.groupGauges = groupGauges;
+ this.rateFactor = rateUnit.toSeconds(1);
+ this.durationFactor = durationUnit.toNanos(1);
+ }
+
+ @VisibleForTesting double convertDuration(final double duration) {
+ return duration / durationFactor;
+ }
+
+ @VisibleForTesting double convertRate(final double rate) {
+ return rate * rateFactor;
+ }
+
+ // ===================================================================================================================
+ // timers
+
+ /**
+ * Build a List of {@link InfluxDbMeasurement}s from a timer map.
+ */
+ public List fromTimers(final Map timers, final long timestamp) {
+ return timers.entrySet().stream()
+ .map(e -> fromTimer(e.getKey(), e.getValue(), timestamp))
+ .collect(toList());
+ }
+
+ /**
+ * Build an {@link InfluxDbMeasurement} from a timer.
+ */
+ @VisibleForTesting InfluxDbMeasurement fromTimer(final String metricName, final Timer t, final long timestamp) {
+ final Snapshot snapshot = t.getSnapshot();
+ final DropwizardMeasurement measurement = parser.parse(metricName);
+
+ final Map tags = new HashMap<>(baseTags);
+ tags.putAll(measurement.tags());
+
+ return new InfluxDbMeasurement.Builder(measurement.name(), timestamp)
+ .putTags(tags)
+ .putField("count", snapshot.size())
+ .putField("min", convertDuration(snapshot.getMin()))
+ .putField("max", convertDuration(snapshot.getMax()))
+ .putField("mean", convertDuration(snapshot.getMean()))
+ .putField("std-dev", convertDuration(snapshot.getStdDev()))
+ .putField("50-percentile", convertDuration(snapshot.getMedian()))
+ .putField("75-percentile", convertDuration(snapshot.get75thPercentile()))
+ .putField("95-percentile", convertDuration(snapshot.get95thPercentile()))
+ .putField("99-percentile", convertDuration(snapshot.get99thPercentile()))
+ .putField("999-percentile", convertDuration(snapshot.get999thPercentile()))
+ .putField("one-minute", convertRate(t.getOneMinuteRate()))
+ .putField("five-minute", convertRate(t.getFiveMinuteRate()))
+ .putField("fifteen-minute", convertRate(t.getFifteenMinuteRate()))
+ .putField("mean-minute", convertRate(t.getMeanRate()))
+ .putField("run-count", t.getCount())
+ .build();
+ }
+
+ // ===================================================================================================================
+ // meters
+
+ /**
+ * Build a List of {@link InfluxDbMeasurement}s from a meter map.
+ */
+ public List fromMeters(final Map meters, final long timestamp) {
+ return meters.entrySet().stream()
+ .map(e -> fromMeter(e.getKey(), e.getValue(), timestamp))
+ .collect(toList());
+ }
+
+ /**
+ * Build an {@link InfluxDbMeasurement} from a meter.
+ */
+ @VisibleForTesting InfluxDbMeasurement fromMeter(final String metricName, final Meter mt, final long timestamp) {
+ final DropwizardMeasurement measurement = parser.parse(metricName);
+
+ final Map tags = new HashMap<>(baseTags);
+ tags.putAll(measurement.tags());
+
+ return new InfluxDbMeasurement.Builder(measurement.name(), timestamp)
+ .putTags(tags)
+ .putField("count", mt.getCount())
+ .putField("one-minute", convertRate(mt.getOneMinuteRate()))
+ .putField("five-minute", convertRate(mt.getFiveMinuteRate()))
+ .putField("fifteen-minute", convertRate(mt.getFifteenMinuteRate()))
+ .putField("mean-minute", convertRate(mt.getMeanRate()))
+ .build();
+ }
+
+ // ===================================================================================================================
+ // histograms
+
+ /**
+ * Build a List of {@link InfluxDbMeasurement}s from a histogram map.
+ */
+ public List fromHistograms(final Map histograms, final long timestamp) {
+ return histograms.entrySet().stream()
+ .map(e -> fromHistogram(e.getKey(), e.getValue(), timestamp))
+ .collect(toList());
+ }
+
+ /**
+ * Build an {@link InfluxDbMeasurement} from a histogram.
+ */
+ @VisibleForTesting InfluxDbMeasurement fromHistogram(final String metricName, final Histogram h, final long timestamp) {
+ final Snapshot snapshot = h.getSnapshot();
+ final DropwizardMeasurement measurement = parser.parse(metricName);
+
+ final Map tags = new HashMap<>(baseTags);
+ tags.putAll(measurement.tags());
+
+ return new InfluxDbMeasurement.Builder(measurement.name(), timestamp)
+ .putTags(tags)
+ .putField("count", snapshot.size())
+ .putField("min", snapshot.getMin())
+ .putField("max", snapshot.getMax())
+ .putField("mean", snapshot.getMean())
+ .putField("std-dev", snapshot.getStdDev())
+ .putField("50-percentile", snapshot.getMedian())
+ .putField("75-percentile", snapshot.get75thPercentile())
+ .putField("95-percentile", snapshot.get95thPercentile())
+ .putField("99-percentile", snapshot.get99thPercentile())
+ .putField("999-percentile", snapshot.get999thPercentile())
+ .putField("run-count", h.getCount())
+ .build();
+ }
+
+ // ===================================================================================================================
+ // groupable measurements: counters and gauges
+
+ /**
+ * Build a List of {@link InfluxDbMeasurement}s from a counter map.
+ */
+ public List fromCounters(final Map counters, final long timestamp) {
+ return fromCounterOrGauge(counters, "count", Counter::getCount, timestamp, groupCounters);
+ }
+
+ /**
+ * Build a List of {@link InfluxDbMeasurement}s from a gauge map.
+ */
+ public List fromGauges(final Map gauges, final long timestamp) {
+ return fromCounterOrGauge(gauges, "value", Gauge::getValue, timestamp, groupGauges);
+ }
+
+ private List fromCounterOrGauge(final Map items,
+ final String defaultFieldName,
+ final Function valueExtractor,
+ final long timestamp,
+ final boolean group) {
+ if (group) {
+ final Map> groupedItems = groupValues(items, defaultFieldName, valueExtractor);
+ return groupedItems.entrySet().stream()
+ .map(entry -> fromValueGroup(entry.getKey(), entry.getValue(), timestamp))
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(toList());
+ } else {
+ return items.entrySet().stream()
+ .map(entry -> fromKeyValue(entry.getKey(), defaultFieldName, valueExtractor.apply(entry.getValue()), timestamp))
+ .collect(toList());
+ }
+ }
+
+ /**
+ * Build an {@link InfluxDbMeasurement} directly from given values.
+ */
+ @VisibleForTesting InfluxDbMeasurement fromKeyValue(final String metricName,
+ final String defaultFieldName,
+ final T value,
+ final long timestamp) {
+ final DropwizardMeasurement measurement = parser.parse(metricName);
+ final Map tags = new HashMap<>(baseTags);
+ tags.putAll(measurement.tags());
+
+ return new InfluxDbMeasurement.Builder(measurement.name(), timestamp)
+ .putTags(tags)
+ .putField(defaultFieldName, value)
+ .build();
+ }
+
+ /**
+ * Groups {@code items} into a set of measurements marked by the measurement name and tag set.
+ *
+ * This method separates measurement names and fields by the following logic:
+
+ *
For {@link DropwizardMeasurement}-style line notation, we parse measurements and fields
+ * directly via {@link DropwizardMeasurement#fromLine(String)}.
+ *
+ *
+ *
For Dropwizard-style dot notation, we read the last part as a field name. If there is
+ * only one part, we use the provided default field name.
+ *
Metrics that end with `.count` will be parsed with the last two parts as a field name.
+ *
+ *
e.g.
+ *
+ *
+ * Full Key |
+ * Parsed Group |
+ * Parsed Field |
+ *
+ *
+ * Measurement,action=restore someField |
+ * Measurement,action=restore |
+ * someField |
+ *
+ *
+ * Measurement,action=restore |
+ * Measurement,action=restore |
+ * defaultFieldName |
+ *
+ *
+ * jvm |
+ * jvm |
+ * defaultFieldName |
+ *
+ *
+ * jvm.threads.deadlock |
+ * jvm.threads |
+ * deadlock |
+ *
+ *
+ * jvm.threads.deadlock.count |
+ * jvm.threads |
+ * deadlock.count |
+ *
+ *
+ *
+ * @return A map from {@link GroupKey GroupKeys} to the group's field map.
+ */
+ @VisibleForTesting Map> groupValues(final Map items,
+ final String defaultFieldName,
+ final Function valueExtractor) {
+ final Map> groupedValues = new HashMap<>();
+
+ items.forEach((key, item) -> {
+ final String measurementKey;
+ final String field;
+
+ if (key.contains(" ") || key.contains(",")) {
+ // Inlined key with tag or field -- formatted as seen in Measurement.toString().
+ measurementKey = key;
+ field = parser.parse(key).field().orElse(defaultFieldName);
+ } else {
+ // Templated key -- formatted in Dropwizard/Graphite-style directory notation.
+
+ // If the key ends in `.count`, we try to include the previous part in the field name.
+ // This is an odd hack to group Dropwizard jvm.threads gauge measurements.
+ // e.g. for 'jvm.threads.deadlock.count': key=jvm.threads, field=deadlock.count.
+ final boolean hasCountPostfix = key.endsWith(".count");
+ final String mainKey = hasCountPostfix ? key.substring(0, key.length() - 6) : key;
+
+ // Parse the field name from the last key part.
+ // e.g. for `jvm.memory.heap`: key=jvm.memory, field=heap.
+ final int lastDotIndex = mainKey.lastIndexOf(".");
+ if (lastDotIndex == -1) {
+ // only one key part; use the default field name.
+ measurementKey = mainKey;
+ field = hasCountPostfix ? "count" : defaultFieldName;
+ } else {
+ // parse the last part as a field name.
+ measurementKey = mainKey.substring(0, lastDotIndex);
+ // use `key` instead of `mainKey` here
+ // to include `.count` in the field name.
+ field = key.substring(lastDotIndex + 1);
+ }
+ }
+
+ final DropwizardMeasurement measurement = parser.parse(measurementKey);
+ final GroupKey groupKey = GroupKey.create(measurement.name(), measurement.tags());
+
+ final Map fields = groupedValues.getOrDefault(groupKey, new HashMap<>());
+ fields.put(field, valueExtractor.apply(item));
+ groupedValues.put(groupKey, fields);
+ });
+
+ return groupedValues;
+ }
+
+ /**
+ * Build an {@link InfluxDbMeasurement} from a group key and field map.
+ */
+ @VisibleForTesting Optional fromValueGroup(final GroupKey groupKey,
+ final Map fields,
+ final long timestamp) {
+ final Map tags = new HashMap<>(baseTags);
+ tags.putAll(groupKey.tags());
+
+ final InfluxDbMeasurement.Builder builder =
+ new InfluxDbMeasurement.Builder(groupKey.measurement(), timestamp)
+ .putTags(tags)
+ .tryPutFields(fields, e -> log.warn(e.getMessage()));
+
+ if (builder.isValid()) {
+ log.warn("Measurement has no valid fields: {}", groupKey.measurement());
+ return Optional.of(builder.build());
+ }
+
+ return Optional.empty();
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/GroupKey.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/GroupKey.java
new file mode 100644
index 0000000..91b537b
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/GroupKey.java
@@ -0,0 +1,16 @@
+package com.kickstarter.dropwizard.metrics.influxdb.transformer;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.Map;
+
+@AutoValue
+abstract class GroupKey {
+ abstract String measurement();
+ abstract Map tags();
+
+ @VisibleForTesting static GroupKey create(final String measurement, final Map tags) {
+ return new AutoValue_GroupKey(measurement, tags);
+ }
+}
diff --git a/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/TaggedPattern.java b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/TaggedPattern.java
new file mode 100644
index 0000000..6942203
--- /dev/null
+++ b/src/main/java/com/kickstarter/dropwizard/metrics/influxdb/transformer/TaggedPattern.java
@@ -0,0 +1,67 @@
+package com.kickstarter.dropwizard.metrics.influxdb.transformer;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.annotations.VisibleForTesting;
+import javax.validation.constraints.NotNull;
+import org.hibernate.validator.constraints.NotBlank;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static java.util.stream.Collectors.toMap;
+
+/**
+ * A wrapper for {@link Pattern regex patterns}, used for extracting tags.
+ */
+public class TaggedPattern {
+ @NotBlank
+ @JsonProperty
+ private String pattern;
+ public String pattern() {
+ return pattern;
+ }
+
+ @NotNull
+ @JsonProperty
+ private List tagKeys;
+ public List tagKeys() {
+ return tagKeys;
+ }
+
+ // for internal use.
+ private final Pattern compiledPattern;
+
+ @JsonCreator
+ public TaggedPattern(final String pattern, final List tagKeys) {
+ this.pattern = pattern;
+ this.tagKeys = tagKeys;
+ this.compiledPattern = Pattern.compile(pattern);
+ }
+
+ @VisibleForTesting TaggedPattern(final String pattern, final String... tagKeys) {
+ this(pattern, Arrays.asList(tagKeys));
+ }
+
+ /**
+ * Matches the {@code input} string and returns a map of matched tags.
+ * The tag map will not contain any empty pattern matcher groups.
+ *
+ * @return An Optional-wrapped mapping of tags, or {@link Optional#empty()}
+ * if the input does not match the pattern.
+ */
+ /*package*/ Optional