diff --git a/dependencies.gradle b/dependencies.gradle index f51cdffb7c..f53a223742 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -2,6 +2,7 @@ def VERSIONS = [ 'ch.qos.logback:logback-classic:1.2.+', 'colt:colt:1.2.0', 'com.amazonaws:aws-java-sdk-cloudwatch:latest.release', + 'com.dynatrace.metric.util:dynatrace-metric-utils-java:0.3.+', 'com.fasterxml.jackson.core:jackson-databind:latest.release', 'com.github.ben-manes.caffeine:caffeine:2.+', 'com.github.charithe:kafka-junit:latest.release', diff --git a/implementations/micrometer-registry-dynatrace/build.gradle b/implementations/micrometer-registry-dynatrace/build.gradle index 584bea256f..d9ba163917 100644 --- a/implementations/micrometer-registry-dynatrace/build.gradle +++ b/implementations/micrometer-registry-dynatrace/build.gradle @@ -2,6 +2,8 @@ dependencies { api project(':micrometer-core') implementation 'org.slf4j:slf4j-api' + + implementation 'com.dynatrace.metric.util:dynatrace-metric-utils-java' testImplementation project(':micrometer-test') testImplementation 'com.fasterxml.jackson.core:jackson-databind' diff --git a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/AbstractDynatraceExporter.java b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/AbstractDynatraceExporter.java index 25ec6bbceb..7254a7ad23 100644 --- a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/AbstractDynatraceExporter.java +++ b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/AbstractDynatraceExporter.java @@ -31,10 +31,9 @@ * @since 1.8.0 */ public abstract class AbstractDynatraceExporter { - - protected DynatraceConfig config; - protected Clock clock; - protected HttpSender httpClient; + protected final DynatraceConfig config; + protected final Clock clock; + protected final HttpSender httpClient; public AbstractDynatraceExporter(DynatraceConfig config, Clock clock, HttpSender httpClient) { this.config = config; @@ -46,5 +45,5 @@ public TimeUnit getBaseTimeUnit() { return TimeUnit.MILLISECONDS; } - public abstract void export(@Nonnull List> partitions); + public abstract void export(@Nonnull List meters); } diff --git a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceApiVersion.java b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceApiVersion.java index b0df8cd14c..29b1f4109c 100644 --- a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceApiVersion.java +++ b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceApiVersion.java @@ -18,8 +18,11 @@ /** * An enum containing valid Dynatrace API versions. + * + * @author Georg Pirklbauer * @since 1.8.0 */ public enum DynatraceApiVersion { V1, + V2 } diff --git a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceConfig.java b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceConfig.java index 93143b9873..7b90fb4f04 100644 --- a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceConfig.java +++ b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/DynatraceConfig.java @@ -15,11 +15,14 @@ */ package io.micrometer.dynatrace; +import com.dynatrace.metric.util.DynatraceMetricApiConstants; import io.micrometer.core.instrument.config.validate.Validated; import io.micrometer.core.instrument.step.StepRegistryConfig; -import io.micrometer.core.instrument.util.StringUtils; import io.micrometer.core.lang.Nullable; +import java.util.Collections; +import java.util.Map; + import static io.micrometer.core.instrument.config.MeterRegistryConfigValidator.*; import static io.micrometer.core.instrument.config.validate.PropertyValidator.*; @@ -38,11 +41,17 @@ default String prefix() { } default String apiToken() { - return getSecret(this, "apiToken").required().get(); + if (apiVersion() == DynatraceApiVersion.V1) { + return getSecret(this, "apiToken").required().get(); + } + return getSecret(this, "apiToken").orElse(""); } default String uri() { - return getUrlString(this, "uri").required().get(); + if (apiVersion() == DynatraceApiVersion.V1) { + return getUrlString(this, "uri").required().get(); + } + return getUrlString(this, "uri").orElse(DynatraceMetricApiConstants.getDefaultOneAgentEndpoint()); } default String deviceId() { @@ -50,9 +59,7 @@ default String deviceId() { } default String technologyType() { - return getSecret(this, "technologyType") - .map(val -> StringUtils.isEmpty(val) ? "java" : val) - .get(); + return getSecret(this, "technologyType").orElse("java"); } /** @@ -67,14 +74,25 @@ default String group() { } /** - * Return the version of the target Dynatrace API. + * Return the version of the target Dynatrace API. Defaults to v1 if not provided. * * @return a {@link DynatraceApiVersion} containing the version of the targeted Dynatrace API. */ default DynatraceApiVersion apiVersion() { // if not specified, defaults to v1 for backwards compatibility. - return getEnum(this, DynatraceApiVersion.class, "apiVersion") - .orElse(DynatraceApiVersion.V1); + return getEnum(this, DynatraceApiVersion.class, "apiVersion").orElse(DynatraceApiVersion.V1); + } + + default String metricKeyPrefix() { + return getString(this, "metricKeyPrefix").orElse(""); + } + + default Map defaultDimensions() { + return Collections.emptyMap(); + } + + default Boolean enrichWithOneAgentMetadata() { + return getBoolean(this, "enrichWithOneAgentMetadata").orElse(true); } @Override @@ -94,7 +112,9 @@ default Validated validate() { check("technologyType", DynatraceConfig::technologyType).andThen(Validated::nonBlank) ); } else { - return apiVersionValidation; // V2 validation comes here + return checkAll(this, + checkRequired("uri", DynatraceConfig::uri) + ); } } ); 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 7039e59be0..e2ed308cd1 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 @@ -16,18 +16,28 @@ package io.micrometer.dynatrace; import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.config.MeterFilter; +import io.micrometer.core.instrument.config.MeterFilterReply; +import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; import io.micrometer.core.instrument.step.StepMeterRegistry; -import io.micrometer.core.instrument.util.MeterPartition; import io.micrometer.core.instrument.util.NamedThreadFactory; import io.micrometer.core.ipc.http.HttpSender; import io.micrometer.core.ipc.http.HttpUrlConnectionSender; import io.micrometer.dynatrace.v1.DynatraceExporterV1; +import io.micrometer.dynatrace.v2.DynatraceExporterV2; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Arrays; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; +import static io.micrometer.core.instrument.config.MeterFilterReply.DENY; +import static io.micrometer.core.instrument.config.MeterFilterReply.NEUTRAL; + /** * {@link StepMeterRegistry} for Dynatrace. * @@ -36,13 +46,13 @@ * @author Johnny Lim * @author PJ Fanning * @author Georg Pirklbauer + * @author Jonatan Ivanov * @since 1.1.0 */ public class DynatraceMeterRegistry extends StepMeterRegistry { private static final ThreadFactory DEFAULT_THREAD_FACTORY = new NamedThreadFactory("dynatrace-metrics-publisher"); private static final Logger logger = LoggerFactory.getLogger(DynatraceMeterRegistry.class); - private final DynatraceConfig config; private final AbstractDynatraceExporter exporter; @SuppressWarnings("deprecation") @@ -53,13 +63,15 @@ public DynatraceMeterRegistry(DynatraceConfig config, Clock clock) { private DynatraceMeterRegistry(DynatraceConfig config, Clock clock, ThreadFactory threadFactory, HttpSender httpClient) { super(config, clock); - this.config = config; - if (config.apiVersion() == DynatraceApiVersion.V1) { - logger.info("Using Dynatrace v1 exporter."); - this.exporter = new DynatraceExporterV1(config, clock, httpClient); + if (config.apiVersion() == DynatraceApiVersion.V2) { + logger.info("Exporting to Dynatrace metrics API v2"); + this.exporter = new DynatraceExporterV2(config, clock, httpClient); + registerMinPercentile(); } else { - throw new IllegalArgumentException("Only v1 export is available at the moment."); + logger.info("Exporting to Dynatrace metrics API v1"); + this.exporter = new DynatraceExporterV1(config, clock, httpClient); } + start(threadFactory); } @@ -69,7 +81,7 @@ public static Builder builder(DynatraceConfig config) { @Override protected void publish() { - exporter.export(MeterPartition.partition(this, config.batchSize())); + exporter.export(this.getMeters()); } @Override @@ -77,6 +89,59 @@ protected TimeUnit getBaseTimeUnit() { return this.exporter.getBaseTimeUnit(); } + /** + * As the micrometer summary statistics (DistributionSummary, and a number of timer meter types) + * do not provide the minimum values that are required by Dynatrace to ingest summary metrics, + * we add the 0th percentile to each summary statistic and use that as the minimum value. + */ + private void registerMinPercentile() { + config().meterFilter(new MeterFilter() { + private final Set metersWithArtificialZeroPercentile = ConcurrentHashMap.newKeySet(); + + /** + * Adds 0th percentile if the user hasn't already added + * and tracks those meter names where the 0th percentile was artificially added. + */ + @Override + public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) { + double[] percentiles; + + if (config.getPercentiles() == null) { + percentiles = new double[] {0}; + metersWithArtificialZeroPercentile.add(id.getName() + ".percentile"); + } else if (!containsZeroPercentile(config)) { + percentiles = new double[config.getPercentiles().length + 1]; + System.arraycopy(config.getPercentiles(), 0, percentiles, 0, config.getPercentiles().length); + percentiles[config.getPercentiles().length] = 0; // theoretically this is already zero + metersWithArtificialZeroPercentile.add(id.getName() + ".percentile"); + } else { + percentiles = config.getPercentiles(); + } + + return DistributionStatisticConfig.builder() + .percentiles(percentiles) + .build() + .merge(config); + } + + /** + * Denies artificially added 0th percentile meters. + */ + @Override + public MeterFilterReply accept(Meter.Id id) { + return hasArtificialZerothPercentile(id) ? DENY : NEUTRAL; + } + + private boolean containsZeroPercentile(DistributionStatisticConfig config) { + return Arrays.stream(config.getPercentiles()).anyMatch(percentile -> percentile == 0); + } + + private boolean hasArtificialZerothPercentile(Meter.Id id) { + return metersWithArtificialZeroPercentile.contains(id.getName()) && "0".equals(id.getTag("phi")); + } + }); + } + public static class Builder { private final DynatraceConfig config; @@ -110,4 +175,3 @@ public DynatraceMeterRegistry build() { } } } - diff --git a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/v1/DynatraceExporterV1.java b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/v1/DynatraceExporterV1.java index 23340f2820..d39e18278c 100644 --- a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/v1/DynatraceExporterV1.java +++ b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/v1/DynatraceExporterV1.java @@ -19,10 +19,12 @@ import io.micrometer.core.instrument.*; import io.micrometer.core.instrument.config.NamingConvention; import io.micrometer.core.instrument.distribution.HistogramSnapshot; +import io.micrometer.core.instrument.util.MeterPartition; import io.micrometer.core.instrument.util.StringUtils; import io.micrometer.core.ipc.http.HttpSender; import io.micrometer.core.lang.Nullable; import io.micrometer.dynatrace.AbstractDynatraceExporter; +import io.micrometer.dynatrace.DynatraceApiVersion; import io.micrometer.dynatrace.DynatraceConfig; import io.micrometer.dynatrace.DynatraceNamingConvention; import org.slf4j.Logger; @@ -67,15 +69,15 @@ public DynatraceExporterV1(DynatraceConfig config, Clock clock, HttpSender httpC super(config, clock, httpClient); this.customMetricEndpointTemplate = config.uri() + "/api/v1/timeseries/"; - this.namingConvention = new DynatraceNamingConvention(); + this.namingConvention = new DynatraceNamingConvention(NamingConvention.dot, DynatraceApiVersion.V1); } @Override - public void export(@Nonnull List> partitions) { + public void export(@Nonnull List meters) { String customDeviceMetricEndpoint = config.uri() + "/api/v1/entity/infrastructure/custom/" + config.deviceId() + "?api-token=" + config.apiToken(); - for (List batch : partitions) { + for (List batch : new MeterPartition(meters, config.batchSize())) { final List series = batch.stream() .flatMap(meter -> meter.match( this::writeMeter, diff --git a/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/v2/DynatraceExporterV2.java b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/v2/DynatraceExporterV2.java new file mode 100644 index 0000000000..a487919312 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace/src/main/java/io/micrometer/dynatrace/v2/DynatraceExporterV2.java @@ -0,0 +1,326 @@ +/** + * Copyright 2021 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.dynatrace.v2; + +import com.dynatrace.metric.util.*; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.*; +import io.micrometer.core.instrument.distribution.HistogramSnapshot; +import io.micrometer.core.instrument.distribution.ValueAtPercentile; +import io.micrometer.core.instrument.util.AbstractPartition; +import io.micrometer.core.ipc.http.HttpSender; +import io.micrometer.dynatrace.AbstractDynatraceExporter; +import io.micrometer.dynatrace.DynatraceConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import java.net.MalformedURLException; +import java.net.URI; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BiFunction; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +/** + * Implementation for Dynatrace v1 metrics API export. + * + * @author Georg Pirklbauer + * @author Jonatan Ivanov + * @since 1.8.0 + */ +public final class DynatraceExporterV2 extends AbstractDynatraceExporter { + private static final String METER_EXCEPTION_FORMAT = "Could not serialize meter %s: %s"; + private static final Pattern EXTRACT_LINES_OK = Pattern.compile("\"linesOk\":\\s?(\\d+)"); + private static final Pattern EXTRACT_LINES_INVALID = Pattern.compile("\"linesInvalid\":\\s?(\\d+)"); + private static final Pattern IS_NULL_ERROR_RESPONSE = Pattern.compile("\"error\":\\s?null"); + + private static final Logger logger = LoggerFactory.getLogger(DynatraceExporterV2.class.getName()); + private static final Map staticDimensions = Collections.singletonMap("dt.metrics.source", "micrometer"); + + private final String endpoint; + private final boolean ignoreToken; + private final MetricBuilderFactory metricBuilderFactory; + + public DynatraceExporterV2(DynatraceConfig config, Clock clock, HttpSender httpClient) { + super(config, clock, httpClient); + this.endpoint = config.uri(); + showErrorIfEndpointIsInvalid(endpoint); + ignoreToken = shouldIgnoreToken(config); + logger.info("Exporting to endpoint {}", this.endpoint); + + MetricBuilderFactory.MetricBuilderFactoryBuilder factoryBuilder = MetricBuilderFactory.builder() + .withPrefix(config.metricKeyPrefix()) + .withDefaultDimensions(parseDefaultDimensions(config.defaultDimensions())); + + if (config.enrichWithOneAgentMetadata()) { + factoryBuilder.withOneAgentMetadata(); + } + + metricBuilderFactory = factoryBuilder.build(); + } + + private void showErrorIfEndpointIsInvalid(String uri) { + try { + URI.create(uri).toURL(); + } catch (IllegalArgumentException | MalformedURLException ex) { + logger.error("Invalid URI provided, exporting will fail: {}", uri); + } + } + + private boolean shouldIgnoreToken(DynatraceConfig config) { + if (config.apiToken().isEmpty()) { + return true; + } else if (config.uri().equals(DynatraceMetricApiConstants.getDefaultOneAgentEndpoint())) { + logger.warn("Potential misconfiguration detected: Token is provided, but the endpoint is set to the local OneAgent endpoint, " + + "thus the token will be ignored. If exporting to the cluster API endpoint is intended, its URI has to be provided explicitly."); + return true; + } else { + return false; + } + } + + private DimensionList parseDefaultDimensions(Map defaultDimensions) { + List dimensions = Stream.concat( + defaultDimensions != null ? defaultDimensions.entrySet().stream() : Stream.empty(), + staticDimensions.entrySet().stream() + ) + .map(entry -> Dimension.create(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + return DimensionList.fromCollection(dimensions); + } + + /** + * Export to the Dynatrace v2 endpoint. Measurements that contain NaN or Infinite values, as + * well as serialized data points that exceed length limits imposed by the API will be dropped + * and not exported. If the number of serialized data points exceeds the maximum number of + * allowed data points per request they will be sent in chunks. + * + * @param meters A list of {@link Meter Meters} that are serialized as one or more metric lines. + */ + @Override + public void export(@Nonnull List meters) { + // Lines that are too long to be ingested into Dynatrace, as well as lines that contain NaN + // or Inf values are dropped and not returned from "toMetricLines", and are therefore dropped. + List metricLines = meters.stream() + .flatMap(this::toMetricLines) // Stream to Stream + .collect(Collectors.toList()); + + sendInBatches(metricLines); + } + + private 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 + ); + } + + Stream toGaugeLine(Gauge meter) { + return toMeterLine(meter, this::createGaugeLine); + } + + private String createGaugeLine(Meter meter, Measurement measurement) { + try { + return createMetricBuilder(meter).setDoubleGaugeValue(measurement.getValue()).serialize(); + } catch (MetricException e) { + logger.warn(String.format(METER_EXCEPTION_FORMAT, meter.getId().getName(), e.getMessage())); + } + + return null; + } + + Stream toCounterLine(Counter meter) { + return toMeterLine(meter, this::createCounterLine); + } + + private String createCounterLine(Meter meter, Measurement measurement) { + try { + return createMetricBuilder(meter).setDoubleCounterValueDelta(measurement.getValue()).serialize(); + } catch (MetricException e) { + logger.warn(String.format(METER_EXCEPTION_FORMAT, meter.getId().getName(), e.getMessage())); + } + + return null; + } + + Stream toTimerLine(Timer meter) { + return toSummaryLine(meter, meter.takeSnapshot(), getBaseTimeUnit()); + } + + private Stream toSummaryLine(Meter meter, HistogramSnapshot histogramSnapshot, TimeUnit timeUnit) { + long count = histogramSnapshot.count(); + double total = (timeUnit != null) ? histogramSnapshot.total(timeUnit) : histogramSnapshot.total(); + double max = (timeUnit != null) ? histogramSnapshot.max(timeUnit) : histogramSnapshot.max(); + + double min; + if (count == 1) { + min = max; + } else { + min = minFromHistogramSnapshot(histogramSnapshot, timeUnit); + } + + return createSummaryLine(meter, min, max, total, count); + } + + private double minFromHistogramSnapshot(HistogramSnapshot histogramSnapshot, TimeUnit timeUnit) { + ValueAtPercentile[] valuesAtPercentiles = histogramSnapshot.percentileValues(); + double min = Double.NaN; + + for (ValueAtPercentile valueAtPercentile : valuesAtPercentiles) { + if (valueAtPercentile.percentile() == 0.0) { + min = (timeUnit != null) ? valueAtPercentile.value(timeUnit) : valueAtPercentile.value(); + break; + } + } + + return min; + } + + private Stream createSummaryLine(Meter meter, double min, double max, double total, long count) { + try { + String line = createMetricBuilder(meter).setDoubleSummaryValue(min, max, total, count).serialize(); + return streamOf(Collections.singletonList(line)); + } catch (MetricException e) { + logger.warn(String.format(METER_EXCEPTION_FORMAT, meter.getId().getName(), e.getMessage())); + } + + return Stream.empty(); + } + + Stream toDistributionSummaryLine(DistributionSummary meter) { + return toSummaryLine(meter, meter.takeSnapshot(), null); + } + + Stream toLongTaskTimerLine(LongTaskTimer meter) { + return toSummaryLine(meter, meter.takeSnapshot(), getBaseTimeUnit()); + } + + Stream toTimeGaugeLine(TimeGauge meter) { + return toMeterLine(meter, this::createGaugeLine); + } + + Stream toFunctionCounterLine(FunctionCounter meter) { + return toMeterLine(meter, this::createCounterLine); + } + + Stream toFunctionTimerLine(FunctionTimer meter) { + double total = meter.totalTime(getBaseTimeUnit()); + double average = meter.mean(getBaseTimeUnit()); + long longCount = Double.valueOf(meter.count()).longValue(); + + return createSummaryLine(meter, average, average, total, longCount); + } + + Stream toMeterLine(Meter meter) { + return toMeterLine(meter, this::createGaugeLine); + } + + private Stream toMeterLine(Meter meter, BiFunction measurementConverter) { + return streamOf(meter.measure()) + .map(measurement -> measurementConverter.apply(meter, measurement)) + .filter(Objects::nonNull); + } + + private Metric.Builder createMetricBuilder(Meter meter) { + return metricBuilderFactory.newMetricBuilder(meter.getId().getName()) + .setDimensions(fromTags(meter.getId().getTags())) + .setTimestamp(Instant.ofEpochMilli(clock.wallTime())); + } + + private DimensionList fromTags(List tags) { + return DimensionList.fromCollection(tags.stream() + .map(tag -> Dimension.create(tag.getKey(), tag.getValue())) + .collect(Collectors.toList()) + ); + } + + private Stream streamOf(Iterable iterable) { + return StreamSupport.stream(iterable.spliterator(), false); + } + + private void send(List metricLines) { + try { + String body = String.join("\n", metricLines); + if (logger.isDebugEnabled()) { + logger.debug("sending lines:\n" + body); + } + + HttpSender.Request.Builder requestBuilder = httpClient.post(endpoint); + if (!ignoreToken) { + requestBuilder.withHeader("Authorization", "Api-Token " + config.apiToken()); + } + + requestBuilder + .withHeader("User-Agent", "micrometer") + .withPlainText(body) + .send() + .onSuccess(response -> handleSuccess(metricLines.size(), response)) + .onError(response -> logger.error("Failed metric ingestion. Error code={} response.body={}", response.code(), response.body())); + } catch (Throwable throwable) { + logger.error("Failed metric ingestion: {}", throwable.getMessage()); + } + } + + private void handleSuccess(int totalSent, HttpSender.Response response) { + if (response.code() == 202) { + if (IS_NULL_ERROR_RESPONSE.matcher(response.body()).find()) { + Matcher linesOkMatchResult = EXTRACT_LINES_OK.matcher(response.body()); + Matcher linesInvalidMatchResult = EXTRACT_LINES_INVALID.matcher(response.body()); + if (linesOkMatchResult.find() && linesInvalidMatchResult.find()) { + logger.info("Sent {} metric lines, linesOk: {}, linesInvalid: {}.", + totalSent, linesOkMatchResult.group(1), linesInvalidMatchResult.group(1)); + } else { + logger.warn("Unable to parse response: {}", response.body()); + } + } else { + logger.warn("Unable to parse response: {}", response.body()); + } + } else { + // common pitfall if URI is supplied in V1 format (without endpoint path) + logger.error("Expected status code 202, got {}. Did you specify the ingest path (e.g.: /api/v2/metrics/ingest)?", response.code()); + } + } + + private void sendInBatches(List metricLines) { + int partitionSize = Math.min(config.batchSize(), DynatraceMetricApiConstants.getPayloadLinesLimit()); + MetricLinePartition.partition(metricLines, partitionSize).forEach(this::send); + } + + static class MetricLinePartition extends AbstractPartition { + + private 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-dynatrace/src/test/java/io/micrometer/dynatrace/DynatraceConfigTest.java b/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/DynatraceConfigTest.java index 79c67a886d..a0b4bade2f 100644 --- a/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/DynatraceConfigTest.java +++ b/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/DynatraceConfigTest.java @@ -15,6 +15,7 @@ */ package io.micrometer.dynatrace; +import com.dynatrace.metric.util.DynatraceMetricApiConstants; import io.micrometer.core.instrument.config.validate.InvalidReason; import io.micrometer.core.instrument.config.validate.Validated; import org.junit.jupiter.api.Test; @@ -26,11 +27,11 @@ import static org.assertj.core.api.Assertions.assertThat; class DynatraceConfigTest { - private final Map props = new HashMap<>(); - private final DynatraceConfig config = props::get; - @Test void invalid() { + Map properties = new HashMap<>(); + DynatraceConfig config = properties::get; + List> failures = config.validate().failures(); assertThat(failures.size()).isEqualTo(3); assertThat(failures.stream().map(Validated::toString)).containsExactlyInAnyOrder( @@ -85,10 +86,12 @@ void invalidVersion() { @Test void valid() { - props.put("dynatrace.apiToken", "secret"); - props.put("dynatrace.uri", "https://uri.dynatrace.com"); - props.put("dynatrace.deviceId", "device"); - + Map properties = new HashMap() {{ + put("dynatrace.apiToken", "secret"); + put("dynatrace.uri", "https://uri.dynatrace.com"); + put("dynatrace.deviceId", "device"); + }}; + DynatraceConfig config = properties::get; assertThat(config.validate().isValid()).isTrue(); } @@ -105,4 +108,55 @@ void testFallbackToV1() { assertThat(config.validate().isValid()).isTrue(); assertThat(config.apiVersion()).isSameAs(DynatraceApiVersion.V1); } + + @Test + void testV2Defaults() { + Map properties = new HashMap() {{ + put("dynatrace.apiVersion", "v2"); + }}; + DynatraceConfig config = properties::get; + + assertThat(config.apiVersion()).isEqualTo(DynatraceApiVersion.V2); + assertThat(config.apiToken()).isEmpty(); + assertThat(config.uri()).isSameAs(DynatraceMetricApiConstants.getDefaultOneAgentEndpoint()); + assertThat(config.metricKeyPrefix()).isEmpty(); + assertThat(config.defaultDimensions()).isEmpty(); + assertThat(config.enrichWithOneAgentMetadata()).isTrue(); + + Validated validated = config.validate(); + assertThat(validated.isValid()).isTrue(); + } + + @Test + void testOneAgentEndpointWithDifferentPort() { + Map properties = new HashMap() {{ + put("dynatrace.apiVersion", "v2"); + put("dynatrace.uri", "http://localhost:13333/metrics/ingest"); + }}; + DynatraceConfig config = properties::get; + + assertThat(config.apiToken()).isEmpty(); + assertThat(config.uri()).isEqualTo("http://localhost:13333/metrics/ingest"); + assertThat(config.apiVersion()).isEqualTo(DynatraceApiVersion.V2); + + Validated validated = config.validate(); + assertThat(validated.isValid()).isTrue(); + } + + @Test + void testV2requiredPropertiesWithEndpointAndToken() { + Map properties = new HashMap() {{ + put("dynatrace.apiVersion", "v2"); + put("dynatrace.uri", "https://uri.dynatrace.com"); + put("dynatrace.apiToken", "secret"); + }}; + + DynatraceConfig config = properties::get; + assertThat(config.apiToken()).isEqualTo("secret"); + assertThat(config.uri()).isEqualTo("https://uri.dynatrace.com"); + assertThat(config.apiVersion()).isEqualTo(DynatraceApiVersion.V2); + + Validated validated = config.validate(); + assertThat(validated.isValid()).isTrue(); + } } diff --git a/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/DynatraceMeterRegistryTest.java b/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/DynatraceMeterRegistryTest.java new file mode 100644 index 0000000000..287f25c043 --- /dev/null +++ b/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/DynatraceMeterRegistryTest.java @@ -0,0 +1,187 @@ +/** + * Copyright 2021 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.dynatrace; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MockClock; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.ipc.http.HttpSender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link DynatraceMeterRegistry}. + * + * @author Jonatan Ivanov + */ +public class DynatraceMeterRegistryTest { + private DynatraceConfig config; + private MockClock clock; + private HttpSender httpClient; + private DynatraceMeterRegistry meterRegistry; + + @BeforeEach + void setUp() { + this.config = createDefaultDynatraceConfig(); + this.clock = new MockClock(); + this.clock.add(System.currentTimeMillis(), MILLISECONDS); // Set the clock to something recent so that the Dynatrace library will not complain. + this.httpClient = mock(HttpSender.class); + this.meterRegistry = DynatraceMeterRegistry.builder(config) + .clock(clock) + .httpClient(httpClient) + .build(); + } + + @Test + void shouldSendProperRequest() throws Throwable { + HttpSender.Request.Builder builder = HttpSender.Request.build(config.uri(), httpClient); + when(httpClient.post(config.uri())).thenReturn(builder); + when(httpClient.send(isA(HttpSender.Request.class))).thenReturn(new HttpSender.Response(202, + "{ \"linesOk\": 4, \"linesInvalid\": 0, \"error\": null }" + )); + + Double gauge = meterRegistry.gauge("my.gauge", 42d); + Counter counter = meterRegistry.counter("my.counter"); + counter.increment(12d); + Timer timer = meterRegistry.timer("my.timer"); + timer.record(22, MILLISECONDS); + timer.record(42, MILLISECONDS); + timer.record(32, MILLISECONDS); + timer.record(12, MILLISECONDS); + clock.add(config.step()); + + meterRegistry.publish(); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(HttpSender.Request.class); + verify(httpClient).send(argumentCaptor.capture()); + HttpSender.Request request = argumentCaptor.getValue(); + + assertThat(request.getRequestHeaders()).containsOnly( + entry("Content-Type", "text/plain"), + entry("User-Agent", "micrometer"), + entry("Authorization", "Api-Token apiToken") + ); + assertThat(request.getEntity()).asString() + .hasLineCount(3) + .contains("my.counter,dt.metrics.source=micrometer count,delta=12.0 " + clock.wallTime()) + .contains("my.gauge,dt.metrics.source=micrometer gauge," + gauge.doubleValue() + " " + clock.wallTime()) + .contains("my.timer,dt.metrics.source=micrometer gauge,min=0.0,max=42.0,sum=108.0,count=4 " + clock.wallTime()); + } + + @Test + void shouldTrackZerothPercentileButShouldNotPublishIt() throws Throwable { + HttpSender.Request.Builder builder = HttpSender.Request.build(config.uri(), httpClient); + when(httpClient.post(config.uri())).thenReturn(builder); + Timer timer = Timer.builder("my.timer") + .publishPercentiles(0.5) + .register(meterRegistry); + timer.record(22, MILLISECONDS); + clock.add(config.step()); + + meterRegistry.publish(); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(HttpSender.Request.class); + verify(httpClient).send(argumentCaptor.capture()); + HttpSender.Request request = argumentCaptor.getValue(); + + assertThat(request.getEntity()).asString() + .hasLineCount(2) + .contains("my.timer,dt.metrics.source=micrometer gauge,min=22.0,max=22.0,sum=22.0,count=1 " + clock.wallTime()) + .contains("my.timer.percentile,phi=0.5,dt.metrics.source=micrometer gauge,0.0 " + clock.wallTime()); + } + + @Test + void shouldPublishZerothPercentileIfAlreadyDefined() throws Throwable { + HttpSender.Request.Builder builder = HttpSender.Request.build(config.uri(), httpClient); + when(httpClient.post(config.uri())).thenReturn(builder); + Timer timer = Timer.builder("my.timer") + .publishPercentiles(0.5, 0.0) + .register(meterRegistry); + timer.record(22, MILLISECONDS); + clock.add(config.step()); + + meterRegistry.publish(); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(HttpSender.Request.class); + verify(httpClient).send(argumentCaptor.capture()); + HttpSender.Request request = argumentCaptor.getValue(); + + assertThat(request.getEntity()).asString() + .hasLineCount(3) + .contains("my.timer,dt.metrics.source=micrometer gauge,min=22.0,max=22.0,sum=22.0,count=1 " + clock.wallTime()) + .contains("my.timer.percentile,phi=0,dt.metrics.source=micrometer gauge,0.0 " + clock.wallTime()) + .contains("my.timer.percentile,phi=0.5,dt.metrics.source=micrometer gauge,0.0 " + clock.wallTime()); + } + + @Test + void shouldPublishZerothPercentileIfExclusivelyDefined() throws Throwable { + HttpSender.Request.Builder builder = HttpSender.Request.build(config.uri(), httpClient); + when(httpClient.post(config.uri())).thenReturn(builder); + Timer timer = Timer.builder("my.timer") + .publishPercentiles(0.0) + .register(meterRegistry); + timer.record(22, MILLISECONDS); + clock.add(config.step()); + + meterRegistry.publish(); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(HttpSender.Request.class); + verify(httpClient).send(argumentCaptor.capture()); + HttpSender.Request request = argumentCaptor.getValue(); + + assertThat(request.getEntity()).asString() + .hasLineCount(2) + .contains("my.timer,dt.metrics.source=micrometer gauge,min=22.0,max=22.0,sum=22.0,count=1 " + clock.wallTime()) + .contains("my.timer.percentile,phi=0,dt.metrics.source=micrometer gauge,0.0 " + clock.wallTime()); + } + + private DynatraceConfig createDefaultDynatraceConfig() { + return new DynatraceConfig() { + @Override + @SuppressWarnings("NullableProblems") + public String get(String key) { + return null; + } + + @Override + @SuppressWarnings("NullableProblems") + public String uri() { + return "http://localhost"; + } + + @Override + @SuppressWarnings("NullableProblems") + public String apiToken() { + return "apiToken"; + } + + @Override + @SuppressWarnings("NullableProblems") + public DynatraceApiVersion apiVersion() { + return DynatraceApiVersion.V2; + } + }; + } +} diff --git a/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/v1/DynatraceExporterV1Test.java b/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/v1/DynatraceExporterV1Test.java index ab186f2e6a..0a3fd06b8d 100644 --- a/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/v1/DynatraceExporterV1Test.java +++ b/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/v1/DynatraceExporterV1Test.java @@ -39,7 +39,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Tests for {@link DynatraceMeterRegistry}. + * Tests for {@link DynatraceExporterV1}. * * @author Johnny Lim */ diff --git a/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/v2/DynatraceExporterV2Test.java b/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/v2/DynatraceExporterV2Test.java new file mode 100644 index 0000000000..242fd7769e --- /dev/null +++ b/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/v2/DynatraceExporterV2Test.java @@ -0,0 +1,470 @@ +/** + * Copyright 2021 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.dynatrace.v2; + +import io.micrometer.core.instrument.*; +import io.micrometer.core.ipc.http.HttpSender; +import io.micrometer.dynatrace.DynatraceApiVersion; +import io.micrometer.dynatrace.DynatraceConfig; +import io.micrometer.dynatrace.DynatraceMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static io.micrometer.core.instrument.MockClock.clock; +import static java.lang.Double.NaN; +import static java.lang.Double.POSITIVE_INFINITY; +import static java.lang.Double.NEGATIVE_INFINITY; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link DynatraceExporterV2}. + * + * @author Georg Pirklbauer + * @author Jonatan Ivanov + */ +class DynatraceExporterV2Test { + private DynatraceConfig config; + private MockClock clock; + private HttpSender httpClient; + private DynatraceMeterRegistry meterRegistry; + private DynatraceExporterV2 exporter; + + @BeforeEach + void setUp() { + this.config = createDefaultDynatraceConfig(); + this.clock = new MockClock(); + this.clock.add(System.currentTimeMillis(), MILLISECONDS); // Set the clock to something recent so that the Dynatrace library will not complain. + this.httpClient = mock(HttpSender.class); + this.meterRegistry = DynatraceMeterRegistry.builder(config) + .clock(clock) + .httpClient(httpClient) + .build(); + + this.exporter = new DynatraceExporterV2(config, clock, httpClient); + } + + @Test + void toGaugeLine() { + meterRegistry.gauge("my.gauge", 1.23); + Gauge gauge = meterRegistry.find("my.gauge").gauge(); + List lines = exporter.toGaugeLine(gauge).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.gauge,dt.metrics.source=micrometer gauge,1.23 " + clock.wallTime()); + } + + @Test + void toGaugeLineShouldDropNanValue() { + meterRegistry.gauge("my.gauge", NaN); + Gauge gauge = meterRegistry.find("my.gauge").gauge(); + assertThat(exporter.toGaugeLine(gauge).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toGaugeLineShouldDropInfiniteValues() { + meterRegistry.gauge("my.gauge", POSITIVE_INFINITY); + Gauge gauge = meterRegistry.find("my.gauge").gauge(); + assertThat(exporter.toGaugeLine(gauge).collect(Collectors.toList())).isEmpty(); + + meterRegistry.gauge("my.gauge", NEGATIVE_INFINITY); + gauge = meterRegistry.find("my.gauge").gauge(); + assertThat(exporter.toGaugeLine(gauge).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toTimeGaugeLine() { + AtomicReference obj = new AtomicReference<>(2.3d); + meterRegistry.more().timeGauge("my.timeGauge", Tags.empty(), obj, MILLISECONDS, AtomicReference::get); + TimeGauge timeGauge = meterRegistry.find("my.timeGauge").timeGauge(); + List lines = exporter.toTimeGaugeLine(timeGauge).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.timeGauge,dt.metrics.source=micrometer gauge,2.3 " + clock.wallTime()); + } + + @Test + void toTimeGaugeLineShouldDropNanValue() { + AtomicReference obj = new AtomicReference<>(NaN); + meterRegistry.more().timeGauge("my.timeGauge", Tags.empty(), obj, MILLISECONDS, AtomicReference::get); + TimeGauge timeGauge = meterRegistry.find("my.timeGauge").timeGauge(); + + assertThat(exporter.toTimeGaugeLine(timeGauge).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toTimeGaugeLineShouldDropInfiniteValues() { + AtomicReference obj = new AtomicReference<>(POSITIVE_INFINITY); + meterRegistry.more().timeGauge("my.timeGauge", Tags.empty(), obj, MILLISECONDS, AtomicReference::get); + TimeGauge timeGauge = meterRegistry.find("my.timeGauge").timeGauge(); + assertThat(exporter.toTimeGaugeLine(timeGauge).collect(Collectors.toList())).isEmpty(); + + obj = new AtomicReference<>(NEGATIVE_INFINITY); + meterRegistry.more().timeGauge("my.timeGauge", Tags.empty(), obj, MILLISECONDS, AtomicReference::get); + timeGauge = meterRegistry.find("my.timeGauge").timeGauge(); + assertThat(exporter.toTimeGaugeLine(timeGauge).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toCounterLine() { + Counter counter = meterRegistry.counter("my.counter"); + counter.increment(); + counter.increment(); + counter.increment(); + clock.add(config.step()); + + List lines = exporter.toCounterLine(counter).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.counter,dt.metrics.source=micrometer count,delta=3.0 " + clock.wallTime()); + } + + @Test + void toCounterLineShouldDropNanValue() { + Counter counter = meterRegistry.counter("my.counter"); + counter.increment(NaN); + clock.add(config.step()); + + assertThat(exporter.toCounterLine(counter).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toCounterLineShouldDropInfiniteValue() { + Counter counter = meterRegistry.counter("my.counter"); + counter.increment(POSITIVE_INFINITY); + clock.add(config.step()); + + assertThat(exporter.toCounterLine(counter).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toFunctionCounterLine() { + AtomicReference obj = new AtomicReference<>(0.0d); + FunctionCounter.builder("my.functionCounter", obj, AtomicReference::get).register(meterRegistry); + FunctionCounter functionCounter = meterRegistry.find("my.functionCounter").functionCounter(); + assertNotNull(functionCounter); + + obj.set(2.3d); + clock.add(config.step()); + + List lines = exporter.toFunctionCounterLine(functionCounter).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.functionCounter,dt.metrics.source=micrometer count,delta=2.3 " + clock.wallTime()); + } + + @Test + void toFunctionCounterLineShouldDropNanValue() { + AtomicReference obj = new AtomicReference<>(0.0d); + FunctionCounter.builder("my.functionCounter", obj, AtomicReference::get).register(meterRegistry); + FunctionCounter functionCounter = meterRegistry.find("my.functionCounter").functionCounter(); + assertNotNull(functionCounter); + + obj.set(NaN); + clock.add(config.step()); + + assertThat(exporter.toFunctionCounterLine(functionCounter).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toFunctionCounterLineShouldDropInfiniteValue() { + AtomicReference obj = new AtomicReference<>(0.0d); + FunctionCounter.builder("my.functionCounter", obj, AtomicReference::get).register(meterRegistry); + FunctionCounter functionCounter = meterRegistry.find("my.functionCounter").functionCounter(); + assertNotNull(functionCounter); + + obj.set(POSITIVE_INFINITY); + clock.add(config.step()); + + assertThat(exporter.toFunctionCounterLine(functionCounter).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toTimerLine() { + Timer timer = meterRegistry.timer("my.timer"); + timer.record(Duration.ofMillis(60)); + timer.record(Duration.ofMillis(20)); + timer.record(Duration.ofMillis(10)); + clock.add(config.step()); + + List lines = exporter.toTimerLine(timer).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.timer,dt.metrics.source=micrometer gauge,min=0.0,max=60.0,sum=90.0,count=3 " + clock.wallTime()); + } + + @Test + void toFunctionTimerLineShouldDropNanMean() { + FunctionTimer functionTimer = new FunctionTimer() { + @Override + public double count() { + return 500; + } + + @Override + @SuppressWarnings("NullableProblems") + public double totalTime(TimeUnit unit) { + return 5000; + } + + @Override + @SuppressWarnings("NullableProblems") + public TimeUnit baseTimeUnit() { + return MILLISECONDS; + } + + @Override + @SuppressWarnings("NullableProblems") + public Id getId() { + return new Id("my.functionTimer", Tags.empty(), null, null, Type.TIMER); + } + + @Override + @SuppressWarnings("NullableProblems") + public double mean(TimeUnit unit) { + return NaN; + } + }; + + assertThat(exporter.toFunctionTimerLine(functionTimer).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toFunctionTimerLine() { + FunctionTimer functionTimer = new FunctionTimer() { + @Override + public double count() { + return 500; + } + + @Override + @SuppressWarnings("NullableProblems") + public double totalTime(TimeUnit unit) { + return 5000; + } + + @Override + @SuppressWarnings("NullableProblems") + public TimeUnit baseTimeUnit() { + return MILLISECONDS; + } + + @Override + @SuppressWarnings("NullableProblems") + public Id getId() { + return new Id("my.functionTimer", Tags.empty(), null, null, Type.TIMER); + } + }; + + List lines = exporter.toFunctionTimerLine(functionTimer).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.functionTimer,dt.metrics.source=micrometer gauge,min=10.0,max=10.0,sum=5000.0,count=500 " + clock.wallTime()); + } + + @Test + void toLongTaskTimerLine() { + LongTaskTimer longTaskTimer = LongTaskTimer.builder("my.longTaskTimer").register(meterRegistry); + List samples = Arrays.asList(42, 48, 40, 35, 22, 16, 13, 8, 6, 2, 4); + int prior = samples.get(0); + for (Integer value : samples) { + clock.add(prior - value, SECONDS); + longTaskTimer.start(); + prior = value; + } + clock(meterRegistry).add(samples.get(samples.size() - 1), SECONDS); + + List lines = exporter.toLongTaskTimerLine(longTaskTimer).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.longTaskTimer,dt.metrics.source=micrometer gauge,min=4000.0,max=42000.0,sum=236000.0,count=11 " + clock.wallTime()); + } + + @Test + void testToDistributionSummaryLine() { + DistributionSummary summary = DistributionSummary.builder("my.summary").register(meterRegistry); + summary.record(3.1); + summary.record(2.3); + summary.record(5.4); + summary.record(.1); + clock.add(config.step()); + + List lines = exporter.toDistributionSummaryLine(summary).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.summary,dt.metrics.source=micrometer gauge,min=0.0,max=5.4,sum=10.9,count=4 " + clock.wallTime()); + } + + @Test + void toMeterLine() { + Measurement m1 = new Measurement(() -> 23d, Statistic.VALUE); + Measurement m2 = new Measurement(() -> 42d, Statistic.VALUE); + Measurement m3 = new Measurement(() -> 5d, Statistic.VALUE); + Meter meter = Meter.builder("my.custom", Meter.Type.OTHER, Arrays.asList(m1, m2, m3)).register(meterRegistry); + + List lines = exporter.toMeterLine(meter).collect(Collectors.toList()); + assertThat(lines).hasSize(3); + assertThat(lines.get(0)).isEqualTo("my.custom,dt.metrics.source=micrometer gauge,23.0 " + clock.wallTime()); + assertThat(lines.get(1)).isEqualTo("my.custom,dt.metrics.source=micrometer gauge,42.0 " + clock.wallTime()); + assertThat(lines.get(2)).isEqualTo("my.custom,dt.metrics.source=micrometer gauge,5.0 " + clock.wallTime()); + } + + @Test + void gaugeWithInvalidNameShouldBeDropped() { + meterRegistry.gauge("~~~", 1.23); + Gauge gauge = meterRegistry.find("~~~").gauge(); + assertNotNull(gauge); + assertThat(exporter.toGaugeLine(gauge).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toGaugeLineShouldContainTags() { + Gauge.builder("my.gauge", () -> 1.23).tags(Tags.of("tag1", "value1", "tag2", "value2")).register(meterRegistry); + Gauge gauge = meterRegistry.find("my.gauge").gauge(); + assertNotNull(gauge); + + List lines = exporter.toGaugeLine(gauge).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.gauge,tag1=value1,dt.metrics.source=micrometer,tag2=value2 gauge,1.23 " + clock.wallTime()); + } + + @Test + void toGaugeLineShouldExportBlankTagValues() { + Gauge.builder("my.gauge", () -> 1.23).tags(Tags.of("tag1", "value1", "tag2", "")).register(meterRegistry); + Gauge gauge = meterRegistry.find("my.gauge").gauge(); + assertNotNull(gauge); + + List lines = exporter.toGaugeLine(gauge).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.gauge,tag1=value1,dt.metrics.source=micrometer,tag2= gauge,1.23 " + clock.wallTime()); + } + + @Test + void counterWithInvalidNameShouldBeDropped() { + meterRegistry.counter("~~~"); + Counter counter = meterRegistry.find("~~~").counter(); + assertNotNull(counter); + assertThat(exporter.toCounterLine(counter).collect(Collectors.toList())).isEmpty(); + } + + @Test + void toCounterLineShouldContainTags() { + Counter.builder("my.counter").tags(Tags.of("tag1", "value1", "tag2", "value2")).register(meterRegistry); + Counter counter = meterRegistry.find("my.counter").counter(); + assertNotNull(counter); + + List lines = exporter.toCounterLine(counter).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.counter,tag1=value1,dt.metrics.source=micrometer,tag2=value2 count,delta=0.0 " + clock.wallTime()); + } + + @Test + void toCounterLineShouldExportBlankTagValues() { + Counter.builder("my.counter").tags(Tags.of("tag1", "value1", "tag2", "")).register(meterRegistry); + Counter counter = meterRegistry.find("my.counter").counter(); + assertNotNull(counter); + + List lines = exporter.toCounterLine(counter).collect(Collectors.toList()); + assertThat(lines).hasSize(1); + assertThat(lines.get(0)).isEqualTo("my.counter,tag1=value1,dt.metrics.source=micrometer,tag2= count,delta=0.0 " + clock.wallTime()); + } + + @Test + void linesExceedingLengthLimitDiscardedGracefully() { + List tagList = new ArrayList<>(); + for (int i = 0; i < 250; i++) { + tagList.add(Tag.of(String.format("key%d", i), String.format("val%d", i))); + } + Tags tags = Tags.concat(tagList); + + meterRegistry.gauge("serialized.as.too.long.line", tags, 1.23); + Gauge gauge = meterRegistry.find("serialized.as.too.long.line").gauge(); + assertThat(gauge).isNotNull(); + + assertThat(exporter.toGaugeLine(gauge).collect(Collectors.toList())).isEmpty(); + } + + @Test + void shouldSendHeadersAndBody() throws Throwable { + HttpSender.Request.Builder builder = HttpSender.Request.build(config.uri(), httpClient); + when(httpClient.post(config.uri())).thenReturn(builder); + when(httpClient.send(isA(HttpSender.Request.class))).thenReturn(new HttpSender.Response(202, + "{ \"linesOk\": 3, \"linesInvalid\": 0, \"error\": null }" + )); + + Counter counter = meterRegistry.counter("my.counter"); + counter.increment(12d); + meterRegistry.gauge("my.gauge", 42d); + Gauge gauge = meterRegistry.find("my.gauge").gauge(); + Timer timer = meterRegistry.timer("my.timer"); + timer.record(22, MILLISECONDS); + clock.add(config.step()); + + exporter.export(Arrays.asList(counter, gauge, timer)); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(HttpSender.Request.class); + verify(httpClient).send(argumentCaptor.capture()); + HttpSender.Request request = argumentCaptor.getValue(); + + assertThat(request.getRequestHeaders()).containsOnly( + entry("Content-Type", "text/plain"), + entry("User-Agent", "micrometer"), + entry("Authorization", "Api-Token apiToken") + ); + assertThat(request.getEntity()).asString() + .hasLineCount(3) + .contains("my.counter,dt.metrics.source=micrometer count,delta=12.0 " + clock.wallTime()) + .contains("my.gauge,dt.metrics.source=micrometer gauge,42.0 " + clock.wallTime()) + .contains("my.timer,dt.metrics.source=micrometer gauge,min=22.0,max=22.0,sum=22.0,count=1 " + clock.wallTime()); + } + + private DynatraceConfig createDefaultDynatraceConfig() { + return new DynatraceConfig() { + @Override + @SuppressWarnings("NullableProblems") + public String get(String key) { + return null; + } + + @Override + @SuppressWarnings("NullableProblems") + public String uri() { + return "http://localhost"; + } + + @Override + @SuppressWarnings("NullableProblems") + public String apiToken() { + return "apiToken"; + } + + @Override + @SuppressWarnings("NullableProblems") + public DynatraceApiVersion apiVersion() { + return DynatraceApiVersion.V2; + } + }; + } +} diff --git a/micrometer-core/src/main/java/io/micrometer/core/instrument/util/MeterPartition.java b/micrometer-core/src/main/java/io/micrometer/core/instrument/util/MeterPartition.java index 2e72fac88e..e5de7bb0c3 100644 --- a/micrometer-core/src/main/java/io/micrometer/core/instrument/util/MeterPartition.java +++ b/micrometer-core/src/main/java/io/micrometer/core/instrument/util/MeterPartition.java @@ -26,13 +26,15 @@ * @author Jon Schneider */ public class MeterPartition extends AbstractPartition { + public MeterPartition(List meters, int partitionSize) { + super(meters, partitionSize); + } public MeterPartition(MeterRegistry registry, int partitionSize) { - super(registry.getMeters(), partitionSize); + this(registry.getMeters(), partitionSize); } public static List> partition(MeterRegistry registry, int partitionSize) { return new MeterPartition(registry, partitionSize); } - }