From 5942fe0888436d376544ee85f41dd26db3ef29fa Mon Sep 17 00:00:00 2001 From: Matthew Li Date: Tue, 16 Jun 2026 15:24:41 -0400 Subject: [PATCH 01/12] init --- .../common/metrics/OtlpHistogramBuckets.java | 79 +++++++ .../common/metrics/OtlpStatsMetricWriter.java | 218 ++++++++++++++++++ .../core/otlp/common/OtlpResourceProto.java | 6 + .../otlp/common/OtlpResourceProtoTest.java | 11 + 4 files changed, 314 insertions(+) create mode 100644 dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java create mode 100644 dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java new file mode 100644 index 00000000000..977bf74ead6 --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java @@ -0,0 +1,79 @@ +package datadog.trace.common.metrics; + +import datadog.metrics.api.Histogram; +import datadog.metrics.api.HistogramWithSum; +import datadog.trace.bootstrap.otlp.metrics.OtlpHistogramPoint; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Projects a client-side-stats {@link Histogram} (a DDSketch over span durations recorded in + * nanoseconds) onto the fixed explicit-bounds histogram layout mandated by the OTLP Trace + * Metrics Export RFC. + */ +final class OtlpHistogramBuckets { + private OtlpHistogramBuckets() {} + + private static final double NANOS_PER_SECOND = 1_000_000_000d; + + static final double[] BOUNDS_SECONDS = { + 0.002, 0.004, 0.006, 0.008, 0.01, 0.05, 0.1, 0.2, 0.4, 0.8, 1, 1.4, 2, 5, 10, 15 + }; + + static final List EXPLICIT_BOUNDS; + + static { + List bounds = new ArrayList<>(BOUNDS_SECONDS.length + 1); + for (double bound : BOUNDS_SECONDS) { + bounds.add(bound); + } + bounds.add(Double.POSITIVE_INFINITY); + EXPLICIT_BOUNDS = Collections.unmodifiableList(bounds); + } + + static int bucketIndex(double seconds) { + for (int i = 0; i < BOUNDS_SECONDS.length; i++) { + if (seconds <= BOUNDS_SECONDS[i]) { + return i; + } + } + return BOUNDS_SECONDS.length; // overflow + } + + /** + * Re-bins {@code histogram} (nanosecond-valued) into an {@link OtlpHistogramPoint} expressed in + * seconds with OTLP's fixed bucket layout. {@code count}, {@code min}, and {@code max} are taken + * directly from the sketch; {@code sum} is exact when the sketch tracks it ({@link + * HistogramWithSum}) and otherwise best-effort estimated from bin upper bounds. + */ + static OtlpHistogramPoint toHistogramPoint(Histogram histogram) { + long[] bucketCounts = new long[BOUNDS_SECONDS.length + 1]; + + List binBoundaries = histogram.getBinBoundaries(); + List binCounts = histogram.getBinCounts(); + double estimatedSumSeconds = 0d; + for (int i = 0; i < binBoundaries.size(); i++) { + double upperSeconds = binBoundaries.get(i) / NANOS_PER_SECOND; + long count = (long) binCounts.get(i).doubleValue(); + bucketCounts[bucketIndex(upperSeconds)] += count; + estimatedSumSeconds += upperSeconds * count; + } + + List counts = new ArrayList<>(bucketCounts.length); + for (long count : bucketCounts) { + counts.add((double) count); + } + + double sumSeconds = + histogram instanceof HistogramWithSum + ? ((HistogramWithSum) histogram).getSum() / NANOS_PER_SECOND + : estimatedSumSeconds; + + double minSeconds = histogram.isEmpty() ? 0d : histogram.getMinValue() / NANOS_PER_SECOND; + double maxSeconds = histogram.isEmpty() ? 0d : histogram.getMaxValue() / NANOS_PER_SECOND; + + return new OtlpHistogramPoint( + histogram.getCount(), EXPLICIT_BOUNDS, counts, sumSeconds, minSeconds, maxSeconds); + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java new file mode 100644 index 00000000000..e31371a6927 --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java @@ -0,0 +1,218 @@ +package datadog.trace.common.metrics; + +import static datadog.trace.bootstrap.otel.metrics.OtelInstrumentType.HISTOGRAM; +import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.LONG_ATTRIBUTE; +import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.STRING_ATTRIBUTE; +import static datadog.trace.core.otlp.common.OtlpCommonProto.I64_WIRE_TYPE; +import static datadog.trace.core.otlp.common.OtlpCommonProto.LEN_WIRE_TYPE; +import static datadog.trace.core.otlp.common.OtlpCommonProto.writeAttribute; +import static datadog.trace.core.otlp.common.OtlpCommonProto.writeI64; +import static datadog.trace.core.otlp.common.OtlpCommonProto.writeTag; +import static datadog.trace.core.otlp.common.OtlpResourceProto.RESOURCE_MESSAGE; +import static datadog.trace.core.otlp.metrics.OtlpMetricsProto.recordDataPointMessage; +import static datadog.trace.core.otlp.metrics.OtlpMetricsProto.recordMetricMessage; +import static datadog.trace.core.otlp.metrics.OtlpMetricsProto.recordScopedMetricsMessage; + +import datadog.communication.serialization.GrowableBuffer; +import datadog.metrics.api.Histogram; +import datadog.trace.api.Config; +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import datadog.trace.bootstrap.otel.common.OtelInstrumentationScope; +import datadog.trace.bootstrap.otel.metrics.OtelInstrumentDescriptor; +import datadog.trace.bootstrap.otlp.metrics.OtlpHistogramPoint; +import datadog.trace.core.otlp.common.OtlpGrpcSender; +import datadog.trace.core.otlp.common.OtlpHttpSender; +import datadog.trace.core.otlp.common.OtlpProtoBuffer; +import datadog.trace.core.otlp.common.OtlpSender; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link MetricWriter} that exports the existing client-side trace (RED) stats as a single + * vendor-neutral OTLP delta-temporality histogram named {@code traces.span.sdk.metrics.duration} + * (unit {@code s}). + * + *

This is the parallel-to-{@link SerializingMetricWriter} OTLP export path. It hangs off the + * same in-memory aggregation ({@link ConflatingMetricsAggregator} / {@link Aggregator}) and + * consumes the same {@link AggregateEntry} stream; only the wire encoding and transport differ. + * Native msgpack stats and OTLP export are mutually exclusive (selected at the factory). + * + *

Assembly mirrors {@code OtlpMetricsProtoCollector} + */ +public final class OtlpStatsMetricWriter implements MetricWriter { + private static final Logger log = LoggerFactory.getLogger(OtlpStatsMetricWriter.class); + + static final String METRIC_NAME = "traces.span.sdk.metrics.duration"; + static final String METRIC_UNIT = "s"; + + private static final OtelInstrumentDescriptor METRIC_DESCRIPTOR = + new OtelInstrumentDescriptor(METRIC_NAME, HISTOGRAM, false, null, METRIC_UNIT); + private static final OtelInstrumentationScope SCOPE = + new OtelInstrumentationScope("datadog.trace.metrics", null, null); + + private static final int DP_START_TIME_FIELD = 2; + private static final int DP_TIME_FIELD = 3; + private static final int DP_ATTRIBUTES_FIELD = 9; + + private static final String SPAN_NAME = "span.name"; + private static final String SPAN_KIND = "span.kind"; + private static final String HTTP_REQUEST_METHOD = "http.request.method"; + private static final String HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"; + private static final String HTTP_ROUTE = "http.route"; + private static final String RPC_RESPONSE_STATUS_CODE = "rpc.response.status_code"; + private static final String STATUS_CODE = "status.code"; + private static final String STATUS_CODE_ERROR = "ERROR"; + + @Nullable private final OtlpSender sender; + + private final GrowableBuffer buf = new GrowableBuffer(512); + private final OtlpProtoBuffer protobuf = new OtlpProtoBuffer(8192); + + private long startNanos; + private long endNanos; + + private int payloadBytes; + private int scopedBytes; + private int metricBytes; + + public OtlpStatsMetricWriter(Config config) { + this(createSender(config)); + } + + // visible for testing: lets tests inject a capturing sender to decode the emitted protobuf + OtlpStatsMetricWriter(@Nullable OtlpSender sender) { + this.sender = sender; + } + + @Nullable + private static OtlpSender createSender(Config config) { + // mirrors OtlpMetricsService's protocol-based sender selection + switch (config.getOtlpMetricsProtocol()) { + case GRPC: + return new OtlpGrpcSender( + config.getOtlpMetricsEndpoint(), + "/opentelemetry.proto.collector.metrics.v1.MetricsService/Export", + config.getOtlpMetricsHeaders(), + config.getOtlpMetricsTimeout(), + config.getOtlpMetricsCompression()); + case HTTP_PROTOBUF: + return new OtlpHttpSender( + config.getOtlpMetricsEndpoint(), + "/v1/metrics", + config.getOtlpMetricsHeaders(), + config.getOtlpMetricsTimeout(), + config.getOtlpMetricsCompression()); + default: + // HTTP_JSON has no protobuf-free encoder yet; JSON transport is deferred per the plan. + log.debug( + "Unsupported OTLP metrics protocol for trace metrics export: {}", + config.getOtlpMetricsProtocol()); + return null; + } + } + + @Override + public void startBucket(int metricCount, long start, long duration) { + // start/duration arrive as epoch nanos / interval nanos (see Aggregator#report) + this.startNanos = start; + this.endNanos = start + duration; + this.payloadBytes = 0; + this.scopedBytes = 0; + this.metricBytes = 0; + } + + @Override + public void add(AggregateEntry entry) { + Histogram okLatencies = entry.getOkLatencies(); + if (!okLatencies.isEmpty()) { + addDataPoint(entry, okLatencies, false); + } + + Histogram errorLatencies = entry.getErrorLatencies(); + if (errorLatencies != null) { + addDataPoint(entry, errorLatencies, true); + } + } + + private void addDataPoint(AggregateEntry entry, Histogram latencies, boolean error) { + writeDataPointAttributes(entry, error); + writeTag(buf, DP_START_TIME_FIELD, I64_WIRE_TYPE); + writeI64(buf, startNanos); + writeTag(buf, DP_TIME_FIELD, I64_WIRE_TYPE); + writeI64(buf, endNanos); + OtlpHistogramPoint point = OtlpHistogramBuckets.toHistogramPoint(latencies); + metricBytes += recordDataPointMessage(buf, point, protobuf); + } + + private void writeDataPointAttributes(AggregateEntry entry, boolean error) { + // TODO(step 4): branch on isTraceOtelSemanticsEnabled() to add the datadog.* attribute set in + // default mode and to omit it in OTel-semantics mode. The OTel-semconv attributes below are + // emitted in both modes. + if (error) { + writeStringAttribute(STATUS_CODE, STATUS_CODE_ERROR); + } + writeStringAttribute(SPAN_NAME, entry.getResource()); + writeStringAttribute(SPAN_KIND, entry.getSpanKind()); + if (entry.getHttpMethod() != null) { + writeStringAttribute(HTTP_REQUEST_METHOD, entry.getHttpMethod()); + } + if (entry.getHttpStatusCode() != 0) { + writeLongAttribute(HTTP_RESPONSE_STATUS_CODE, entry.getHttpStatusCode()); + } + if (entry.getHttpEndpoint() != null) { + writeStringAttribute(HTTP_ROUTE, entry.getHttpEndpoint()); + } + if (entry.getGrpcStatusCode() != null) { + writeStringAttribute(RPC_RESPONSE_STATUS_CODE, entry.getGrpcStatusCode()); + } + } + + private void writeStringAttribute(String key, @Nullable UTF8BytesString value) { + if (value != null) { + writeStringAttribute(key, value.toString()); + } + } + + private void writeStringAttribute(String key, String value) { + writeTag(buf, DP_ATTRIBUTES_FIELD, LEN_WIRE_TYPE); + writeAttribute(buf, STRING_ATTRIBUTE, key, value); + } + + private void writeLongAttribute(String key, long value) { + writeTag(buf, DP_ATTRIBUTES_FIELD, LEN_WIRE_TYPE); + writeAttribute(buf, LONG_ATTRIBUTE, key, value); + } + + @Override + public void finishBucket() { + try { + if (metricBytes > 0) { + scopedBytes += recordMetricMessage(buf, METRIC_DESCRIPTOR, metricBytes, protobuf); + } + if (scopedBytes > 0) { + payloadBytes += recordScopedMetricsMessage(buf, SCOPE, scopedBytes, protobuf); + } + if (payloadBytes == 0) { + return; + } + payloadBytes += protobuf.recordMessage(RESOURCE_MESSAGE); + protobuf.recordMessage(buf, 1, payloadBytes); + + if (sender != null) { + sender.send(protobuf.toPayload()); + } + } finally { + reset(); + } + } + + @Override + public void reset() { + buf.reset(); + protobuf.reset(); + payloadBytes = 0; + scopedBytes = 0; + metricBytes = 0; + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpResourceProto.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpResourceProto.java index 0e45aa22ab7..0a86668c234 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpResourceProto.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpResourceProto.java @@ -47,6 +47,12 @@ static byte[] buildResourceMessage(Config config) { if (!version.isEmpty()) { writeResourceAttribute(buf, "service.version", version); } + if (config.isReportHostName()) { + String hostName = config.getHostName(); + if (hostName != null && !hostName.isEmpty()) { + writeResourceAttribute(buf, "host.name", hostName); + } + } writeResourceAttribute(buf, "telemetry.sdk.name", "datadog"); writeResourceAttribute(buf, "telemetry.sdk.version", TRACER_VERSION); writeResourceAttribute(buf, "telemetry.sdk.language", "java"); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/common/OtlpResourceProtoTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/common/OtlpResourceProtoTest.java index 541f1750554..cb613137bcc 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/common/OtlpResourceProtoTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/common/OtlpResourceProtoTest.java @@ -5,6 +5,7 @@ import static datadog.trace.api.config.GeneralConfig.SERVICE_NAME; import static datadog.trace.api.config.GeneralConfig.TAGS; import static datadog.trace.api.config.GeneralConfig.VERSION; +import static datadog.trace.api.config.TracerConfig.TRACE_REPORT_HOSTNAME; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -99,6 +100,16 @@ static Stream resourceMessageCases() { "service.name", "my-service", "region", "us-east", "team", "platform")), + // report-hostname disabled (default): no host.name written + Arguments.of( + "report-hostname disabled", + props(SERVICE_NAME, "my-service"), + attrs("service.name", "my-service")), + // report-hostname enabled: host.name written with the detected hostname + Arguments.of( + "report-hostname enabled", + props(SERVICE_NAME, "my-service", TRACE_REPORT_HOSTNAME, "true"), + attrs("service.name", "my-service", "host.name", Config.get().getHostName())), // all config values set together; telemetry.sdk.* keys in tags must be ignored Arguments.of( "service, env, version, and tags all set", From 095eec33b97a69f4def5912fdeba6b1c345c779d Mon Sep 17 00:00:00 2001 From: Matthew Li Date: Wed, 17 Jun 2026 13:47:29 -0400 Subject: [PATCH 02/12] adding exact sums for ok/error --- .../common/metrics/OtlpHistogramBuckets.java | 15 ++++----------- .../common/metrics/OtlpStatsMetricWriter.java | 3 ++- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java index 977bf74ead6..a07165484ed 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java @@ -1,7 +1,6 @@ package datadog.trace.common.metrics; import datadog.metrics.api.Histogram; -import datadog.metrics.api.HistogramWithSum; import datadog.trace.bootstrap.otlp.metrics.OtlpHistogramPoint; import java.util.ArrayList; import java.util.Collections; @@ -44,20 +43,18 @@ static int bucketIndex(double seconds) { /** * Re-bins {@code histogram} (nanosecond-valued) into an {@link OtlpHistogramPoint} expressed in * seconds with OTLP's fixed bucket layout. {@code count}, {@code min}, and {@code max} are taken - * directly from the sketch; {@code sum} is exact when the sketch tracks it ({@link - * HistogramWithSum}) and otherwise best-effort estimated from bin upper bounds. + * directly from the sketch; {@code sumNanos} is the exact duration sum tracked alongside the + * sketch by {@link AggregateEntry} (the DDSketch-derived sum would only be approximate). */ - static OtlpHistogramPoint toHistogramPoint(Histogram histogram) { + static OtlpHistogramPoint toHistogramPoint(Histogram histogram, long sumNanos) { long[] bucketCounts = new long[BOUNDS_SECONDS.length + 1]; List binBoundaries = histogram.getBinBoundaries(); List binCounts = histogram.getBinCounts(); - double estimatedSumSeconds = 0d; for (int i = 0; i < binBoundaries.size(); i++) { double upperSeconds = binBoundaries.get(i) / NANOS_PER_SECOND; long count = (long) binCounts.get(i).doubleValue(); bucketCounts[bucketIndex(upperSeconds)] += count; - estimatedSumSeconds += upperSeconds * count; } List counts = new ArrayList<>(bucketCounts.length); @@ -65,11 +62,7 @@ static OtlpHistogramPoint toHistogramPoint(Histogram histogram) { counts.add((double) count); } - double sumSeconds = - histogram instanceof HistogramWithSum - ? ((HistogramWithSum) histogram).getSum() / NANOS_PER_SECOND - : estimatedSumSeconds; - + double sumSeconds = sumNanos / NANOS_PER_SECOND; double minSeconds = histogram.isEmpty() ? 0d : histogram.getMinValue() / NANOS_PER_SECOND; double maxSeconds = histogram.isEmpty() ? 0d : histogram.getMaxValue() / NANOS_PER_SECOND; diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java index e31371a6927..a190c5748c2 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java @@ -141,7 +141,8 @@ private void addDataPoint(AggregateEntry entry, Histogram latencies, boolean err writeI64(buf, startNanos); writeTag(buf, DP_TIME_FIELD, I64_WIRE_TYPE); writeI64(buf, endNanos); - OtlpHistogramPoint point = OtlpHistogramBuckets.toHistogramPoint(latencies); + long sumNanos = error ? entry.getErrorDuration() : entry.getOkDuration(); + OtlpHistogramPoint point = OtlpHistogramBuckets.toHistogramPoint(latencies, sumNanos); metricBytes += recordDataPointMessage(buf, point, protobuf); } From 4c92994d0fa09c7a6559acdb1127d0b9a7ceb534 Mon Sep 17 00:00:00 2001 From: Matthew Li Date: Wed, 17 Jun 2026 13:56:54 -0400 Subject: [PATCH 03/12] remove host.name changes --- .../trace/core/otlp/common/OtlpResourceProto.java | 6 ------ .../trace/core/otlp/common/OtlpResourceProtoTest.java | 11 ----------- 2 files changed, 17 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpResourceProto.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpResourceProto.java index 0a86668c234..0e45aa22ab7 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpResourceProto.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpResourceProto.java @@ -47,12 +47,6 @@ static byte[] buildResourceMessage(Config config) { if (!version.isEmpty()) { writeResourceAttribute(buf, "service.version", version); } - if (config.isReportHostName()) { - String hostName = config.getHostName(); - if (hostName != null && !hostName.isEmpty()) { - writeResourceAttribute(buf, "host.name", hostName); - } - } writeResourceAttribute(buf, "telemetry.sdk.name", "datadog"); writeResourceAttribute(buf, "telemetry.sdk.version", TRACER_VERSION); writeResourceAttribute(buf, "telemetry.sdk.language", "java"); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/common/OtlpResourceProtoTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/common/OtlpResourceProtoTest.java index cb613137bcc..541f1750554 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/common/OtlpResourceProtoTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/common/OtlpResourceProtoTest.java @@ -5,7 +5,6 @@ import static datadog.trace.api.config.GeneralConfig.SERVICE_NAME; import static datadog.trace.api.config.GeneralConfig.TAGS; import static datadog.trace.api.config.GeneralConfig.VERSION; -import static datadog.trace.api.config.TracerConfig.TRACE_REPORT_HOSTNAME; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -100,16 +99,6 @@ static Stream resourceMessageCases() { "service.name", "my-service", "region", "us-east", "team", "platform")), - // report-hostname disabled (default): no host.name written - Arguments.of( - "report-hostname disabled", - props(SERVICE_NAME, "my-service"), - attrs("service.name", "my-service")), - // report-hostname enabled: host.name written with the detected hostname - Arguments.of( - "report-hostname enabled", - props(SERVICE_NAME, "my-service", TRACE_REPORT_HOSTNAME, "true"), - attrs("service.name", "my-service", "host.name", Config.get().getHostName())), // all config values set together; telemetry.sdk.* keys in tags must be ignored Arguments.of( "service, env, version, and tags all set", From da4d67d1d4244babd2669d687cae5b363e2c0f3b Mon Sep 17 00:00:00 2001 From: Matthew Li Date: Wed, 17 Jun 2026 16:27:52 -0400 Subject: [PATCH 04/12] init tests --- .../metrics/OtlpHistogramBucketsTest.java | 153 +++++ .../metrics/OtlpStatsMetricWriterTest.java | 534 ++++++++++++++++++ 2 files changed, 687 insertions(+) create mode 100644 dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpHistogramBucketsTest.java create mode 100644 dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpHistogramBucketsTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpHistogramBucketsTest.java new file mode 100644 index 00000000000..ad0de52e0ec --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpHistogramBucketsTest.java @@ -0,0 +1,153 @@ +package datadog.trace.common.metrics; + +import static datadog.trace.common.metrics.OtlpHistogramBuckets.BOUNDS_SECONDS; +import static datadog.trace.common.metrics.OtlpHistogramBuckets.EXPLICIT_BOUNDS; +import static datadog.trace.common.metrics.OtlpHistogramBuckets.bucketIndex; +import static datadog.trace.common.metrics.OtlpHistogramBuckets.toHistogramPoint; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.metrics.api.Histogram; +import datadog.metrics.api.Histograms; +import datadog.metrics.impl.DDSketchHistograms; +import datadog.trace.bootstrap.otlp.metrics.OtlpHistogramPoint; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Unit tests for {@link OtlpHistogramBuckets}, the helper that re-bins a nanosecond-valued DDSketch + * onto the fixed OTLP explicit-bounds histogram layout (in seconds). + * + *

Both methods under test ({@code bucketIndex}, {@code toHistogramPoint}) are package-private, + * so this test lives in {@code datadog.trace.common.metrics}. + */ +class OtlpHistogramBucketsTest { + + private static final double NANOS_PER_SECOND = 1_000_000_000d; + + // Tolerance for double assertions on exactly-computed values (e.g. sum from sumNanos, integer + // counts). DDSketch-derived values like min/max use looser tolerances inline, since the sketch + // only preserves values to within its relative accuracy. + private static final double EPS = 1e-9; + + @BeforeAll + static void registerHistogramFactory() { + Histograms.register(DDSketchHistograms.FACTORY); + } + + // ── bucketIndex ────────────────────────────────────────────────────────── + + static Stream boundsWithIndex() { + return IntStream.range(0, BOUNDS_SECONDS.length) + .mapToObj(i -> Arguments.of(i, BOUNDS_SECONDS[i])); + } + + @ParameterizedTest(name = "value exactly on BOUNDS_SECONDS[{0}]={1} returns index {0}") + @MethodSource("boundsWithIndex") + void valueExactlyOnBoundReturnsThatIndex(int index, double bound) { + // <= semantics: a value exactly on a bound falls in that bound's bucket. + assertEquals(index, bucketIndex(bound)); + } + + @Test + void valueJustAboveSmallBoundFallsInNextBucket() { + // 0.002 is the first bound; a value just above it but below the second bound (0.004) + // must roll into the next index. + assertEquals(0, bucketIndex(0.002)); + assertEquals(1, bucketIndex(0.0020001)); + assertEquals(1, bucketIndex(0.004)); + } + + @Test + void valueAboveLargestBoundIsOverflow() { + // > 15s overflows to the final (16th) index. + assertEquals(BOUNDS_SECONDS.length, bucketIndex(15.0000001)); + assertEquals(BOUNDS_SECONDS.length, bucketIndex(100.0)); + // exactly 15 (the largest non-overflow bound) is the last non-overflow index. + assertEquals(BOUNDS_SECONDS.length - 1, bucketIndex(15.0)); + } + + // ── EXPLICIT_BOUNDS layout ──────────────────────────────────────────────── + + @Test + void explicitBoundsHas17EntriesEndingInInfinity() { + assertEquals(17, EXPLICIT_BOUNDS.size()); + assertEquals(Double.POSITIVE_INFINITY, EXPLICIT_BOUNDS.get(EXPLICIT_BOUNDS.size() - 1)); + } + + // ── toHistogramPoint ────────────────────────────────────────────────────── + + @Test + void toHistogramPointSummary() { + Histogram h = Histogram.newHistogram(); + // Samples spanning the layout, including a 1ns duration — the upstream-clamped minimum + // (DDSpan#finishAndAddToTrace does Math.max(1, durationNano)). 1ns is far below the smallest + // bound (0.002s) and must land in bucket 0, not be dropped. 1ms also falls in bucket 0, so the + // two sub-2ms samples share it; 100ms → bucket 6, 3s → bucket 13. + long[] samplesNanos = { + 1L, + (long) (0.001 * NANOS_PER_SECOND), + (long) (0.1 * NANOS_PER_SECOND), + (long) (3.0 * NANOS_PER_SECOND) + }; + for (long s : samplesNanos) { + h.accept(s); + } + + // Use a sumNanos that deliberately differs from the sketch's implied sum to prove the + // returned sum comes from the ARGUMENT, not the sketch. + long sumNanos = 42L * (long) NANOS_PER_SECOND; + OtlpHistogramPoint point = toHistogramPoint(h, sumNanos); + + // 17 bucket counts (the EXPLICIT_BOUNDS layout itself is covered by its own test). + assertEquals(17, point.bucketCounts.size()); + + // total count == number of samples. + assertEquals(samplesNanos.length, (long) point.count); + + // max is the 3s sample (CollapsingLowestDenseStore collapses the LOWEST bins, so the top is + // preserved accurately). The exact 1ns min is NOT recoverable: over this wide value range the + // lowest bins collapse, so getMinValue reports the collapsed bin (sub-2ms here), not 1ns. The + // tiny sample isn't lost though — that's proven by the count and bucket-0 assertions below. + // DDSketch is relative-accuracy, so min/max use loose tolerances. + assertTrue( + point.min > 0 && point.min <= BOUNDS_SECONDS[0], "min collapses into bucket 0 range"); + assertEquals(3.0, point.max, 1e-2); + + // sum equals the sumNanos ARGUMENT converted to seconds, not the sketch sum. + assertEquals(42.0, point.sum, EPS); + + // The two sub-2ms samples (1ns + 1ms) both land in bucket 0; nothing is lost: bucket counts + // sum to the total count. The count==Σbuckets invariant guards against a future histogram + // config that parks tiny values in a zero/negative store (excluded from getBinCounts). + long bucketTotal = 0; + for (int i = 0; i < point.bucketCounts.size(); i++) { + long c = (long) point.bucketCounts.get(i).doubleValue(); + bucketTotal += c; + if (i == 0) { + assertEquals(2L, c, "1ns + 1ms both land in bucket 0 (<= 0.002s)"); + } + } + assertEquals(samplesNanos.length, bucketTotal); + } + + @Test + void emptyHistogramHasZeroMinMaxAndCount() { + Histogram h = Histogram.newHistogram(); + OtlpHistogramPoint point = toHistogramPoint(h, 0L); + + assertEquals(0.0, point.count, EPS); + assertEquals(0.0, point.min, EPS); + assertEquals(0.0, point.max, EPS); + long bucketTotal = 0; + for (double c : point.bucketCounts) { + bucketTotal += (long) c; + } + assertEquals(0L, bucketTotal); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java new file mode 100644 index 00000000000..c47973c257f --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java @@ -0,0 +1,534 @@ +package datadog.trace.common.metrics; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.google.protobuf.CodedInputStream; +import com.google.protobuf.WireFormat; +import datadog.metrics.api.Histograms; +import datadog.metrics.impl.DDSketchHistograms; +import datadog.trace.core.otlp.common.OtlpPayload; +import datadog.trace.core.otlp.common.OtlpSender; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link OtlpStatsMetricWriter}. Drives the writer through {@code startBucket} → {@code + * add} → {@code finishBucket} with a capturing {@link OtlpSender}, then decodes the emitted + * protobuf {@code ExportMetricsServiceRequest} ({@code MetricsData}) using protobuf's {@link + * CodedInputStream}, reusing the decode idioms from {@code OtlpMetricsProtoTest}. + * + *

Lives in {@code datadog.trace.common.metrics} to reach the package-private testing constructor + * {@code OtlpStatsMetricWriter(OtlpSender)} and the {@code AggregateEntryTestUtils} factory. + * + *

Wire layout (OTLP metrics proto): + * + *

+ *   MetricsData        { ResourceMetrics resource_metrics = 1; }
+ *   ResourceMetrics    { Resource resource = 1; ScopeMetrics scope_metrics = 2; }
+ *   ScopeMetrics       { InstrumentationScope scope = 1; Metric metrics = 2; }
+ *   Metric             { string name = 1; string unit = 3; Histogram histogram = 9; }
+ *   Histogram          { HistogramDataPoint data_points = 1; AggregationTemporality = 2; }
+ *   HistogramDataPoint { fixed64 start = 2; fixed64 time = 3; fixed64 count = 4; double sum = 5;
+ *                        fixed64 bucket_counts = 6; double explicit_bounds = 7;
+ *                        KeyValue attributes = 9; double min = 11; double max = 12; }
+ * 
+ */ +class OtlpStatsMetricWriterTest { + + private static final int TEMPORALITY_DELTA = 1; + + @BeforeAll + static void registerHistogramFactory() { + Histograms.register(DDSketchHistograms.FACTORY); + } + + // ── capturing sender ────────────────────────────────────────────────────── + + private static final class CapturingSender implements OtlpSender { + int sendCount; + byte[] lastPayload; + + @Override + public void send(OtlpPayload payload) { + sendCount++; + java.nio.ByteBuffer content = payload.getContent(); + byte[] bytes = new byte[content.remaining()]; + content.get(bytes); + lastPayload = bytes; + } + + @Override + public void shutdown() {} + } + + // ── entry builders ────────────────────────────────────────────────────── + + /** Build an entry and record {@code hits} ok durations of {@code durationNanos} each. */ + private static AggregateEntry okEntry(long durationNanos, int hits) { + AggregateEntry e = + AggregateEntryTestUtils.of( + "GET /users", + "web", + "servlet.request", + null, + "web", + 0, + false, + true, + "server", + null, + null, + null, + null); + for (int i = 0; i < hits; i++) { + e.recordOneDuration(durationNanos); + } + return e; + } + + // ── decode helpers (adapted from OtlpMetricsProtoTest) ────────────────────── + + /** A decoded histogram data point. */ + private static final class DataPoint { + long start; + long end; + long count; + double sum; + double min; + double max; + final List bucketCounts = new ArrayList<>(); + final List bounds = new ArrayList<>(); + final Map attributes = new HashMap<>(); + } + + /** A decoded metric: name, unit, temporality, and its histogram data points. */ + private static final class DecodedMetric { + String name; + String unit; + int temporality = -1; + final List dataPoints = new ArrayList<>(); + } + + /** Decodes a full {@code MetricsData} payload into the single histogram metric it carries. */ + private static DecodedMetric decode(byte[] payload) throws IOException { + CodedInputStream metricsData = CodedInputStream.newInstance(payload); + int metricsTag = metricsData.readTag(); + assertEquals(1, WireFormat.getTagFieldNumber(metricsTag), "MetricsData.resource_metrics = 1"); + CodedInputStream resourceMetrics = metricsData.readBytes().newCodedInput(); + assertTrue(metricsData.isAtEnd(), "expected exactly one ResourceMetrics"); + + DecodedMetric metric = null; + while (!resourceMetrics.isAtEnd()) { + int tag = resourceMetrics.readTag(); + int field = WireFormat.getTagFieldNumber(tag); + if (field == 2) { // ScopeMetrics + metric = parseScopeMetrics(resourceMetrics.readBytes().newCodedInput()); + } else { + resourceMetrics.skipField(tag); // Resource (field 1) etc. + } + } + assertNotNull(metric, "no ScopeMetrics found"); + return metric; + } + + private static DecodedMetric parseScopeMetrics(CodedInputStream scopeMetrics) throws IOException { + DecodedMetric metric = null; + while (!scopeMetrics.isAtEnd()) { + int tag = scopeMetrics.readTag(); + if (WireFormat.getTagFieldNumber(tag) == 2) { // Metric + metric = parseMetric(scopeMetrics.readBytes().newCodedInput()); + } else { + scopeMetrics.skipField(tag); + } + } + return metric; + } + + private static DecodedMetric parseMetric(CodedInputStream m) throws IOException { + DecodedMetric metric = new DecodedMetric(); + while (!m.isAtEnd()) { + int tag = m.readTag(); + switch (WireFormat.getTagFieldNumber(tag)) { + case 1: + metric.name = m.readString(); + break; + case 3: + metric.unit = m.readString(); + break; + case 9: // Histogram + parseHistogram(m.readBytes().newCodedInput(), metric); + break; + default: + m.skipField(tag); + } + } + return metric; + } + + private static void parseHistogram(CodedInputStream h, DecodedMetric metric) throws IOException { + while (!h.isAtEnd()) { + int tag = h.readTag(); + switch (WireFormat.getTagFieldNumber(tag)) { + case 1: // HistogramDataPoint (repeated) + metric.dataPoints.add(parseDataPoint(h.readBytes().newCodedInput())); + break; + case 2: // aggregation_temporality + metric.temporality = h.readEnum(); + break; + default: + h.skipField(tag); + } + } + } + + private static DataPoint parseDataPoint(CodedInputStream dp) throws IOException { + DataPoint p = new DataPoint(); + while (!dp.isAtEnd()) { + int tag = dp.readTag(); + switch (WireFormat.getTagFieldNumber(tag)) { + case 2: + p.start = dp.readFixed64(); + break; + case 3: + p.end = dp.readFixed64(); + break; + case 4: + p.count = dp.readFixed64(); + break; + case 5: + p.sum = dp.readDouble(); + break; + case 6: + p.bucketCounts.add(dp.readFixed64()); + break; + case 7: + p.bounds.add(dp.readDouble()); + break; + case 9: // attributes (KeyValue) + readKeyValue(dp.readBytes().newCodedInput(), p.attributes); + break; + case 11: + p.min = dp.readDouble(); + break; + case 12: + p.max = dp.readDouble(); + break; + default: + dp.skipField(tag); + } + } + return p; + } + + /** + * Reads a {@code KeyValue} into {@code out}: key (field 1) → value. Value is an {@code AnyValue} + * (field 2); we decode string (field 1) and int (field 3) variants used by this writer. + */ + private static void readKeyValue(CodedInputStream kv, Map out) + throws IOException { + String key = null; + Object value = null; + while (!kv.isAtEnd()) { + int tag = kv.readTag(); + switch (WireFormat.getTagFieldNumber(tag)) { + case 1: + key = kv.readString(); + break; + case 2: // AnyValue + value = readAnyValue(kv.readBytes().newCodedInput()); + break; + default: + kv.skipField(tag); + } + } + if (key != null) { + out.put(key, value); + } + } + + private static Object readAnyValue(CodedInputStream any) throws IOException { + Object value = null; + while (!any.isAtEnd()) { + int tag = any.readTag(); + switch (WireFormat.getTagFieldNumber(tag)) { + case 1: // string_value + value = any.readString(); + break; + case 3: // int_value + value = any.readInt64(); + break; + default: + any.skipField(tag); + } + } + return value; + } + + // ── test cases ────────────────────────────────────────────────────────── + + @Test + void okOnlyEntryProducesExactlyOneDataPoint() throws IOException { + CapturingSender sender = new CapturingSender(); + OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender); + + long start = SECONDS.toNanos(1_700_000_000L); + long duration = SECONDS.toNanos(10); + writer.startBucket(1, start, duration); + writer.add(okEntry(SECONDS.toNanos(1), 3)); + writer.finishBucket(); + + assertEquals(1, sender.sendCount, "exactly one payload sent"); + DecodedMetric metric = decode(sender.lastPayload); + + assertEquals("traces.span.sdk.metrics.duration", metric.name); + assertEquals("s", metric.unit); + assertEquals(TEMPORALITY_DELTA, metric.temporality, "histogram must be delta temporality"); + assertEquals(1, metric.dataPoints.size(), "ok-only → one data point"); + + DataPoint dp = metric.dataPoints.get(0); + assertEquals(start, dp.start, "start_time_unix_nano == startBucket start"); + assertEquals(start + duration, dp.end, "time_unix_nano == start + duration"); + assertEquals(3L, dp.count); + assertEquals(3L, sumBuckets(dp), "bucket counts sum to total count"); + assertEquals(17, dp.bucketCounts.size(), "17 OTLP buckets"); + assertFalse(dp.attributes.containsKey("status.code"), "ok point carries no status.code"); + } + + @Test + void okPlusErrorEntryProducesTwoDataPointsWithErrorStatus() throws IOException { + CapturingSender sender = new CapturingSender(); + OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender); + + AggregateEntry e = + AggregateEntryTestUtils.of( + "GET /users", + "web", + "servlet.request", + null, + "web", + 0, + false, + true, + "server", + null, + null, + null, + null); + e.recordOneDuration(SECONDS.toNanos(1)); // ok + e.recordOneDuration(SECONDS.toNanos(2)); // ok + e.recordOneDuration(SECONDS.toNanos(3) | AggregateEntry.ERROR_TAG); // error + + long start = SECONDS.toNanos(1_700_000_000L); + long duration = SECONDS.toNanos(10); + writer.startBucket(1, start, duration); + writer.add(e); + writer.finishBucket(); + + DecodedMetric metric = decode(sender.lastPayload); + assertEquals(2, metric.dataPoints.size(), "ok+error → two data points"); + + long okCount = 0; + long errorCount = 0; + DataPoint errorPoint = null; + DataPoint okPoint = null; + for (DataPoint dp : metric.dataPoints) { + if ("ERROR".equals(dp.attributes.get("status.code"))) { + errorPoint = dp; + errorCount = dp.count; + } else { + okPoint = dp; + okCount = dp.count; + } + } + assertNotNull(errorPoint, "one data point must carry status.code=ERROR"); + assertNotNull(okPoint, "one data point must omit status.code"); + assertEquals(e.getOkLatencies().getCount(), (double) okCount, 1e-9); + assertEquals(e.getErrorLatencies().getCount(), (double) errorCount, 1e-9); + assertEquals(okCount, sumBuckets(okPoint)); + assertEquals(errorCount, sumBuckets(errorPoint)); + } + + @Test + void httpAndGrpcAttributesAppearOnlyWhenSet() throws IOException { + CapturingSender sender = new CapturingSender(); + OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender); + + AggregateEntry e = + AggregateEntryTestUtils.of( + "GET /users/{id}", + "web", + "servlet.request", + null, + "web", + 200, + false, + true, + "server", + null, + "GET", + "/users/{id}", + "0"); + e.recordOneDuration(SECONDS.toNanos(1)); + + long start = SECONDS.toNanos(1_700_000_000L); + writer.startBucket(1, start, SECONDS.toNanos(10)); + writer.add(e); + writer.finishBucket(); + + DecodedMetric metric = decode(sender.lastPayload); + assertEquals(1, metric.dataPoints.size()); + Map attrs = metric.dataPoints.get(0).attributes; + + assertEquals("GET", attrs.get("http.request.method")); + assertEquals(200L, attrs.get("http.response.status_code")); + assertEquals("/users/{id}", attrs.get("http.route")); + assertEquals("0", attrs.get("rpc.response.status_code")); + + // a bare entry has none of these + CapturingSender sender2 = new CapturingSender(); + OtlpStatsMetricWriter writer2 = new OtlpStatsMetricWriter(sender2); + writer2.startBucket(1, start, SECONDS.toNanos(10)); + writer2.add(okEntry(SECONDS.toNanos(1), 1)); + writer2.finishBucket(); + Map bareAttrs = decode(sender2.lastPayload).dataPoints.get(0).attributes; + assertFalse(bareAttrs.containsKey("http.request.method")); + assertFalse(bareAttrs.containsKey("http.response.status_code")); + assertFalse(bareAttrs.containsKey("http.route")); + assertFalse(bareAttrs.containsKey("rpc.response.status_code")); + } + + @Test + void emptyBucketSendsNothing() { + CapturingSender sender = new CapturingSender(); + OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender); + + writer.startBucket(0, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10)); + writer.finishBucket(); // no add() + + assertEquals(0, sender.sendCount, "empty bucket must not invoke send"); + assertNull(sender.lastPayload); + } + + @Test + void nullSenderDoesNotThrowOnNonEmptyBucket() { + // mirrors the HTTP_JSON path where createSender(config) returns null. + OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter((OtlpSender) null); + writer.startBucket(1, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10)); + writer.add(okEntry(SECONDS.toNanos(1), 2)); + try { + writer.finishBucket(); + } catch (Exception ex) { + fail("finishBucket must not throw with a null sender, but threw: " + ex); + } + } + + // ── step 4: attribute modes ────────────────────────────────────────────── + + @Test + void defaultModeCarriesDatadogAttributes() throws IOException { + CapturingSender sender = new CapturingSender(); + // otelSemanticsMode = false → datadog.* should be present + OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, false); + writer.startBucket(1, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10)); + // use an entry where all hits are top-level: OR in TOP_LEVEL_TAG + AggregateEntry e = + AggregateEntryTestUtils.of( + "servlet.request", + "web", + "servlet.request", + null, + "web", + 0, + false, + true, + "server", + null, + null, + null, + null); + e.recordOneDuration(SECONDS.toNanos(1) | AggregateEntry.TOP_LEVEL_TAG); + writer.add(e); + writer.finishBucket(); + + Map attrs = decode(sender.lastPayload).dataPoints.get(0).attributes; + assertTrue( + attrs.containsKey("datadog.operation.name"), "operation name present in default mode"); + assertTrue(attrs.containsKey("datadog.span.type"), "span type present in default mode"); + assertTrue( + attrs.containsKey("datadog.span.top_level"), "span top-level present in default mode"); + assertEquals(1L, attrs.get("datadog.span.top_level"), "all hits top-level → 1"); + // OTel-semconv attrs are present in both modes + assertTrue(attrs.containsKey("span.name"), "span.name present in both modes"); + assertFalse(attrs.containsKey("datadog.origin"), "non-synthetic entry has no datadog.origin"); + } + + @Test + void defaultModeCarriesSyntheticsOrigin() throws IOException { + CapturingSender sender = new CapturingSender(); + OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, false); + writer.startBucket(1, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10)); + AggregateEntry e = + AggregateEntryTestUtils.of( + "servlet.request", + "web", + "servlet.request", + null, + "web", + 0, + true, // synthetic=true + true, + "server", + null, + null, + null, + null); + e.recordOneDuration(SECONDS.toNanos(1)); + writer.add(e); + writer.finishBucket(); + + Map attrs = decode(sender.lastPayload).dataPoints.get(0).attributes; + assertEquals( + "synthetics", attrs.get("datadog.origin"), "synthetic entry carries datadog.origin"); + } + + @Test + void otelSemanticsModeOmitsDatadogAttributes() throws IOException { + CapturingSender sender = new CapturingSender(); + // otelSemanticsMode = true → datadog.* must be absent + OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, true); + writer.startBucket(1, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10)); + writer.add(okEntry(SECONDS.toNanos(1), 1)); + writer.finishBucket(); + + Map attrs = decode(sender.lastPayload).dataPoints.get(0).attributes; + assertFalse( + attrs.containsKey("datadog.operation.name"), + "operation name absent in otel-semantics mode"); + assertFalse(attrs.containsKey("datadog.span.type"), "span type absent in otel-semantics mode"); + assertFalse( + attrs.containsKey("datadog.span.top_level"), + "span top-level absent in otel-semantics mode"); + assertFalse(attrs.containsKey("datadog.origin"), "origin absent in otel-semantics mode"); + // OTel-semconv attrs must still be present + assertTrue(attrs.containsKey("span.name"), "span.name present even in otel-semantics mode"); + } + + private static long sumBuckets(DataPoint dp) { + long total = 0; + for (long c : dp.bucketCounts) { + total += c; + } + return total; + } +} From 5ef4652b85575828368735b3451808428e619fb4 Mon Sep 17 00:00:00 2001 From: Matthew Li Date: Thu, 18 Jun 2026 13:11:02 -0400 Subject: [PATCH 05/12] adding datadog-specific datapoint attribtues --- .../common/metrics/OtlpStatsMetricWriter.java | 27 ++++++++++++++++--- .../metrics/OtlpStatsMetricWriterTest.java | 17 ++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java index a190c5748c2..7103c3d7092 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java @@ -63,8 +63,13 @@ public final class OtlpStatsMetricWriter implements MetricWriter { private static final String RPC_RESPONSE_STATUS_CODE = "rpc.response.status_code"; private static final String STATUS_CODE = "status.code"; private static final String STATUS_CODE_ERROR = "ERROR"; + private static final String DATADOG_OPERATION_NAME = "datadog.operation.name"; + private static final String DATADOG_SPAN_TYPE = "datadog.span.type"; + private static final String DATADOG_SPAN_TOP_LEVEL = "datadog.span.top_level"; + private static final String DATADOG_ORIGIN = "datadog.origin"; @Nullable private final OtlpSender sender; + private final boolean otelSemanticsMode; private final GrowableBuffer buf = new GrowableBuffer(512); private final OtlpProtoBuffer protobuf = new OtlpProtoBuffer(8192); @@ -77,12 +82,18 @@ public final class OtlpStatsMetricWriter implements MetricWriter { private int metricBytes; public OtlpStatsMetricWriter(Config config) { - this(createSender(config)); + this(createSender(config), config.isTraceOtelSemanticsEnabled()); } // visible for testing: lets tests inject a capturing sender to decode the emitted protobuf OtlpStatsMetricWriter(@Nullable OtlpSender sender) { + this(sender, false); + } + + // visible for testing: lets tests inject a capturing sender and control the semantics mode + OtlpStatsMetricWriter(@Nullable OtlpSender sender, boolean otelSemanticsMode) { this.sender = sender; + this.otelSemanticsMode = otelSemanticsMode; } @Nullable @@ -147,12 +158,10 @@ private void addDataPoint(AggregateEntry entry, Histogram latencies, boolean err } private void writeDataPointAttributes(AggregateEntry entry, boolean error) { - // TODO(step 4): branch on isTraceOtelSemanticsEnabled() to add the datadog.* attribute set in - // default mode and to omit it in OTel-semantics mode. The OTel-semconv attributes below are - // emitted in both modes. if (error) { writeStringAttribute(STATUS_CODE, STATUS_CODE_ERROR); } + // OTel semconv attrs are emitted in both modes writeStringAttribute(SPAN_NAME, entry.getResource()); writeStringAttribute(SPAN_KIND, entry.getSpanKind()); if (entry.getHttpMethod() != null) { @@ -167,6 +176,16 @@ private void writeDataPointAttributes(AggregateEntry entry, boolean error) { if (entry.getGrpcStatusCode() != null) { writeStringAttribute(RPC_RESPONSE_STATUS_CODE, entry.getGrpcStatusCode()); } + // Default (Datadog) mode: emit datadog.* per-point attributes + if (!otelSemanticsMode) { + writeStringAttribute(DATADOG_OPERATION_NAME, entry.getOperationName()); + writeStringAttribute(DATADOG_SPAN_TYPE, entry.getType()); + writeLongAttribute( + DATADOG_SPAN_TOP_LEVEL, entry.getTopLevelCount() == entry.getHitCount() ? 1L : 0L); + // Emit the full origin (synthetics, synthetics-browser, rum, ciapp-test, lambda, ...) when + // present; writeStringAttribute no-ops on a null value. + writeStringAttribute(DATADOG_ORIGIN, entry.getOrigin()); + } } private void writeStringAttribute(String key, @Nullable UTF8BytesString value) { diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java index c47973c257f..cffa781cc15 100644 --- a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java @@ -524,6 +524,23 @@ void otelSemanticsModeOmitsDatadogAttributes() throws IOException { assertTrue(attrs.containsKey("span.name"), "span.name present even in otel-semantics mode"); } + @Test + void defaultModePassesFullOriginThrough() throws IOException { + // A non-synthetics origin must be emitted verbatim, not collapsed to a synthetics flag. + CapturingSender sender = new CapturingSender(); + OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, false); + writer.startBucket(1, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10)); + AggregateEntry e = + AggregateEntryTestUtils.ofOrigin( + "servlet.request", "web", "servlet.request", "web", "server", "ciapp-test"); + e.recordOneDuration(SECONDS.toNanos(1)); + writer.add(e); + writer.finishBucket(); + + Map attrs = decode(sender.lastPayload).dataPoints.get(0).attributes; + assertEquals("ciapp-test", attrs.get("datadog.origin"), "full origin emitted verbatim"); + } + private static long sumBuckets(DataPoint dp) { long total = 0; for (long c : dp.bucketCounts) { From a1a428c973429a49b91fab596f80520c5cf5f13a Mon Sep 17 00:00:00 2001 From: Matthew Li Date: Tue, 23 Jun 2026 09:38:49 -0400 Subject: [PATCH 06/12] more specific logging --- .../datadog/trace/common/metrics/OtlpStatsMetricWriter.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java index 7103c3d7092..28ec2c3bed3 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java @@ -116,8 +116,9 @@ private static OtlpSender createSender(Config config) { config.getOtlpMetricsCompression()); default: // HTTP_JSON has no protobuf-free encoder yet; JSON transport is deferred per the plan. - log.debug( - "Unsupported OTLP metrics protocol for trace metrics export: {}", + log.warn( + "OTLP trace metrics export disabled: unsupported metrics protocol {}. " + + "Set OTEL_EXPORTER_OTLP_METRICS_PROTOCOL to grpc or http/protobuf.", config.getOtlpMetricsProtocol()); return null; } From 72c12bb847d2b0e585e1d243589777fed79324a7 Mon Sep 17 00:00:00 2001 From: Matthew Li Date: Wed, 24 Jun 2026 14:03:17 -0400 Subject: [PATCH 07/12] refactor origin usage in test helpers and add origin specific testS remove irrelevant test following rebase --- .../metrics/OtlpStatsMetricWriterTest.java | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java index cffa781cc15..67c5fdae106 100644 --- a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java @@ -523,24 +523,6 @@ void otelSemanticsModeOmitsDatadogAttributes() throws IOException { // OTel-semconv attrs must still be present assertTrue(attrs.containsKey("span.name"), "span.name present even in otel-semantics mode"); } - - @Test - void defaultModePassesFullOriginThrough() throws IOException { - // A non-synthetics origin must be emitted verbatim, not collapsed to a synthetics flag. - CapturingSender sender = new CapturingSender(); - OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, false); - writer.startBucket(1, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10)); - AggregateEntry e = - AggregateEntryTestUtils.ofOrigin( - "servlet.request", "web", "servlet.request", "web", "server", "ciapp-test"); - e.recordOneDuration(SECONDS.toNanos(1)); - writer.add(e); - writer.finishBucket(); - - Map attrs = decode(sender.lastPayload).dataPoints.get(0).attributes; - assertEquals("ciapp-test", attrs.get("datadog.origin"), "full origin emitted verbatim"); - } - private static long sumBuckets(DataPoint dp) { long total = 0; for (long c : dp.bucketCounts) { From 966296715e724c3fce0036f1dcf16882d6c3c5f0 Mon Sep 17 00:00:00 2001 From: Matthew Li Date: Thu, 25 Jun 2026 22:59:23 -0400 Subject: [PATCH 08/12] rename histogram class and refactor tests --- ...ts.java => OtlpStatsHistogramBuckets.java} | 8 +- .../common/metrics/OtlpStatsMetricWriter.java | 24 +- ...ava => OtlpStatsHistogramBucketsTest.java} | 26 +- .../metrics/OtlpStatsMetricWriterTest.java | 269 ++++++------------ 4 files changed, 112 insertions(+), 215 deletions(-) rename dd-trace-core/src/main/java/datadog/trace/common/metrics/{OtlpHistogramBuckets.java => OtlpStatsHistogramBuckets.java} (86%) rename dd-trace-core/src/test/java/datadog/trace/common/metrics/{OtlpHistogramBucketsTest.java => OtlpStatsHistogramBucketsTest.java} (80%) diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsHistogramBuckets.java similarity index 86% rename from dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java rename to dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsHistogramBuckets.java index a07165484ed..372cac73bae 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpHistogramBuckets.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsHistogramBuckets.java @@ -11,8 +11,8 @@ * nanoseconds) onto the fixed explicit-bounds histogram layout mandated by the OTLP Trace * Metrics Export RFC. */ -final class OtlpHistogramBuckets { - private OtlpHistogramBuckets() {} +final class OtlpStatsHistogramBuckets { + private OtlpStatsHistogramBuckets() {} private static final double NANOS_PER_SECOND = 1_000_000_000d; @@ -42,9 +42,7 @@ static int bucketIndex(double seconds) { /** * Re-bins {@code histogram} (nanosecond-valued) into an {@link OtlpHistogramPoint} expressed in - * seconds with OTLP's fixed bucket layout. {@code count}, {@code min}, and {@code max} are taken - * directly from the sketch; {@code sumNanos} is the exact duration sum tracked alongside the - * sketch by {@link AggregateEntry} (the DDSketch-derived sum would only be approximate). + * seconds with OTLP's fixed bucket layout. */ static OtlpHistogramPoint toHistogramPoint(Histogram histogram, long sumNanos) { long[] bucketCounts = new long[BOUNDS_SECONDS.length + 1]; diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java index 28ec2c3bed3..b199bf745fe 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java @@ -67,10 +67,12 @@ public final class OtlpStatsMetricWriter implements MetricWriter { private static final String DATADOG_SPAN_TYPE = "datadog.span.type"; private static final String DATADOG_SPAN_TOP_LEVEL = "datadog.span.top_level"; private static final String DATADOG_ORIGIN = "datadog.origin"; + private static final String SYNTHETICS_ORIGIN = "synthetics"; @Nullable private final OtlpSender sender; private final boolean otelSemanticsMode; + // Need a temporary buffer to know what size to write for the final protobuf buffer private final GrowableBuffer buf = new GrowableBuffer(512); private final OtlpProtoBuffer protobuf = new OtlpProtoBuffer(8192); @@ -85,12 +87,8 @@ public OtlpStatsMetricWriter(Config config) { this(createSender(config), config.isTraceOtelSemanticsEnabled()); } - // visible for testing: lets tests inject a capturing sender to decode the emitted protobuf - OtlpStatsMetricWriter(@Nullable OtlpSender sender) { - this(sender, false); - } - - // visible for testing: lets tests inject a capturing sender and control the semantics mode + // visible for testing: lets tests inject a capturing sender to decode the emitted protobuf and + // control the semantics mode OtlpStatsMetricWriter(@Nullable OtlpSender sender, boolean otelSemanticsMode) { this.sender = sender; this.otelSemanticsMode = otelSemanticsMode; @@ -154,7 +152,7 @@ private void addDataPoint(AggregateEntry entry, Histogram latencies, boolean err writeTag(buf, DP_TIME_FIELD, I64_WIRE_TYPE); writeI64(buf, endNanos); long sumNanos = error ? entry.getErrorDuration() : entry.getOkDuration(); - OtlpHistogramPoint point = OtlpHistogramBuckets.toHistogramPoint(latencies, sumNanos); + OtlpHistogramPoint point = OtlpStatsHistogramBuckets.toHistogramPoint(latencies, sumNanos); metricBytes += recordDataPointMessage(buf, point, protobuf); } @@ -165,16 +163,16 @@ private void writeDataPointAttributes(AggregateEntry entry, boolean error) { // OTel semconv attrs are emitted in both modes writeStringAttribute(SPAN_NAME, entry.getResource()); writeStringAttribute(SPAN_KIND, entry.getSpanKind()); - if (entry.getHttpMethod() != null) { + if (entry.hasHttpMethod()) { writeStringAttribute(HTTP_REQUEST_METHOD, entry.getHttpMethod()); } if (entry.getHttpStatusCode() != 0) { writeLongAttribute(HTTP_RESPONSE_STATUS_CODE, entry.getHttpStatusCode()); } - if (entry.getHttpEndpoint() != null) { + if (entry.hasHttpEndpoint()) { writeStringAttribute(HTTP_ROUTE, entry.getHttpEndpoint()); } - if (entry.getGrpcStatusCode() != null) { + if (entry.hasGrpcStatusCode()) { writeStringAttribute(RPC_RESPONSE_STATUS_CODE, entry.getGrpcStatusCode()); } // Default (Datadog) mode: emit datadog.* per-point attributes @@ -183,9 +181,9 @@ private void writeDataPointAttributes(AggregateEntry entry, boolean error) { writeStringAttribute(DATADOG_SPAN_TYPE, entry.getType()); writeLongAttribute( DATADOG_SPAN_TOP_LEVEL, entry.getTopLevelCount() == entry.getHitCount() ? 1L : 0L); - // Emit the full origin (synthetics, synthetics-browser, rum, ciapp-test, lambda, ...) when - // present; writeStringAttribute no-ops on a null value. - writeStringAttribute(DATADOG_ORIGIN, entry.getOrigin()); + if (entry.isSynthetics()) { + writeStringAttribute(DATADOG_ORIGIN, SYNTHETICS_ORIGIN); + } } } diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpHistogramBucketsTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsHistogramBucketsTest.java similarity index 80% rename from dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpHistogramBucketsTest.java rename to dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsHistogramBucketsTest.java index ad0de52e0ec..d37cde2d92a 100644 --- a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpHistogramBucketsTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsHistogramBucketsTest.java @@ -1,9 +1,9 @@ package datadog.trace.common.metrics; -import static datadog.trace.common.metrics.OtlpHistogramBuckets.BOUNDS_SECONDS; -import static datadog.trace.common.metrics.OtlpHistogramBuckets.EXPLICIT_BOUNDS; -import static datadog.trace.common.metrics.OtlpHistogramBuckets.bucketIndex; -import static datadog.trace.common.metrics.OtlpHistogramBuckets.toHistogramPoint; +import static datadog.trace.common.metrics.OtlpStatsHistogramBuckets.BOUNDS_SECONDS; +import static datadog.trace.common.metrics.OtlpStatsHistogramBuckets.EXPLICIT_BOUNDS; +import static datadog.trace.common.metrics.OtlpStatsHistogramBuckets.bucketIndex; +import static datadog.trace.common.metrics.OtlpStatsHistogramBuckets.toHistogramPoint; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -20,13 +20,10 @@ import org.junit.jupiter.params.provider.MethodSource; /** - * Unit tests for {@link OtlpHistogramBuckets}, the helper that re-bins a nanosecond-valued DDSketch - * onto the fixed OTLP explicit-bounds histogram layout (in seconds). - * - *

Both methods under test ({@code bucketIndex}, {@code toHistogramPoint}) are package-private, - * so this test lives in {@code datadog.trace.common.metrics}. + * Unit tests for {@link OtlpStatsHistogramBuckets}, the helper that re-bins a nanosecond-valued + * DDSketch onto the fixed OTLP explicit-bounds histogram layout (in seconds). */ -class OtlpHistogramBucketsTest { +class OtlpStatsHistogramBucketsTest { private static final double NANOS_PER_SECOND = 1_000_000_000d; @@ -85,10 +82,8 @@ void explicitBoundsHas17EntriesEndingInInfinity() { @Test void toHistogramPointSummary() { Histogram h = Histogram.newHistogram(); - // Samples spanning the layout, including a 1ns duration — the upstream-clamped minimum - // (DDSpan#finishAndAddToTrace does Math.max(1, durationNano)). 1ns is far below the smallest - // bound (0.002s) and must land in bucket 0, not be dropped. 1ms also falls in bucket 0, so the - // two sub-2ms samples share it; 100ms → bucket 6, 3s → bucket 13. + // 1ns and 1ms are below the smallest bound (0.002s) and must land in bucket 0; 100ms → bucket + // 6, 3s → bucket 13. long[] samplesNanos = { 1L, (long) (0.001 * NANOS_PER_SECOND), @@ -122,9 +117,6 @@ void toHistogramPointSummary() { // sum equals the sumNanos ARGUMENT converted to seconds, not the sketch sum. assertEquals(42.0, point.sum, EPS); - // The two sub-2ms samples (1ns + 1ms) both land in bucket 0; nothing is lost: bucket counts - // sum to the total count. The count==Σbuckets invariant guards against a future histogram - // config that parks tiny values in a zero/negative store (excluded from getBinCounts). long bucketTotal = 0; for (int i = 0; i < point.bucketCounts.size(); i++) { long c = (long) point.bucketCounts.get(i).doubleValue(); diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java index 67c5fdae106..92088f510cf 100644 --- a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java @@ -19,8 +19,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.annotation.Nullable; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; /** * Tests for {@link OtlpStatsMetricWriter}. Drives the writer through {@code startBucket} → {@code @@ -28,9 +31,6 @@ * protobuf {@code ExportMetricsServiceRequest} ({@code MetricsData}) using protobuf's {@link * CodedInputStream}, reusing the decode idioms from {@code OtlpMetricsProtoTest}. * - *

Lives in {@code datadog.trace.common.metrics} to reach the package-private testing constructor - * {@code OtlpStatsMetricWriter(OtlpSender)} and the {@code AggregateEntryTestUtils} factory. - * *

Wire layout (OTLP metrics proto): * *

@@ -47,6 +47,8 @@
 class OtlpStatsMetricWriterTest {
 
   private static final int TEMPORALITY_DELTA = 1;
+  private static final long BUCKET_START = SECONDS.toNanos(1_700_000_000L);
+  private static final long BUCKET_DURATION = SECONDS.toNanos(10);
 
   @BeforeAll
   static void registerHistogramFactory() {
@@ -74,23 +76,32 @@ public void shutdown() {}
 
   // ── entry builders ──────────────────────────────────────────────────────
 
+  private static AggregateEntry entry(
+      String resource,
+      boolean synthetic,
+      int httpStatusCode,
+      @Nullable String httpMethod,
+      @Nullable String httpEndpoint,
+      @Nullable String grpcStatusCode) {
+    return AggregateEntryTestUtils.of(
+        resource,
+        "web",
+        "servlet.request",
+        null,
+        "web",
+        httpStatusCode,
+        synthetic,
+        true,
+        "server",
+        null,
+        httpMethod,
+        httpEndpoint,
+        grpcStatusCode);
+  }
+
   /** Build an entry and record {@code hits} ok durations of {@code durationNanos} each. */
   private static AggregateEntry okEntry(long durationNanos, int hits) {
-    AggregateEntry e =
-        AggregateEntryTestUtils.of(
-            "GET /users",
-            "web",
-            "servlet.request",
-            null,
-            "web",
-            0,
-            false,
-            true,
-            "server",
-            null,
-            null,
-            null,
-            null);
+    AggregateEntry e = entry("GET /users", false, 0, null, null, null);
     for (int i = 0; i < hits; i++) {
       e.recordOneDuration(durationNanos);
     }
@@ -99,16 +110,15 @@ private static AggregateEntry okEntry(long durationNanos, int hits) {
 
   // ── decode helpers (adapted from OtlpMetricsProtoTest) ──────────────────────
 
-  /** A decoded histogram data point. */
+  /**
+   * A decoded histogram data point. Only the fields this test asserts on are decoded: the window
+   * timestamps, the total count, and the attributes. Per-bucket contents (bucket_counts,
+   * explicit_bounds), sum, min, and max are intentionally not decoded here.
+   */
   private static final class DataPoint {
     long start;
     long end;
     long count;
-    double sum;
-    double min;
-    double max;
-    final List bucketCounts = new ArrayList<>();
-    final List bounds = new ArrayList<>();
     final Map attributes = new HashMap<>();
   }
 
@@ -206,25 +216,10 @@ private static DataPoint parseDataPoint(CodedInputStream dp) throws IOException
         case 4:
           p.count = dp.readFixed64();
           break;
-        case 5:
-          p.sum = dp.readDouble();
-          break;
-        case 6:
-          p.bucketCounts.add(dp.readFixed64());
-          break;
-        case 7:
-          p.bounds.add(dp.readDouble());
-          break;
         case 9: // attributes (KeyValue)
           readKeyValue(dp.readBytes().newCodedInput(), p.attributes);
           break;
-        case 11:
-          p.min = dp.readDouble();
-          break;
-        case 12:
-          p.max = dp.readDouble();
-          break;
-        default:
+        default: // sum, bucket_counts, explicit_bounds, min, max — not asserted here
           dp.skipField(tag);
       }
     }
@@ -275,21 +270,29 @@ private static Object readAnyValue(CodedInputStream any) throws IOException {
     return value;
   }
 
-  // ── test cases ──────────────────────────────────────────────────────────
+  // ── writer driver ─────────────────────────────────────────────────────────
 
-  @Test
-  void okOnlyEntryProducesExactlyOneDataPoint() throws IOException {
+  /**
+   * Drives the writer through one full {@code startBucket → add → finishBucket} cycle for {@code
+   * entry} over the fixed {@link #BUCKET_START}/{@link #BUCKET_DURATION} window, asserts that
+   * exactly one payload was sent, and returns the decoded metric.
+   */
+  private static DecodedMetric writeAndDecode(boolean otelSemanticsMode, AggregateEntry entry)
+      throws IOException {
     CapturingSender sender = new CapturingSender();
-    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender);
-
-    long start = SECONDS.toNanos(1_700_000_000L);
-    long duration = SECONDS.toNanos(10);
-    writer.startBucket(1, start, duration);
-    writer.add(okEntry(SECONDS.toNanos(1), 3));
+    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, otelSemanticsMode);
+    writer.startBucket(1, BUCKET_START, BUCKET_DURATION);
+    writer.add(entry);
     writer.finishBucket();
-
     assertEquals(1, sender.sendCount, "exactly one payload sent");
-    DecodedMetric metric = decode(sender.lastPayload);
+    return decode(sender.lastPayload);
+  }
+
+  // ── test cases ──────────────────────────────────────────────────────────
+
+  @Test
+  void okOnlyEntryProducesExactlyOneDataPoint() throws IOException {
+    DecodedMetric metric = writeAndDecode(false, okEntry(SECONDS.toNanos(1), 3));
 
     assertEquals("traces.span.sdk.metrics.duration", metric.name);
     assertEquals("s", metric.unit);
@@ -297,45 +300,20 @@ void okOnlyEntryProducesExactlyOneDataPoint() throws IOException {
     assertEquals(1, metric.dataPoints.size(), "ok-only → one data point");
 
     DataPoint dp = metric.dataPoints.get(0);
-    assertEquals(start, dp.start, "start_time_unix_nano == startBucket start");
-    assertEquals(start + duration, dp.end, "time_unix_nano == start + duration");
+    assertEquals(BUCKET_START, dp.start, "start_time_unix_nano == startBucket start");
+    assertEquals(BUCKET_START + BUCKET_DURATION, dp.end, "time_unix_nano == start + duration");
     assertEquals(3L, dp.count);
-    assertEquals(3L, sumBuckets(dp), "bucket counts sum to total count");
-    assertEquals(17, dp.bucketCounts.size(), "17 OTLP buckets");
     assertFalse(dp.attributes.containsKey("status.code"), "ok point carries no status.code");
   }
 
   @Test
   void okPlusErrorEntryProducesTwoDataPointsWithErrorStatus() throws IOException {
-    CapturingSender sender = new CapturingSender();
-    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender);
-
-    AggregateEntry e =
-        AggregateEntryTestUtils.of(
-            "GET /users",
-            "web",
-            "servlet.request",
-            null,
-            "web",
-            0,
-            false,
-            true,
-            "server",
-            null,
-            null,
-            null,
-            null);
+    AggregateEntry e = entry("GET /users", false, 0, null, null, null);
     e.recordOneDuration(SECONDS.toNanos(1)); // ok
     e.recordOneDuration(SECONDS.toNanos(2)); // ok
     e.recordOneDuration(SECONDS.toNanos(3) | AggregateEntry.ERROR_TAG); // error
 
-    long start = SECONDS.toNanos(1_700_000_000L);
-    long duration = SECONDS.toNanos(10);
-    writer.startBucket(1, start, duration);
-    writer.add(e);
-    writer.finishBucket();
-
-    DecodedMetric metric = decode(sender.lastPayload);
+    DecodedMetric metric = writeAndDecode(false, e);
     assertEquals(2, metric.dataPoints.size(), "ok+error → two data points");
 
     long okCount = 0;
@@ -355,38 +333,14 @@ void okPlusErrorEntryProducesTwoDataPointsWithErrorStatus() throws IOException {
     assertNotNull(okPoint, "one data point must omit status.code");
     assertEquals(e.getOkLatencies().getCount(), (double) okCount, 1e-9);
     assertEquals(e.getErrorLatencies().getCount(), (double) errorCount, 1e-9);
-    assertEquals(okCount, sumBuckets(okPoint));
-    assertEquals(errorCount, sumBuckets(errorPoint));
   }
 
   @Test
   void httpAndGrpcAttributesAppearOnlyWhenSet() throws IOException {
-    CapturingSender sender = new CapturingSender();
-    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender);
-
-    AggregateEntry e =
-        AggregateEntryTestUtils.of(
-            "GET /users/{id}",
-            "web",
-            "servlet.request",
-            null,
-            "web",
-            200,
-            false,
-            true,
-            "server",
-            null,
-            "GET",
-            "/users/{id}",
-            "0");
+    AggregateEntry e = entry("GET /users/{id}", false, 200, "GET", "/users/{id}", "0");
     e.recordOneDuration(SECONDS.toNanos(1));
 
-    long start = SECONDS.toNanos(1_700_000_000L);
-    writer.startBucket(1, start, SECONDS.toNanos(10));
-    writer.add(e);
-    writer.finishBucket();
-
-    DecodedMetric metric = decode(sender.lastPayload);
+    DecodedMetric metric = writeAndDecode(false, e);
     assertEquals(1, metric.dataPoints.size());
     Map attrs = metric.dataPoints.get(0).attributes;
 
@@ -396,12 +350,8 @@ void httpAndGrpcAttributesAppearOnlyWhenSet() throws IOException {
     assertEquals("0", attrs.get("rpc.response.status_code"));
 
     // a bare entry has none of these
-    CapturingSender sender2 = new CapturingSender();
-    OtlpStatsMetricWriter writer2 = new OtlpStatsMetricWriter(sender2);
-    writer2.startBucket(1, start, SECONDS.toNanos(10));
-    writer2.add(okEntry(SECONDS.toNanos(1), 1));
-    writer2.finishBucket();
-    Map bareAttrs = decode(sender2.lastPayload).dataPoints.get(0).attributes;
+    Map bareAttrs =
+        writeAndDecode(false, okEntry(SECONDS.toNanos(1), 1)).dataPoints.get(0).attributes;
     assertFalse(bareAttrs.containsKey("http.request.method"));
     assertFalse(bareAttrs.containsKey("http.response.status_code"));
     assertFalse(bareAttrs.containsKey("http.route"));
@@ -411,9 +361,9 @@ void httpAndGrpcAttributesAppearOnlyWhenSet() throws IOException {
   @Test
   void emptyBucketSendsNothing() {
     CapturingSender sender = new CapturingSender();
-    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender);
+    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, false);
 
-    writer.startBucket(0, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10));
+    writer.startBucket(0, BUCKET_START, BUCKET_DURATION);
     writer.finishBucket(); // no add()
 
     assertEquals(0, sender.sendCount, "empty bucket must not invoke send");
@@ -423,8 +373,8 @@ void emptyBucketSendsNothing() {
   @Test
   void nullSenderDoesNotThrowOnNonEmptyBucket() {
     // mirrors the HTTP_JSON path where createSender(config) returns null.
-    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter((OtlpSender) null);
-    writer.startBucket(1, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10));
+    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(null, false);
+    writer.startBucket(1, BUCKET_START, BUCKET_DURATION);
     writer.add(okEntry(SECONDS.toNanos(1), 2));
     try {
       writer.finishBucket();
@@ -433,35 +383,13 @@ void nullSenderDoesNotThrowOnNonEmptyBucket() {
     }
   }
 
-  // ── step 4: attribute modes ──────────────────────────────────────────────
-
   @Test
   void defaultModeCarriesDatadogAttributes() throws IOException {
-    CapturingSender sender = new CapturingSender();
-    // otelSemanticsMode = false → datadog.* should be present
-    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, false);
-    writer.startBucket(1, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10));
     // use an entry where all hits are top-level: OR in TOP_LEVEL_TAG
-    AggregateEntry e =
-        AggregateEntryTestUtils.of(
-            "servlet.request",
-            "web",
-            "servlet.request",
-            null,
-            "web",
-            0,
-            false,
-            true,
-            "server",
-            null,
-            null,
-            null,
-            null);
+    AggregateEntry e = entry("servlet.request", false, 0, null, null, null);
     e.recordOneDuration(SECONDS.toNanos(1) | AggregateEntry.TOP_LEVEL_TAG);
-    writer.add(e);
-    writer.finishBucket();
 
-    Map attrs = decode(sender.lastPayload).dataPoints.get(0).attributes;
+    Map attrs = writeAndDecode(false, e).dataPoints.get(0).attributes;
     assertTrue(
         attrs.containsKey("datadog.operation.name"), "operation name present in default mode");
     assertTrue(attrs.containsKey("datadog.span.type"), "span type present in default mode");
@@ -470,48 +398,36 @@ void defaultModeCarriesDatadogAttributes() throws IOException {
     assertEquals(1L, attrs.get("datadog.span.top_level"), "all hits top-level → 1");
     // OTel-semconv attrs are present in both modes
     assertTrue(attrs.containsKey("span.name"), "span.name present in both modes");
-    assertFalse(attrs.containsKey("datadog.origin"), "non-synthetic entry has no datadog.origin");
+    // datadog.origin presence/absence is covered by defaultModeEmitsSyntheticOrigin
   }
 
-  @Test
-  void defaultModeCarriesSyntheticsOrigin() throws IOException {
-    CapturingSender sender = new CapturingSender();
-    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, false);
-    writer.startBucket(1, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10));
-    AggregateEntry e =
-        AggregateEntryTestUtils.of(
-            "servlet.request",
-            "web",
-            "servlet.request",
-            null,
-            "web",
-            0,
-            true, // synthetic=true
-            true,
-            "server",
-            null,
-            null,
-            null,
-            null);
+  /**
+   * In default mode a synthetic entry emits {@code datadog.origin = "synthetics"}; a non-synthetic
+   * entry omits the attribute. Origin has collapsed to a boolean {@code synthetic} flag upstream,
+   * so {@code "synthetics"} is the only origin value that can reach the writer.
+   */
+  @ParameterizedTest(name = "synthetic={0} → datadog.origin={1}")
+  @CsvSource(
+      nullValues = "NULL",
+      value = {"false, NULL", "true, synthetics"})
+  void defaultModeEmitsSyntheticOrigin(boolean synthetic, String expectedOrigin)
+      throws IOException {
+    AggregateEntry e = entry("servlet.request", synthetic, 0, null, null, null);
     e.recordOneDuration(SECONDS.toNanos(1));
-    writer.add(e);
-    writer.finishBucket();
 
-    Map attrs = decode(sender.lastPayload).dataPoints.get(0).attributes;
-    assertEquals(
-        "synthetics", attrs.get("datadog.origin"), "synthetic entry carries datadog.origin");
+    Map attrs = writeAndDecode(false, e).dataPoints.get(0).attributes;
+    if (expectedOrigin == null) {
+      assertFalse(attrs.containsKey("datadog.origin"), "non-synthetic → datadog.origin absent");
+    } else {
+      assertEquals(expectedOrigin, attrs.get("datadog.origin"), "synthetic → datadog.origin");
+    }
   }
 
   @Test
   void otelSemanticsModeOmitsDatadogAttributes() throws IOException {
-    CapturingSender sender = new CapturingSender();
     // otelSemanticsMode = true → datadog.* must be absent
-    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, true);
-    writer.startBucket(1, SECONDS.toNanos(1_700_000_000L), SECONDS.toNanos(10));
-    writer.add(okEntry(SECONDS.toNanos(1), 1));
-    writer.finishBucket();
-
-    Map attrs = decode(sender.lastPayload).dataPoints.get(0).attributes;
+    Map attrs =
+        writeAndDecode(true, okEntry(SECONDS.toNanos(1), 1)).dataPoints.get(0).attributes;
     assertFalse(
         attrs.containsKey("datadog.operation.name"),
         "operation name absent in otel-semantics mode");
@@ -523,11 +439,4 @@ void otelSemanticsModeOmitsDatadogAttributes() throws IOException {
     // OTel-semconv attrs must still be present
     assertTrue(attrs.containsKey("span.name"), "span.name present even in otel-semantics mode");
   }
-  private static long sumBuckets(DataPoint dp) {
-    long total = 0;
-    for (long c : dp.bucketCounts) {
-      total += c;
-    }
-    return total;
-  }
 }

From aa351f0c04b872ae462e48cc19f3860645878e55 Mon Sep 17 00:00:00 2001
From: Matthew Li 
Date: Sat, 27 Jun 2026 09:11:21 -0400
Subject: [PATCH 09/12] force temporality delta on tracemetrics histogram

---
 .../common/metrics/OtlpStatsMetricWriter.java    |  4 +++-
 .../core/otlp/metrics/OtlpMetricsProto.java      | 16 +++++++++++++++-
 2 files changed, 18 insertions(+), 2 deletions(-)

diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java
index b199bf745fe..517077ed90b 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java
@@ -207,7 +207,9 @@ private void writeLongAttribute(String key, long value) {
   public void finishBucket() {
     try {
       if (metricBytes > 0) {
-        scopedBytes += recordMetricMessage(buf, METRIC_DESCRIPTOR, metricBytes, protobuf);
+        // trace stats histograms are inherently per-interval deltas (buckets are cleared after
+        // every flush), so always encode DELTA regardless of the temporality preference
+        scopedBytes += recordMetricMessage(buf, METRIC_DESCRIPTOR, metricBytes, protobuf, true);
       }
       if (scopedBytes > 0) {
         payloadBytes += recordScopedMetricsMessage(buf, SCOPE, scopedBytes, protobuf);
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/metrics/OtlpMetricsProto.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/metrics/OtlpMetricsProto.java
index 751649940ba..a92c0826340 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/metrics/OtlpMetricsProto.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/metrics/OtlpMetricsProto.java
@@ -60,6 +60,20 @@ public static int recordMetricMessage(
       OtelInstrumentDescriptor descriptor,
       int nestedDataPointBytes,
       OtlpProtoBuffer protobuf) {
+    return recordMetricMessage(buf, descriptor, nestedDataPointBytes, protobuf, false);
+  }
+
+  /**
+   * Records a metric message after its nested data point messages have been recorded. When {@code
+   * forceDelta} is {@code true}, a histogram is encoded as DELTA regardless of the configured
+   * temporality preference (for sources whose data points are inherently per-interval deltas).
+   */
+  public static int recordMetricMessage(
+      GrowableBuffer buf,
+      OtelInstrumentDescriptor descriptor,
+      int nestedDataPointBytes,
+      OtlpProtoBuffer protobuf,
+      boolean forceDelta) {
 
     writeTag(buf, 1, LEN_WIRE_TYPE);
     writeString(buf, descriptor.getName().getUtf8Bytes());
@@ -107,7 +121,7 @@ public static int recordMetricMessage(
         writeTag(buf, 9, LEN_WIRE_TYPE);
         writeVarInt(buf, nestedDataPointBytes + 2);
         writeTag(buf, 2, VARINT_WIRE_TYPE);
-        writeVarInt(buf, HISTOGRAM_TEMPORALITY);
+        writeVarInt(buf, forceDelta ? TEMPORALITY_DELTA : HISTOGRAM_TEMPORALITY);
         break;
       default:
         throw new IllegalArgumentException("Unknown instrument type: " + descriptor.getType());

From 9cf8a2566a0684ae01c4ed14664c8424550fd2dd Mon Sep 17 00:00:00 2001
From: Matthew Li 
Date: Sat, 27 Jun 2026 09:27:02 -0400
Subject: [PATCH 10/12] gating error latencies on empty as well

---
 .../common/metrics/OtlpStatsMetricWriter.java |  2 +-
 .../metrics/OtlpStatsMetricWriterTest.java    | 35 +++++++++++++++++++
 2 files changed, 36 insertions(+), 1 deletion(-)

diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java
index 517077ed90b..62518b80785 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java
@@ -140,7 +140,7 @@ public void add(AggregateEntry entry) {
     }
 
     Histogram errorLatencies = entry.getErrorLatencies();
-    if (errorLatencies != null) {
+    if (errorLatencies != null && !errorLatencies.isEmpty()) {
       addDataPoint(entry, errorLatencies, true);
     }
   }
diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java
index 92088f510cf..c825ed61d6b 100644
--- a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java
+++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java
@@ -335,6 +335,41 @@ void okPlusErrorEntryProducesTwoDataPointsWithErrorStatus() throws IOException {
     assertEquals(e.getErrorLatencies().getCount(), (double) errorCount, 1e-9);
   }
 
+  @Test
+  void errorSeriesDoesNotLingerAfterClearWhenBucketHasOnlyOkHits() throws IOException {
+    CapturingSender sender = new CapturingSender();
+    OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, false);
+
+    // Bucket 1: the entry sees an error, so its error histogram is allocated and emits a point.
+    AggregateEntry e = entry("GET /users", null, 0, null, null, null);
+    e.recordOneDuration(SECONDS.toNanos(1)); // ok
+    e.recordOneDuration(SECONDS.toNanos(3) | AggregateEntry.ERROR_TAG); // error
+
+    writer.startBucket(1, BUCKET_START, BUCKET_DURATION);
+    writer.add(e);
+    writer.finishBucket();
+    DecodedMetric bucket1 = decode(sender.lastPayload);
+    assertEquals(2, bucket1.dataPoints.size(), "bucket with an error → ok+error data points");
+    assertTrue(
+        bucket1.dataPoints.stream()
+            .anyMatch(dp -> "ERROR".equals(dp.attributes.get("status.code"))),
+        "bucket 1 must carry a status.code=ERROR point");
+
+    // Bucket 2: same entry, reset then only OK hits. errorLatencies survives clear() (cleared, not
+    // nulled), so a non-null-but-empty histogram must NOT emit a phantom zero-count error series.
+    e.clear();
+    e.recordOneDuration(SECONDS.toNanos(2)); // ok only
+
+    writer.startBucket(2, BUCKET_START + BUCKET_DURATION, BUCKET_DURATION);
+    writer.add(e);
+    writer.finishBucket();
+    DecodedMetric bucket2 = decode(sender.lastPayload);
+    assertEquals(1, bucket2.dataPoints.size(), "ok-only bucket → exactly one data point");
+    assertFalse(
+        bucket2.dataPoints.get(0).attributes.containsKey("status.code"),
+        "recovered entry must not emit a lingering status.code=ERROR series");
+  }
+
   @Test
   void httpAndGrpcAttributesAppearOnlyWhenSet() throws IOException {
     AggregateEntry e = entry("GET /users/{id}", false, 200, "GET", "/users/{id}", "0");

From f04268a12c7de44430f09489113061c458855c47 Mon Sep 17 00:00:00 2001
From: Matthew Li 
Date: Tue, 30 Jun 2026 15:14:29 -0400
Subject: [PATCH 11/12] update metricintegrationtest

---
 .../datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java
index c825ed61d6b..31ed707cabd 100644
--- a/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java
+++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/OtlpStatsMetricWriterTest.java
@@ -341,7 +341,7 @@ void errorSeriesDoesNotLingerAfterClearWhenBucketHasOnlyOkHits() throws IOExcept
     OtlpStatsMetricWriter writer = new OtlpStatsMetricWriter(sender, false);
 
     // Bucket 1: the entry sees an error, so its error histogram is allocated and emits a point.
-    AggregateEntry e = entry("GET /users", null, 0, null, null, null);
+    AggregateEntry e = entry("GET /users", false, 0, null, null, null);
     e.recordOneDuration(SECONDS.toNanos(1)); // ok
     e.recordOneDuration(SECONDS.toNanos(3) | AggregateEntry.ERROR_TAG); // error
 

From 7b264b95e04eca33fd27584fcd43e61008f4de00 Mon Sep 17 00:00:00 2001
From: Matthew Li 
Date: Thu, 2 Jul 2026 14:56:21 -0500
Subject: [PATCH 12/12] cleanup

---
 .../datadog/trace/common/metrics/OtlpStatsMetricWriter.java | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java
index 62518b80785..98256d61d4f 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/OtlpStatsMetricWriter.java
@@ -34,9 +34,9 @@
  * (unit {@code s}).
  *
  * 

This is the parallel-to-{@link SerializingMetricWriter} OTLP export path. It hangs off the - * same in-memory aggregation ({@link ConflatingMetricsAggregator} / {@link Aggregator}) and - * consumes the same {@link AggregateEntry} stream; only the wire encoding and transport differ. - * Native msgpack stats and OTLP export are mutually exclusive (selected at the factory). + * same in-memory aggregation ({@link ClientStatsAggregator} / {@link Aggregator}) and consumes the + * same {@link AggregateEntry} stream; only the wire encoding and transport differ. Native msgpack + * stats and OTLP export are mutually exclusive (selected at the factory). * *

Assembly mirrors {@code OtlpMetricsProtoCollector} */