diff --git a/build.gradle b/build.gradle index 90c2f04057..4455adbda0 100644 --- a/build.gradle +++ b/build.gradle @@ -225,7 +225,7 @@ subprojects { } if (project.extensions.findByName('bintray')) { - bintray.labels = ['micrometer', 'atlas', 'metrics', 'prometheus', 'spectator', 'influx', 'new-relic', 'signalfx', 'wavefront', 'elastic', 'dynatrace', 'azure-monitor', 'appoptics', 'kairos', 'stackdriver'] + bintray.labels = ['micrometer', 'atlas', 'metrics', 'prometheus', 'spectator', 'influx', 'new-relic', 'signalfx', 'wavefront', 'elastic', 'dynatrace', 'dynatrace2', 'azure-monitor', 'appoptics', 'kairos', 'stackdriver'] bintray.packageName = 'io.micrometer' } } diff --git a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceMeterRegistry.java b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceMeterRegistry.java index 88be9aa225..f2533bab47 100644 --- a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceMeterRegistry.java +++ b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceMeterRegistry.java @@ -42,7 +42,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; /** - * {@link StepMeterRegistry} for Dynatrace. + * {@link StepMeterRegistry} for Dynatrace metric API v1. * * @author Oriol Barcelona * @author Jon Schneider diff --git a/implementations/micrometer-registry-dynatrace2/build.gradle b/implementations/micrometer-registry-dynatrace2/build.gradle new file mode 100644 index 0000000000..584bea256f --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/build.gradle @@ -0,0 +1,8 @@ +dependencies { + api project(':micrometer-core') + + implementation 'org.slf4j:slf4j-api' + + testImplementation project(':micrometer-test') + testImplementation 'com.fasterxml.jackson.core:jackson-databind' +} diff --git a/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/DynatraceConfig.java b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/DynatraceConfig.java new file mode 100644 index 0000000000..e4f3b67ee7 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/DynatraceConfig.java @@ -0,0 +1,80 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import io.micrometer.core.instrument.config.validate.InvalidReason; +import io.micrometer.core.instrument.config.validate.Validated; +import io.micrometer.core.instrument.step.StepRegistryConfig; + +import java.util.function.Function; + +import static io.micrometer.core.instrument.config.MeterRegistryConfigValidator.*; +import static io.micrometer.core.instrument.config.validate.PropertyValidator.*; +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.MAX_METRIC_LINES_PER_REQUEST; + +/** + * Configuration for {@link DynatraceMeterRegistry} + * + * @author Oriol Barcelona + * @author David Mass + */ +public interface DynatraceConfig extends StepRegistryConfig { + + @Override + default String prefix() { + return "dynatrace2"; + } + + default String deviceName() { return getString(this,"deviceName").orElse(""); } + + default String groupName() { return getString(this,"groupName").orElse(""); } + + default String entityId() { return getString(this,"entityId").orElse(""); } + + default String apiToken() { + if (this.uri().contains("localhost")) { + return getSecret(this, "apiToken").orElse(""); + } + return getSecret(this, "apiToken").required().get(); + } + + default String uri() { + return getUrlString(this, "uri").required().get(); + } + + @Override + default int batchSize() { + return getInteger(this, "batchSize").orElse(MAX_METRIC_LINES_PER_REQUEST); + } + + @Override + default Validated validate() { + return checkAll(this, + c -> StepRegistryConfig.validate(c), + check("deviceName", DynatraceConfig::deviceName), + check("groupName", DynatraceConfig::groupName), + check("entityId", DynatraceConfig::entityId), + check("apiToken", DynatraceConfig::apiToken), + checkRequired("uri", DynatraceConfig::uri), + check("batchSize", DynatraceConfig::batchSize) + .andThen(invalidateWhenGreaterThan(MAX_METRIC_LINES_PER_REQUEST)) + ); + } + + default Function, Validated> invalidateWhenGreaterThan(int value) { + return v -> v.invalidateWhen(b -> b > value, "cannot be greater than " + value, InvalidReason.MALFORMED); + } +} diff --git a/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/DynatraceMeterRegistry.java b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/DynatraceMeterRegistry.java new file mode 100644 index 0000000000..e9a9dd2a8c --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/DynatraceMeterRegistry.java @@ -0,0 +1,180 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.config.NamingConvention; +import io.micrometer.core.instrument.step.StepMeterRegistry; +import io.micrometer.core.instrument.util.NamedThreadFactory; +import io.micrometer.core.ipc.http.HttpSender; +import io.micrometer.core.ipc.http.HttpUrlConnectionSender; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * {@link StepMeterRegistry} for Dynatrace metric API v2 + * https://dev-wiki.dynatrace.org/display/MET/MINT+Specification#MINTSpecification-IngestFormat + * + * @author Oriol Barcelona + * @author David Mass + * @see Dynatrace metric ingestion v2 + * @since ? + */ +public class DynatraceMeterRegistry extends StepMeterRegistry { + + private static final ThreadFactory DEFAULT_THREAD_FACTORY = new NamedThreadFactory("dynatrace2-metrics-publisher"); + private final Logger logger = LoggerFactory.getLogger(DynatraceMeterRegistry.class); + private final DynatraceConfig config; + private final HttpSender httpClient; + + @SuppressWarnings("deprecation") + public DynatraceMeterRegistry(DynatraceConfig config, Clock clock) { + this(config, clock, DEFAULT_THREAD_FACTORY, new HttpUrlConnectionSender(config.connectTimeout(), config.readTimeout())); + } + + public DynatraceMeterRegistry(DynatraceConfig config, Clock clock, ThreadFactory threadFactory, HttpSender httpClient) { + super(config, clock); + this.config = config; + this.httpClient = httpClient; + addCommonTags(); + + config().namingConvention(new LineProtocolNamingConvention()); + start(threadFactory); + } + + @Override + protected void publish() { + NamingConvention lineProtocolNamingConvention = config().namingConvention(); + MetricLineFactory metricLineFactory = new MetricLineFactory(clock, lineProtocolNamingConvention); + + Map> metricLines = getMeters() + .stream() + .flatMap(metricLineFactory::toMetricLines) + .collect(Collectors.partitioningBy(this::lineLengthGreaterThanLimit)); + + List metricLinesToSkip = metricLines.get(true); + if (!metricLinesToSkip.isEmpty()) { + logger.warn( + "Dropping {} metric lines because are greater than line protocol max length limit ({}).", + metricLinesToSkip.size(), + LineProtocolIngestionLimits.METRIC_LINE_MAX_LENGTH); + } + + List metricLinesToSend = metricLines.get(false); + new MetricsApiIngestion(httpClient, config) + .sendInBatches(metricLinesToSend); + } + + private boolean lineLengthGreaterThanLimit(String line) { + return line.length() > LineProtocolIngestionLimits.METRIC_LINE_MAX_LENGTH; + } + + private void addCommonTags() { + addCommonTags_entityId(); + addCommonTags_deviceName(); + addCommonTags_groupName(); + } + + private void addCommonTags_groupName() { + if (!config.deviceName().equals("")) { + config().commonTags("group-name", config.groupName()); + } + } + + private void addCommonTags_deviceName() { + if (!config.deviceName().equals("")) { + config().commonTags("device-name", config.deviceName()); + } + } + + private void addCommonTags_entityId() { + String[] cases = {"HOST","PROCESS_GROUP_INSTANCE","PROCESS_GROUP","CUSTOM_DEVICE_GROUP","CUSTOM_DEVICE"}; + if (!config.entityId().equals("")) { + int index; + for (index = 0;index < cases.length; index++) { + if (config.entityId().startsWith(cases[index])) break; + } + switch (index) { + case 0: + config().commonTags("dt.entity.host", config.entityId()); + break; + case 1: + config().commonTags("dt.entity.process_group_instance", config.entityId()); + break; + case 2: + config().commonTags("dt.entity.process_group", config.entityId()); + break; + case 3: + config().commonTags("dt.entity.custom_device_group", config.entityId()); + break; + case 4: + config().commonTags("dt.entity.custom_device", config.entityId()); + break; + default: + logger.debug("Entity ID Not Available"); + } + } + } + + @Override + protected TimeUnit getBaseTimeUnit() { + return TimeUnit.MILLISECONDS; + } + + public static Builder builder(DynatraceConfig config) { + return new Builder(config); + } + + public static class Builder { + private final DynatraceConfig config; + + private Clock clock = Clock.SYSTEM; + private ThreadFactory threadFactory = DEFAULT_THREAD_FACTORY; + private HttpSender httpClient; + + @SuppressWarnings("deprecation") + Builder(DynatraceConfig config) { + this.config = config; + this.httpClient = new HttpUrlConnectionSender(config.connectTimeout(), config.readTimeout()); + } + + public Builder clock(Clock clock) { + this.clock = clock; + return this; + } + + public Builder threadFactory(ThreadFactory threadFactory) { + this.threadFactory = threadFactory; + return this; + } + + public Builder httpClient(HttpSender httpClient) { + this.httpClient = httpClient; + return this; + } + + public DynatraceMeterRegistry build() { + return new DynatraceMeterRegistry(config, clock, threadFactory, httpClient); + } + } +} + diff --git a/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/LineProtocolFormatters.java b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/LineProtocolFormatters.java new file mode 100644 index 0000000000..fbf3982333 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/LineProtocolFormatters.java @@ -0,0 +1,97 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import io.micrometer.core.instrument.Measurement; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.config.NamingConvention; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + + +/** + * Static formatters for line protocol + * Formatters are not responsible of formatting metric keys, dimension keys nor dimension values as + * this is provided by Micrometer core when implementing a corresponding + * {@link io.micrometer.core.instrument.config.NamingConvention} + * + * @author Oriol Barcelona + * @author David Mass + * @see LineProtocolNamingConvention + */ +class LineProtocolFormatters { + + private static final DecimalFormat METRIC_VALUE_FORMAT = new DecimalFormat( + "#.#####", + DecimalFormatSymbols.getInstance(Locale.US)); + + static String formatGaugeMetricLine(NamingConvention namingConvention, Meter meter, Measurement measurement, long timestamp) { + return String.format( + "%s %s %d", + formatMetricAndDimensions(namingConvention.name(formatMetricName(meter, measurement), meter.getId().getType()), meter.getId().getTags(), namingConvention), + formatMetricValue(measurement.getValue()), + timestamp); + } + + static String formatCounterMetricLine(NamingConvention namingConvention, Meter meter, Measurement measurement, long timestamp) { + return String.format( + "%s count,delta=%s %d", + formatMetricAndDimensions(namingConvention.name(formatMetricName(meter, measurement), meter.getId().getType()), meter.getId().getTags(), namingConvention), + formatMetricValue(measurement.getValue()), + timestamp); + } + + static String formatTimerMetricLine(NamingConvention namingConvention, Meter meter, Measurement measurement, long timestamp) { + return String.format( + "%s gauge,%s %d", + formatMetricAndDimensions(namingConvention.name(formatMetricName(meter, measurement), meter.getId().getType()), meter.getId().getTags(), namingConvention), + formatMetricValue(measurement.getValue()), + timestamp); + } + + private static String formatMetricAndDimensions(String metric, List tags, NamingConvention namingConvention) { + if (tags.isEmpty() ) { + return metric; + } + return String.format("%s,%s", metric, formatTags(tags, namingConvention)); + } + + private static String formatMetricName(Meter meter, Measurement measurement) { + String meterName = meter.getId().getName(); + if (Streams.of(meter.measure()).count() == 1) { + return meterName; + } + + return String.format("%s.%s", meterName, measurement.getStatistic().getTagValueRepresentation()); + } + + private static String formatTags(List tags, NamingConvention namingConvention) { + return tags.stream() + .map(tag -> String.format("%s=\"%s\"", namingConvention.tagKey(tag.getKey()), namingConvention.tagValue(tag.getValue()))) + .limit(LineProtocolIngestionLimits.METRIC_LINE_MAX_DIMENSIONS) + .collect(Collectors.joining(",")); + } + + private static String formatMetricValue(double value) { + return METRIC_VALUE_FORMAT.format(value); + } + +} diff --git a/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/LineProtocolIngestionLimits.java b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/LineProtocolIngestionLimits.java new file mode 100644 index 0000000000..dfc263e818 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/LineProtocolIngestionLimits.java @@ -0,0 +1,31 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +/** + * Relevant limits for metric ingestion + * + * @author Oriol Barcelona + * @see metric ingestion limits + */ +class LineProtocolIngestionLimits { + static int METRIC_KEY_MAX_LENGTH = 250; + static int DIMENSION_KEY_MAX_LENGTH = 100; + static int DIMENSION_VALUE_MAX_LENGTH = 250; + static int METRIC_LINE_MAX_DIMENSIONS = 50; + static int METRIC_LINE_MAX_LENGTH = 2000; + static int MAX_METRIC_LINES_PER_REQUEST = 1000; +} diff --git a/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/LineProtocolNamingConvention.java b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/LineProtocolNamingConvention.java new file mode 100644 index 0000000000..2733bef02a --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/LineProtocolNamingConvention.java @@ -0,0 +1,86 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.config.NamingConvention; +import io.micrometer.core.lang.Nullable; + +import java.util.Locale; +import java.util.regex.Pattern; + +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.DIMENSION_KEY_MAX_LENGTH; +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.DIMENSION_VALUE_MAX_LENGTH; +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.METRIC_KEY_MAX_LENGTH; + +/** + * Naming convention for line protocol ingestion into Dynatrace + * @see metric key naming convention + * + * @author Oriol Barcelona + * @author David Mass + */ +public class LineProtocolNamingConvention implements NamingConvention { + + private static final Pattern KEY_CLEANUP_PATTERN = Pattern.compile("[^a-z0-9-_.]"); + private static final Pattern NAME_CLEANUP_PATTERN = Pattern.compile("[^a-z0-9-_.]"); + + private final NamingConvention delegate; + + public LineProtocolNamingConvention(NamingConvention delegate) { + this.delegate = delegate; + } + + public LineProtocolNamingConvention() { + this(NamingConvention.dot); + } + + @Override + public String name(String name, Meter.Type type, @Nullable String baseUnit) { + String conventionName = name.toLowerCase(Locale.US); + String sanitized = NAME_CLEANUP_PATTERN.matcher(conventionName).replaceAll("_"); + try { + return sanitized.substring(0,METRIC_KEY_MAX_LENGTH); + } + catch (IndexOutOfBoundsException e) { + return sanitized; + } + } + + @Override + public String tagKey(String key) { + String conventionKey = key.toLowerCase(Locale.US); + String sanitized = KEY_CLEANUP_PATTERN.matcher(conventionKey).replaceAll("_"); + try { + return sanitized.substring(0, DIMENSION_KEY_MAX_LENGTH); + } + catch (IndexOutOfBoundsException e) { + return sanitized; + } + } + + @Override + public String tagValue(String value) { + String sanitized = value.replace("\\","\\\\"); + sanitized = sanitized.replace("\"", "\\\""); + try { + return sanitized.substring(0, DIMENSION_VALUE_MAX_LENGTH); + } + catch (IndexOutOfBoundsException e) { + return sanitized; + } + } +} diff --git a/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/MetricLineFactory.java b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/MetricLineFactory.java new file mode 100644 index 0000000000..a8af50b128 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/MetricLineFactory.java @@ -0,0 +1,157 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import io.micrometer.core.instrument.*; +import io.micrometer.core.instrument.config.NamingConvention; + +import java.util.stream.Stream; + +/** + * Metric line factory which maps from micrometer domain to expected format in line protocol + * + * @author Oriol Barcelona + * @author David Mass + */ +class MetricLineFactory { + private final Clock clock; + private final NamingConvention namingConvention; + + MetricLineFactory(Clock clock, NamingConvention lineProtocolNamingConvention) { + this.clock = clock; + this.namingConvention = lineProtocolNamingConvention; + } + + /** + * Creates the formatted metric lines for the corresponding meter. A meter will have multiple + * metric lines considering the measurements within. + * + * @param meter to extract the measurements + * @return a stream of formatted metric lines + */ + Stream toMetricLines(Meter meter) { + return meter.match( + this::toGaugeLine, + this::toCounterLine, + this::toTimerLine, + this::toDistributionSummaryLine, + this::toLongTaskTimerLine, + this::toTimeGaugeLine, + this::toFunctionCounterLine, + this::toFunctionTimerLine, + this::toMeterLine + ); + } + + private Stream toGaugeLine(Gauge meter) { + long wallTime = clock.wallTime(); + + return Streams.of(meter.measure()) + .map(measurement -> LineProtocolFormatters.formatGaugeMetricLine( + namingConvention, + meter, + measurement, + wallTime)); + } + + private Stream toCounterLine(Counter meter) { + long wallTime = clock.wallTime(); + + return Streams.of(meter.measure()) + .map(measurement -> LineProtocolFormatters.formatCounterMetricLine( + namingConvention, + meter, + measurement, + wallTime)); + } + + private Stream toTimerLine(Timer meter) { + long wallTime = clock.wallTime(); + + return Streams.of(meter.measure()) + .map(measurement -> LineProtocolFormatters.formatTimerMetricLine( + namingConvention, + meter, + measurement, + wallTime)); + } + + private Stream toDistributionSummaryLine(DistributionSummary meter) { + long wallTime = clock.wallTime(); + + return Streams.of(meter.measure()) + .map(measurement -> LineProtocolFormatters.formatGaugeMetricLine( + namingConvention, + meter, + measurement, + wallTime)); + } + + private Stream toLongTaskTimerLine(LongTaskTimer meter) { + long wallTime = clock.wallTime(); + + return Streams.of(meter.measure()) + .map(measurement -> LineProtocolFormatters.formatTimerMetricLine( + namingConvention, + meter, + measurement, + wallTime)); + } + + private Stream toTimeGaugeLine(TimeGauge meter) { + long wallTime = clock.wallTime(); + + return Streams.of(meter.measure()) + .map(measurement -> LineProtocolFormatters.formatGaugeMetricLine( + namingConvention, + meter, + measurement, + wallTime)); + } + + private Stream toFunctionCounterLine(FunctionCounter meter) { + long wallTime = clock.wallTime(); + + return Streams.of(meter.measure()) + .map(measurement -> LineProtocolFormatters.formatCounterMetricLine( + namingConvention, + meter, + measurement, + wallTime)); + } + + private Stream toFunctionTimerLine(FunctionTimer meter) { + long wallTime = clock.wallTime(); + + return Streams.of(meter.measure()) + .map(measurement -> LineProtocolFormatters.formatTimerMetricLine( + namingConvention, + meter, + measurement, + wallTime)); + } + + private Stream toMeterLine(Meter meter) { + long wallTime = clock.wallTime(); + + return Streams.of(meter.measure()) + .map(measurement -> LineProtocolFormatters.formatGaugeMetricLine( + namingConvention, + meter, + measurement, + wallTime)); + } +} diff --git a/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/MetricsApiIngestion.java b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/MetricsApiIngestion.java new file mode 100644 index 0000000000..1f89661381 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/MetricsApiIngestion.java @@ -0,0 +1,68 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import io.micrometer.core.instrument.util.AbstractPartition; +import io.micrometer.core.ipc.http.HttpSender; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.stream.Collectors; + +class MetricsApiIngestion { + public static final String METRICS_INGESTION_URL = "/api/v2/metrics/ingest"; + + private final Logger logger = LoggerFactory.getLogger(MetricsApiIngestion.class); + private final HttpSender httpSender; + private final DynatraceConfig config; + + MetricsApiIngestion(HttpSender httpSender, DynatraceConfig config) { + this.httpSender = httpSender; + this.config = config; + } + + void sendInBatches(List metricLines) { + MetricLinePartition.partition(metricLines, config.batchSize()) + .forEach(this::send); + } + + private void send(List metricLines) { + try { + String body = metricLines.stream().collect(Collectors.joining(System.lineSeparator())); + + httpSender.post(config.uri() + METRICS_INGESTION_URL) + .withHeader("Authorization", "Api-Token " + config.apiToken()) + .withPlainText(body) + .send() + .onSuccess((r) -> logger.debug("Ingested {} metric lines into Dynatrace", metricLines.size())) + .onError((r) -> logger.error("Failed metric ingestion. code={} body={}", r.code(), r.body())); + } catch (Throwable throwable) { + logger.error("Failed metric ingestion", throwable); + } + } + + static class MetricLinePartition extends AbstractPartition { + + MetricLinePartition(List list, int partitionSize) { + super(list, partitionSize); + } + + static List> partition(List list, int partitionSize) { + return new MetricLinePartition(list, partitionSize); + } + } +} diff --git a/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/Streams.java b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/Streams.java new file mode 100644 index 0000000000..647e780914 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/Streams.java @@ -0,0 +1,25 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +class Streams { + static Stream of(Iterable iterable) { + return StreamSupport.stream(iterable.spliterator(), false); + } +} diff --git a/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/package-info.java b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/package-info.java new file mode 100644 index 0000000000..29472a9d27 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/main/java/io/micrometer/dynatrace2/package-info.java @@ -0,0 +1,21 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +@NonNullFields +package io.micrometer.dynatrace2; + +import io.micrometer.core.lang.NonNullApi; +import io.micrometer.core.lang.NonNullFields; diff --git a/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/DynatraceConfigTest.java b/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/DynatraceConfigTest.java new file mode 100644 index 0000000000..eed11a1335 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/DynatraceConfigTest.java @@ -0,0 +1,77 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import io.micrometer.core.instrument.config.validate.Validated; +import org.assertj.core.api.WithAssertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.MAX_METRIC_LINES_PER_REQUEST; + +class DynatraceConfigTest implements WithAssertions { + private final Map props = new HashMap<>(); + private final DynatraceConfig config = props::get; + + @Test + void shouldBeValid_whenAllRequiredPropsAreSet() { + props.put("dynatrace2.apiToken", "secret"); + props.put("dynatrace2.uri", "https://uri.dynatrace.com"); + props.put("dynatrace2.deviceName", "test-device"); + + assertThat(config.validate().isValid()).isTrue(); + } + + @Test + void shouldBeInvalid_whenAllRequiredPropsAreMissing() { + List> failures = config.validate().failures(); + + assertThat(failures) + .extracting(Validated.Invalid::getMessage) + .containsOnly("is required"); + } + + @Test + void shouldBeInvalid_whenBatchSizeIsBiggerThanMaxMetricLinesLimit() { + props.put("dynatrace2.batchSize", String.valueOf(MAX_METRIC_LINES_PER_REQUEST + 1)); + + List> failures = config.validate().failures(); + + assertThat(failures) + .extracting(Validated.Invalid::getProperty) + .containsOnlyOnce("dynatrace2.batchSize"); + } + + @Test + void shouldBeEmptyString_whenOptionalPropsAreNotSet() { + props.put("dynatrace2.apiToken", "secret"); + props.put("dynatrace2.uri", "https://uri.dynatrace.com"); + + assertThat(config.deviceName()).matches(""); + assertThat(config.entityId()).matches(""); + assertThat(config.groupName()).matches(""); + } + + @Test + void shouldBeApiTokenEmptyString_whenURLIncludesLocalhost() { + props.put("dynatrace2.uri", "http://localhost:14499/metrics/ingest"); + + assertThat(config.apiToken()).matches(""); + } +} diff --git a/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/DynatraceMeterRegistryTest.java b/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/DynatraceMeterRegistryTest.java new file mode 100644 index 0000000000..28c2524dc1 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/DynatraceMeterRegistryTest.java @@ -0,0 +1,252 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.matching.StringValuePattern; +import com.github.tomakehurst.wiremock.matching.UrlPattern; +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.MockClock; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.config.validate.ValidationException; +import org.assertj.core.api.WithAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import ru.lanwen.wiremock.ext.WiremockResolver; +import ru.lanwen.wiremock.ext.WiremockResolver.Wiremock; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.DIMENSION_KEY_MAX_LENGTH; +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.DIMENSION_VALUE_MAX_LENGTH; +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.METRIC_KEY_MAX_LENGTH; +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.METRIC_LINE_MAX_LENGTH; + +/** + * Tests for {@link DynatraceMeterRegistry}. + * + * @author Oriol Barcelona + * @author David Mass + */ +@ExtendWith(WiremockResolver.class) +class DynatraceMeterRegistryTest implements WithAssertions { + + private static final String API_TOKEN = "DT-API-TOKEN"; + private static final UrlPattern METRICS_INGESTION_URL = urlEqualTo(MetricsApiIngestion.METRICS_INGESTION_URL); + private static final StringValuePattern TEXT_PLAIN_CONTENT_TYPE = equalTo("text/plain"); + + WireMockServer dtApiServer; + Clock clock; + DynatraceMeterRegistry meterRegistry; + + @BeforeEach + void setupServerAndConfig(@Wiremock WireMockServer server) { + this.dtApiServer = server; + DynatraceConfig config = new DynatraceConfig() { + @Override + public String get(String key) { + return null; + } + + @Override + public String apiToken() { + return API_TOKEN; + } + + @Override + public String uri() { + return server.baseUrl(); + } + + @Override + public String entityId() { return "HOST-06F288EE2A930951"; } + }; + clock = new MockClock(); + meterRegistry = DynatraceMeterRegistry.builder(config) + .clock(clock) + .build(); + dtApiServer.stubFor(post(METRICS_INGESTION_URL) + .willReturn(aResponse().withStatus(202))); + } + + @Test + void shouldThrowValidationException_whenUriIsMissingInConfig() { + DynatraceConfig config = new DynatraceConfig() { + @Override + public String get(String key) { + return null; + } + + @Override + public String apiToken() { + return "apiToken"; + } + }; + + assertThatThrownBy(() -> DynatraceMeterRegistry.builder(config).build()) + .isExactlyInstanceOf(ValidationException.class); + } + + @Test + void shouldThrowValidationException_whenApiTokenIsMissingInConfig() { + DynatraceConfig config = new DynatraceConfig() { + @Override + public String get(String key) { + return null; + } + + @Override + public String uri() { + return "uri"; + } + }; + + assertThatThrownBy(() -> DynatraceMeterRegistry.builder(config).build()) + .isExactlyInstanceOf(ValidationException.class); + } + + @Test + void shouldIngestAMetricThroughTheApi() { + meterRegistry.gauge("cpu.temperature", 55); + + meterRegistry.publish(); + + dtApiServer.verify(postRequestedFor(METRICS_INGESTION_URL) + .withHeader("Content-Type", TEXT_PLAIN_CONTENT_TYPE) + .withRequestBody(equalToMetricLines("cpu.temperature,dt.entity.host=\"HOST-06F288EE2A930951\" 55")) + ); + } + + @Test + void shouldIngestAMetricThroughTheApi_whenHasDimensions() { + meterRegistry.gauge( + "cpu.temperature", + Tags.of("hostname", "server01", "cpu", "1"), + 55); + + meterRegistry.publish(); + + dtApiServer.verify(postRequestedFor(METRICS_INGESTION_URL) + .withHeader("Content-Type", TEXT_PLAIN_CONTENT_TYPE) + .withRequestBody(equalToMetricLines("cpu.temperature,cpu=\"1\",dt.entity.host=\"HOST-06F288EE2A930951\",hostname=\"server01\" 55")) + ); + } + + @Test + void shouldIngestMultipleMetricsThroughTheApi_whenSameMetricButDifferentDimensions() { + meterRegistry.gauge( + "cpu.temperature", + Tags.of("hostname", "server01", "cpu", "1"), + 55); + meterRegistry.gauge( + "cpu.temperature", + Tags.of("hostname", "server01", "cpu", "2"), + 50); + + + meterRegistry.publish(); + + dtApiServer.verify(postRequestedFor(METRICS_INGESTION_URL) + .withHeader("Content-Type", TEXT_PLAIN_CONTENT_TYPE) + .withRequestBody(equalToMetricLines( + "cpu.temperature,cpu=\"1\",dt.entity.host=\"HOST-06F288EE2A930951\",hostname=\"server01\" 55", + "cpu.temperature,cpu=\"2\",dt.entity.host=\"HOST-06F288EE2A930951\",hostname=\"server01\" 50" + )) + ); + } + + + @Test + void shouldMetricNameBeSanitized_whenSpecialChars() { + meterRegistry.gauge( + "cpu#temperature", + Tags.of("hostname", "server01", "cpu", "1"), + 55); + + meterRegistry.publish(); + + dtApiServer.verify(postRequestedFor(METRICS_INGESTION_URL) + .withHeader("Content-Type", TEXT_PLAIN_CONTENT_TYPE) + .withRequestBody(equalToMetricLines("cpu_temperature,cpu=\"1\",dt.entity.host=\"HOST-06F288EE2A930951\",hostname=\"server01\" 55")) + ); + } + + + @Test + void shouldAddCommonTag_whenEntityIdPropIsAdded() { + meterRegistry.gauge( + "cpu_temperature", 55); + + meterRegistry.publish(); + + dtApiServer.verify(postRequestedFor(METRICS_INGESTION_URL) + .withHeader("Content-Type", TEXT_PLAIN_CONTENT_TYPE) + .withRequestBody(equalToMetricLines("cpu_temperature,dt.entity.host=\"HOST-06F288EE2A930951\" 55")) + ); + } + + @Test + void shouldSkipMetricLines_whenAreBiggerThanMaxLengthLimit() { + String metricName = newString(METRIC_KEY_MAX_LENGTH); + long metricValue = 55; + int lengthForTags = METRIC_LINE_MAX_LENGTH - metricName.length() - String.valueOf(metricValue).length(); + int maxSizeTagLength = DIMENSION_KEY_MAX_LENGTH + DIMENSION_VALUE_MAX_LENGTH; + int numberOfTags = (lengthForTags / maxSizeTagLength) + 1; + + List tags = IntStream.range(0, numberOfTags) + .mapToObj(String::valueOf) + .map(this::maxSizeTag) + .collect(Collectors.toList()); + + meterRegistry.gauge(metricName, tags, metricValue); + + meterRegistry.publish(); + + dtApiServer.verify(0, anyRequestedFor(METRICS_INGESTION_URL)); + } + + private Tag maxSizeTag(String prefix) { + return Tag.of( + prefix + newString(DIMENSION_KEY_MAX_LENGTH - prefix.length()), + newString(DIMENSION_VALUE_MAX_LENGTH)); + } + + private String newString(int length) { + return new String(new char[length]); + } + + private StringValuePattern equalToMetricLines(String... lines) { + return equalToMetricLines(clock.wallTime(), lines); + } + + private StringValuePattern equalToMetricLines(long time, String... lines) { + return equalTo( + Stream.of(lines) + .map(line -> line + " " + time) + .collect(Collectors.joining(System.lineSeparator()))); + } +} diff --git a/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/LineProtocolFormattersTest.java b/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/LineProtocolFormattersTest.java new file mode 100644 index 0000000000..3ae088977d --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/LineProtocolFormattersTest.java @@ -0,0 +1,189 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import io.micrometer.core.instrument.*; +import io.micrometer.core.instrument.config.NamingConvention; +import org.assertj.core.api.WithAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static io.micrometer.dynatrace2.LineProtocolFormatters.formatCounterMetricLine; +import static io.micrometer.dynatrace2.LineProtocolFormatters.formatGaugeMetricLine; +import static io.micrometer.dynatrace2.LineProtocolFormatters.formatTimerMetricLine; + + +class LineProtocolFormattersTest implements WithAssertions { + + DynatraceMeterRegistry meterRegistry; + Clock clock; + NamingConvention namingConvention = new LineProtocolNamingConvention(); + + @BeforeEach + void setUpConfigAndMeterRegistry() { + DynatraceConfig config = new DynatraceConfig() { + @Override + public String get(String key) { + return null; + } + + @Override + public String apiToken() { + return "API_TOKEN"; + } + + @Override + public String uri() { + return "https://dynatrace.com"; + } + + @Override + public String entityId() { return ""; } + }; + clock = new MockClock(); + meterRegistry = DynatraceMeterRegistry.builder(config) + .clock(clock) + .build(); + } + + @Test + void shouldCreateAGaugeMetricLine_whenDimensionsAreEmpty() { + meterRegistry.gauge("my.metric", 55); + + List metricLines = new ArrayList<>(); + + for (Meter meter : meterRegistry.getMeters()) { + for (Measurement measurement : meter.measure()) { + String metricLine = formatGaugeMetricLine(namingConvention, meter, measurement, 12345); + metricLines.add(metricLine); + } + } + + assertThat(metricLines).contains("my.metric 55 12345"); + } + + @Test + void shouldCreateAGaugeMetricLine_whenMultipleDimensions() { + meterRegistry.gauge("my.metric",Tags.of("country", "es", "city", "bcn"), 3.33); + + List metricLines = new ArrayList<>(); + + for (Meter meter : meterRegistry.getMeters()) { + for (Measurement measurement : meter.measure()) { + String metricLine = formatGaugeMetricLine(namingConvention, meter, measurement, 12345); + metricLines.add(metricLine); + } + } + + assertThat(metricLines).contains("my.metric,city=\"bcn\",country=\"es\" 3.33 12345"); + } + + @Test + void shouldCreateACounterMetricLine_whenDimensionsAreEmpty() { + Counter counterMeter = meterRegistry.counter("my.metric"); + + List metricLines = new ArrayList<>(); + + for (Measurement measurement : counterMeter.measure()) { + String metricLine = formatCounterMetricLine(namingConvention, counterMeter, measurement, 12345); + metricLines.add(metricLine); + } + assertThat(metricLines).contains("my.metric count,delta=0 12345"); + + } + + @Test + void shouldCreateACounterMetricLine_whenMultipleDimensions() { + Counter counterMeter = Counter.builder("my.metric").tag("country", "es").tag("city", "bcn").register(meterRegistry); + + List metricLines = new ArrayList<>(); + + for (Measurement measurement : counterMeter.measure()) { + String metricLine = formatCounterMetricLine(namingConvention, counterMeter, measurement, 12345); + metricLines.add(metricLine); + } + + assertThat(metricLines).contains("my.metric,city=\"bcn\",country=\"es\" count,delta=0 12345"); + } + + @Test + void shouldCreateATimerMetricLine_whenDimensionsAreEmpty() { + meterRegistry.timer("my.metric"); + + List metricLines = new ArrayList<>(); + + for (Meter meter : meterRegistry.getMeters()) { + for (Measurement measurement : meter.measure()) { + String metricLine = formatTimerMetricLine(namingConvention, meter, measurement, 12345); + metricLines.add(metricLine); + } + } + + assertThat(metricLines).contains("my.metric.count gauge,0 12345"); + } + + @Test + void shouldCreateATimerMetricLine_whenMultipleDimensions() { + meterRegistry.timer("my.metric", Tags.of("country", "es", "city", "bcn")); + + List metricLines = new ArrayList<>(); + + for (Meter meter : meterRegistry.getMeters()) { + for (Measurement measurement : meter.measure()) { + String metricLine = formatTimerMetricLine(namingConvention, meter, measurement, 12345); + metricLines.add(metricLine); + } + } + + assertThat(metricLines).contains("my.metric.count,city=\"bcn\",country=\"es\" gauge,0 12345"); + } + + + @Test + void shouldSucceed_whenTagKeyContainsSpecialChar() { + meterRegistry.timer("my.metric", Tags.of("country#lang", "es", "city", "bcn")); + + List metricLines = new ArrayList<>(); + + for (Meter meter : meterRegistry.getMeters()) { + for (Measurement measurement : meter.measure()) { + String metricLine = formatTimerMetricLine(namingConvention, meter, measurement, 12345); + metricLines.add(metricLine); + } + } + + assertThat(metricLines).contains("my.metric.count,city=\"bcn\",country_lang=\"es\" gauge,0 12345"); + } + + @Test + void shouldSucceed_whenMeticNameContainsSpecialChar() { + meterRegistry.timer("My#Metric"); + + List metricLines = new ArrayList<>(); + + for (Meter meter : meterRegistry.getMeters()) { + for (Measurement measurement : meter.measure()) { + String metricLine = formatTimerMetricLine(namingConvention, meter, measurement, 12345); + metricLines.add(metricLine); + } + } + + assertThat(metricLines).contains("my_metric.count gauge,0 12345"); + } +} diff --git a/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/LineProtocolNamingConventionTest.java b/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/LineProtocolNamingConventionTest.java new file mode 100644 index 0000000000..c0ee7b138d --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/LineProtocolNamingConventionTest.java @@ -0,0 +1,84 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.config.NamingConvention; +import org.assertj.core.api.WithAssertions; +import org.junit.jupiter.api.Test; + +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.DIMENSION_KEY_MAX_LENGTH; +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.DIMENSION_VALUE_MAX_LENGTH; +import static io.micrometer.dynatrace2.LineProtocolIngestionLimits.METRIC_KEY_MAX_LENGTH; + +class LineProtocolNamingConventionTest implements WithAssertions { + + NamingConvention namingConvention = new LineProtocolNamingConvention(); + + @Test + void shouldMetricKeyBeTrimmed_whenIsGreaterThanMaxLength() { + String name = stringOfSize(METRIC_KEY_MAX_LENGTH + 1); + + String metricKey = namingConvention.name(name, Meter.Type.GAUGE); + + assertThat(metricKey).hasSize(METRIC_KEY_MAX_LENGTH); + } + + @Test + void shouldMetricKeyBeSanitized_whenItContainsSpecialChars() { + String name = "my,metric"; + + String metricKey = namingConvention.name(name, Meter.Type.GAUGE); + + assertThat(metricKey).matches("my_metric"); + } + + @Test + void shouldDimensionNameBeTrimmed_whenIsGreaterThanMaxLength() { + String key = stringOfSize(DIMENSION_KEY_MAX_LENGTH + 1); + + String dimensionName = namingConvention.tagKey(key); + + assertThat(dimensionName).hasSize(DIMENSION_KEY_MAX_LENGTH); + } + + @Test + void shouldDimensionNameBeSanitized_whenItContainsSpecialChars() { + String key = "country#lang"; + + String dimensionName = namingConvention.tagKey(key); + + assertThat(dimensionName).matches("country_lang"); + } + + @Test + void shouldDimensionValueBeTrimmed_whenIsGreaterThanMaxLength() { + String value = stringOfSize(DIMENSION_VALUE_MAX_LENGTH + 1); + + String dimensionValue = namingConvention.tagValue(value); + + assertThat(dimensionValue).hasSize(DIMENSION_VALUE_MAX_LENGTH); + } + + private String stringOfSize(int size) { + return IntStream.range(0, size) + .mapToObj(doesnotmatter -> "a") + .collect(Collectors.joining()); + } +} diff --git a/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/MetricsApiIngestionTest.java b/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/MetricsApiIngestionTest.java new file mode 100644 index 0000000000..d5dc5504b9 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace2/src/test/java/io/micrometer/dynatrace2/MetricsApiIngestionTest.java @@ -0,0 +1,127 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.dynatrace2; + +import io.micrometer.core.ipc.http.HttpSender; +import io.micrometer.core.ipc.http.HttpSender.Method; +import io.micrometer.core.ipc.http.HttpSender.Request; +import org.assertj.core.api.WithAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import wiremock.com.google.common.collect.ImmutableMap; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static io.micrometer.dynatrace2.MetricsApiIngestion.METRICS_INGESTION_URL; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class MetricsApiIngestionTest implements WithAssertions { + + HttpSender httpSender; + DynatraceConfig config; + MetricsApiIngestion metricsApiIngestion; + + @BeforeEach + void setUp() throws Throwable { + httpSender = spy(HttpSender.class); + when(httpSender.send(any())).thenReturn(new HttpSender.Response(202, null)); + + config = mock(DynatraceConfig.class); + when(config.batchSize()).thenReturn(2); + when(config.uri()).thenReturn("https://micrometer.dynatrace.com"); + when(config.apiToken()).thenReturn("my-token"); + + metricsApiIngestion = new MetricsApiIngestion(httpSender, config); + } + + @Test + void shouldNotSendRequests_whenNoMetricLines() { + List metricLines = emptyList(); + + metricsApiIngestion.sendInBatches(metricLines); + + verifyNoInteractions(httpSender); + } + + @Test + void shouldSendOneRequest_whenLessOrEqualToBatchSize() { + List metricLines = asList("first", "second"); + + metricsApiIngestion.sendInBatches(metricLines); + + verify(httpSender).post(any()); + } + + @Test + void shouldSendMultipleRequests_whenGreaterThanBatchSize() { + List metricLines = IntStream.range(0, 20) + .mapToObj(String::valueOf) + .collect(Collectors.toList()); + + metricsApiIngestion.sendInBatches(metricLines); + + verify(httpSender, times(10)).post(any()); + } + + @Test + void shouldFulfillApiSpec() throws Throwable { + List metricLines = asList("first", "second"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); + + metricsApiIngestion.sendInBatches(metricLines); + verify(httpSender).send(requestCaptor.capture()); + + assertThat(requestCaptor.getValue()) + .extracting( + Request::getMethod, + r -> r.getUrl().toString(), + Request::getRequestHeaders, + r -> new String(r.getEntity(), StandardCharsets.UTF_8)) + .contains( + Method.POST, + config.uri() + METRICS_INGESTION_URL, + ImmutableMap.of( + "Authorization", "Api-Token " + config.apiToken(), + "Content-Type", "text/plain"), + "first" + System.lineSeparator() + "second"); + } + + @Test + void shouldKeepSendingTheRequests_whenOneHttpRequestThrowsException() throws Throwable { + when(httpSender.send(any())) + .thenThrow(new IllegalArgumentException()) + .thenReturn(new HttpSender.Response(202, null)); + + List metricLines = asList("first", "second", "third", "fourth"); + + metricsApiIngestion.sendInBatches(metricLines); + + verify(httpSender, times(2)).post(any()); + } +} diff --git a/samples/micrometer-samples-boot2/build.gradle b/samples/micrometer-samples-boot2/build.gradle index f7c7729f78..f45676f5a7 100644 --- a/samples/micrometer-samples-boot2/build.gradle +++ b/samples/micrometer-samples-boot2/build.gradle @@ -7,7 +7,7 @@ apply plugin: 'io.spring.dependency-management' dependencies { implementation project(":micrometer-core") - ['atlas', 'azure-monitor', 'prometheus', 'datadog', 'elastic', 'ganglia', 'graphite', 'health', 'jmx', 'influx', 'statsd', 'new-relic', 'cloudwatch', 'cloudwatch2', 'signalfx', 'wavefront', 'elastic', 'dynatrace', 'humio', 'appoptics', 'stackdriver'].each { sys -> + ['atlas', 'azure-monitor', 'prometheus', 'datadog', 'elastic', 'ganglia', 'graphite', 'health', 'jmx', 'influx', 'statsd', 'new-relic', 'cloudwatch', 'cloudwatch2', 'signalfx', 'wavefront', 'elastic', 'dynatrace', 'dynatrace2', 'humio', 'appoptics', 'stackdriver'].each { sys -> implementation project(":micrometer-registry-$sys") } diff --git a/samples/micrometer-samples-boot2/src/main/java/io/micrometer/boot2/samples/Dynatrace2Sample.java b/samples/micrometer-samples-boot2/src/main/java/io/micrometer/boot2/samples/Dynatrace2Sample.java new file mode 100644 index 0000000000..b6c0c8b9ea --- /dev/null +++ b/samples/micrometer-samples-boot2/src/main/java/io/micrometer/boot2/samples/Dynatrace2Sample.java @@ -0,0 +1,29 @@ +/** + * Copyright 2020 VMware, Inc. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.boot2.samples; + +import io.micrometer.boot2.samples.components.PersonController; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication(scanBasePackageClasses = PersonController.class) +@EnableScheduling +public class Dynatrace2Sample { + public static void main(String[] args) { + new SpringApplicationBuilder(Dynatrace2Sample.class).profiles("dynatrace2").run(args); + } +} diff --git a/samples/micrometer-samples-boot2/src/main/resources/application-dynatrace2.yml b/samples/micrometer-samples-boot2/src/main/resources/application-dynatrace2.yml new file mode 100644 index 0000000000..552a29bb35 --- /dev/null +++ b/samples/micrometer-samples-boot2/src/main/resources/application-dynatrace2.yml @@ -0,0 +1,20 @@ +# +# Copyright 2020 VMware, Inc. +#

+# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +#

+# https://www.apache.org/licenses/LICENSE-2.0 +#

+# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +management.metrics.export.dynatrace2: + enabled: true + apiToken: my-api-token + uri: https://my-environment-id.live.dynatrace.com diff --git a/samples/micrometer-samples-boot2/src/main/resources/application.yml b/samples/micrometer-samples-boot2/src/main/resources/application.yml index 8b0a9e3a88..03b951080e 100644 --- a/samples/micrometer-samples-boot2/src/main/resources/application.yml +++ b/samples/micrometer-samples-boot2/src/main/resources/application.yml @@ -27,6 +27,7 @@ management: azuremonitor.enabled: false datadog.enabled: false dynatrace.enabled: false + dynatrace2.enabled: false elastic.enabled: false ganglia.enabled: false graphite.enabled: false diff --git a/samples/micrometer-samples-core/build.gradle b/samples/micrometer-samples-core/build.gradle index be12415917..93bd4751ae 100644 --- a/samples/micrometer-samples-core/build.gradle +++ b/samples/micrometer-samples-core/build.gradle @@ -8,7 +8,7 @@ dependencies { implementation('ch.qos.logback:logback-classic') implementation('org.slf4j:slf4j-api') - ['atlas', 'prometheus', 'datadog', 'ganglia', 'elastic', 'graphite', 'jmx', 'influx', 'statsd', 'new-relic', 'cloudwatch', 'cloudwatch2', 'signalfx', 'wavefront', 'dynatrace', 'azure-monitor', 'humio', 'appoptics', 'kairos', 'stackdriver'].each { sys -> + ['atlas', 'prometheus', 'datadog', 'ganglia', 'elastic', 'graphite', 'jmx', 'influx', 'statsd', 'new-relic', 'cloudwatch', 'cloudwatch2', 'signalfx', 'wavefront', 'dynatrace', 'dynatrace2', 'azure-monitor', 'humio', 'appoptics', 'kairos', 'stackdriver'].each { sys -> implementation project(":micrometer-registry-$sys") } diff --git a/samples/micrometer-samples-core/src/main/java/io/micrometer/core/samples/utils/SampleRegistries.java b/samples/micrometer-samples-core/src/main/java/io/micrometer/core/samples/utils/SampleRegistries.java index 95ad4dd18d..775b5d32c1 100644 --- a/samples/micrometer-samples-core/src/main/java/io/micrometer/core/samples/utils/SampleRegistries.java +++ b/samples/micrometer-samples-core/src/main/java/io/micrometer/core/samples/utils/SampleRegistries.java @@ -32,8 +32,6 @@ import io.micrometer.core.lang.Nullable; import io.micrometer.datadog.DatadogConfig; import io.micrometer.datadog.DatadogMeterRegistry; -import io.micrometer.dynatrace.DynatraceConfig; -import io.micrometer.dynatrace.DynatraceMeterRegistry; import io.micrometer.elastic.ElasticConfig; import io.micrometer.elastic.ElasticMeterRegistry; import io.micrometer.ganglia.GangliaConfig; @@ -424,8 +422,8 @@ public Duration step() { }, Clock.SYSTEM); } - public static DynatraceMeterRegistry dynatrace(String apiToken, String uri) { - return new DynatraceMeterRegistry(new DynatraceConfig() { + public static io.micrometer.dynatrace.DynatraceMeterRegistry dynatrace(String apiToken, String uri) { + return new io.micrometer.dynatrace.DynatraceMeterRegistry(new io.micrometer.dynatrace.DynatraceConfig() { @Override public String get(String key) { return null; @@ -453,6 +451,45 @@ public Duration step() { }, Clock.SYSTEM); } + public static io.micrometer.dynatrace2.DynatraceMeterRegistry dynatrace2(String apiToken, String uri, String entityId, String deviceName, String groupName) { + return io.micrometer.dynatrace2.DynatraceMeterRegistry.builder(new io.micrometer.dynatrace2.DynatraceConfig() { + @Override + public String get(String key) { + return null; + } + + @Override + public String apiToken() { + return apiToken; + } + + @Override + public String uri() { + return uri; + } + + @Override + public String entityId() { + return entityId; + } + + @Override + public String deviceName() { + return deviceName; + } + + @Override + public String groupName() { + return groupName; + } + + @Override + public Duration step() { + return Duration.ofSeconds(5); + } + }).build(); + } + public static HumioMeterRegistry humio(String apiToken) { return new HumioMeterRegistry(new HumioConfig() { @Override diff --git a/scripts/sync-to-maven-central.sh b/scripts/sync-to-maven-central.sh index 2b626a3ada..70e3d34295 100755 --- a/scripts/sync-to-maven-central.sh +++ b/scripts/sync-to-maven-central.sh @@ -30,6 +30,7 @@ MODULES=( micrometer-registry-elastic micrometer-registry-kairos micrometer-registry-dynatrace + micrometer-registry-dynatrace2 micrometer-registry-humio micrometer-registry-azure-monitor micrometer-registry-appoptics diff --git a/settings.gradle b/settings.gradle index 295c448c6f..71769270ae 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,7 +10,7 @@ include 'micrometer-jersey2' include 'micrometer-test' -['atlas', 'prometheus', 'datadog', 'elastic', 'ganglia', 'graphite', 'health', 'jmx', 'influx', 'statsd', 'new-relic', 'cloudwatch', 'cloudwatch2', 'signalfx', 'wavefront', 'dynatrace', 'azure-monitor', 'humio', 'appoptics', 'kairos', 'stackdriver', 'opentsdb'].each { sys -> +['atlas', 'prometheus', 'datadog', 'elastic', 'ganglia', 'graphite', 'health', 'jmx', 'influx', 'statsd', 'new-relic', 'cloudwatch', 'cloudwatch2', 'signalfx', 'wavefront', 'dynatrace', 'dynatrace2', 'azure-monitor', 'humio', 'appoptics', 'kairos', 'stackdriver', 'opentsdb'].each { sys -> include "micrometer-registry-$sys" project(":micrometer-registry-$sys").projectDir = new File(rootProject.projectDir, "implementations/micrometer-registry-$sys") }