Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,22 @@
package com.datadoghq.dogstatsd.http;

import com.datadoghq.dogstatsd.Sketch;
import com.datadoghq.dogstatsd.http.serializer.Metric;
import com.datadoghq.dogstatsd.http.serializer.PayloadBuilder;
import com.datadoghq.dogstatsd.http.serializer.PayloadConsumer;
import java.net.URI;
import java.nio.BufferOverflowException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;

/**
* Simple Dogstatsd HTTP client for sending pre-aggregated metrics.
*
* <p>A {@code host:} tag is not sent as a tag: it is removed from the tags and submitted as the
* host resource of the timeseries.
*
* <p>Not thread safe.
*
* <p>Caveat: if the forwarder throws {@code InterruptedException}, the payload in progress is lost.
Expand All @@ -30,6 +36,8 @@ public class DirectHttpClient {
private final Sketch sketchBuffer = new Sketch();
private final String prefix;
private static final int defaultInterval = 10;
private static final String hostTagPrefix = "host:";
private static final String hostResourceType = "host";

/**
* Creates a builder for a client sending its payloads through the given forwarder.
Expand Down Expand Up @@ -127,13 +135,13 @@ public DirectHttpClient build() {
* @param name the metric name, to which the client prefix is prepended.
* @param value the gauge value.
* @param ts the timestamp of the point in seconds since Unix epoch.
* @param tags the tags to attach to the point.
* @param tags the tags to attach to the point. A {@code host:} tag is not attached as a tag:
* the first one is submitted as the host resource of the timeseries, and any further {@code
* host:} tags are dropped.
* @throws BufferOverflowException if the encoded metric exceeds the maximum payload size.
*/
public void gauge(String name, double value, long ts, List<String> tags) {
seriesBuilder
.gauge(prefixed(name))
.setTags(tags)
withTagsAndHost(seriesBuilder.gauge(prefixed(name)), tags)
.setInterval(defaultInterval)
.addPoint(ts, value)
.close();
Expand All @@ -148,13 +156,13 @@ public void gauge(String name, double value, long ts, List<String> tags) {
* @param name the metric name, to which the client prefix is prepended.
* @param value the count accumulated over the interval starting at {@code ts}.
* @param ts the timestamp of the point in seconds since Unix epoch.
* @param tags the tags to attach to the point.
* @param tags the tags to attach to the point. A {@code host:} tag is not attached as a tag:
* the first one is submitted as the host resource of the timeseries, and any further {@code
* host:} tags are dropped.
* @throws BufferOverflowException if the encoded metric exceeds the maximum payload size.
*/
public void count(String name, double value, long ts, List<String> tags) {
seriesBuilder
.rate(prefixed(name))
.setTags(tags)
withTagsAndHost(seriesBuilder.rate(prefixed(name)), tags)
.setInterval(defaultInterval)
.addPoint(ts, value / defaultInterval)
.close();
Expand All @@ -167,21 +175,71 @@ public void count(String name, double value, long ts, List<String> tags) {
* @param values the observations to summarize.
* @param sampleRate the sampling rate used to collect {@code values}, in {@code (0, 1]}.
* @param ts the timestamp of the point in seconds since Unix epoch.
* @param tags the tags to attach to the point.
* @param tags the tags to attach to the point. A {@code host:} tag is not attached as a tag:
* the first one is submitted as the host resource of the timeseries, and any further {@code
* host:} tags are dropped.
* @throws IllegalArgumentException if {@code sampleRate} is {@code NaN}, not positive, or
* greater than 1.
* @throws BufferOverflowException if the encoded metric exceeds the maximum payload size.
*/
public void distribution(
String name, double[] values, double sampleRate, long ts, List<String> tags) {
sketchBuffer.build(values, sampleRate);
sketchesBuilder.sketch(prefixed(name)).setTags(tags).addPoint(ts, sketchBuffer).close();
withTagsAndHost(sketchesBuilder.sketch(prefixed(name)), tags)
.addPoint(ts, sketchBuffer)
.close();
}

private String prefixed(final String name) {
return prefix.isEmpty() ? name : prefix + name;
}

/** Applies the tags to the metric, extracting the host tag into the host resource. */
private static <T extends Metric<T>> T withTagsAndHost(
final T metric, final List<String> tags) {
final String host = hostTag(tags);
return metric.setTags(host == null ? tags : withoutHostTags(tags))
.setResources(hostResource(host));
}

/** Returns the value of the first host tag, or null if there is none. */
static String hostTag(final List<String> tags) {
if (tags == null) {
return null;
}
for (int i = 0; i < tags.size(); i++) {
final String tag = tags.get(i);
if (tag.startsWith(hostTagPrefix)) {
return tag.substring(hostTagPrefix.length());
}
}
return null;
}

/** Returns the tags with every host tag removed, or the tags themselves if there was none. */
static List<String> withoutHostTags(final List<String> tags) {
if (tags == null) {
return null;
}
ArrayList<String> rest = null;
for (int i = 0; i < tags.size(); i++) {
final String tag = tags.get(i);
if (tag.startsWith(hostTagPrefix)) {
if (rest == null) {
rest = new ArrayList<>(tags.subList(0, i));
}
} else if (rest != null) {
rest.add(tag);
}
}
return rest == null ? tags : rest;
}

/** Returns the host resource pair, or null if there is no host. */
static List<String> hostResource(final String host) {
return host == null ? null : Arrays.asList(hostResourceType, host);
}

/**
* Completes any in-progress payloads and submits them to the forwarder.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@

import java.util.List;

abstract class Metric<T extends Metric<T>> {
/**
* Common settings shared by all timeseries builders.
*
* <p>Instances are obtained from {@link PayloadBuilder}; this class cannot be subclassed outside of
* this package.
*
* @param <T> the concrete builder type returned by the setters, for chaining.
*/
public abstract class Metric<T extends Metric<T>> {
final PayloadBuilder pb;

final long type;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/* Unless explicitly stated otherwise all files in this repository are
* licensed under the Apache 2.0 License.
*
* This product includes software developed at Datadog
* (https://www.datadoghq.com/) Copyright 2026 Datadog, Inc.
*/

package com.datadoghq.dogstatsd.http;

import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;

import com.datadoghq.dogstatsd.Sketch;
import com.datadoghq.dogstatsd.http.serializer.PayloadBuilder;
import com.datadoghq.dogstatsd.http.serializer.PayloadConsumer;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.Test;

public class DirectHttpClientTest {
private static final URI seriesUri = URI.create("series");
private static final URI sketchesUri = URI.create("sketches");

static class TestForwarder implements DirectHttpClient.Forwarder {
final ArrayList<URI> uris = new ArrayList<>();
final ArrayList<byte[]> payloads = new ArrayList<>();

@Override
public void send(URI uri, byte[] payload) {
uris.add(uri);
payloads.add(payload);
}
}

private static PayloadBuilder builderInto(final List<byte[]> payloads) {
return new PayloadBuilder(
new PayloadConsumer() {
@Override
public void handle(byte[] p) {
payloads.add(p);
}
});
}

/** Asserts the client sent exactly one payload to uri, matching the only expected payload. */
private static void assertSent(List<byte[]> expected, TestForwarder fwd, URI uri, String what) {
assertEquals("expected payloads", 1, expected.size());
assertEquals("payloads sent", Collections.singletonList(uri), fwd.uris);
assertArrayEquals(what, expected.get(0), fwd.payloads.get(0));
}

@Test
public void gaugeSendsHostTagAsResource() {
TestForwarder fwd = new TestForwarder();
DirectHttpClient client = DirectHttpClient.builder(fwd).prefix("pfx").build();
client.gauge("metric", 1.5, 100, Arrays.asList("a:b", "host:h1", "host:h2"));
client.flush();

ArrayList<byte[]> expected = new ArrayList<>();
PayloadBuilder b = builderInto(expected);
b.gauge("pfx.metric")
.setTags(Collections.singletonList("a:b"))
.setResources(Arrays.asList("host", "h1"))
.setInterval(10)
.addPoint(100, 1.5)
.close();
b.close();

assertSent(expected, fwd, seriesUri, "gauge");
}

@Test
public void countSendsHostTagAsResource() {
TestForwarder fwd = new TestForwarder();
DirectHttpClient client = DirectHttpClient.builder(fwd).build();
client.count("metric", 20, 100, Arrays.asList("host:h1", "a:b"));
client.flush();

ArrayList<byte[]> expected = new ArrayList<>();
PayloadBuilder b = builderInto(expected);
b.rate("metric")
.setTags(Collections.singletonList("a:b"))
.setResources(Arrays.asList("host", "h1"))
.setInterval(10)
.addPoint(100, 2)
.close();
b.close();

assertSent(expected, fwd, seriesUri, "count");
}

@Test
public void distributionSendsHostTagAsResource() {
double[] values = new double[] {1, 2, 2};

TestForwarder fwd = new TestForwarder();
DirectHttpClient client = DirectHttpClient.builder(fwd).build();
client.distribution("metric", values, 1.0, 100, Arrays.asList("host:h1", "a:b"));
client.flush();

Sketch sketch = new Sketch();
sketch.build(values, 1.0);

ArrayList<byte[]> expected = new ArrayList<>();
PayloadBuilder b = builderInto(expected);
b.sketch("metric")
.setTags(Collections.singletonList("a:b"))
.setResources(Arrays.asList("host", "h1"))
.addPoint(100, sketch)
.close();
b.close();

assertSent(expected, fwd, sketchesUri, "distribution");
}

@Test
public void noHostTagLeavesResourcesUnset() {
TestForwarder fwd = new TestForwarder();
DirectHttpClient client = DirectHttpClient.builder(fwd).build();
client.gauge("metric", 1.5, 100, Arrays.asList("a:b", "hostname:h1"));
client.flush();

ArrayList<byte[]> expected = new ArrayList<>();
PayloadBuilder b = builderInto(expected);
b.gauge("metric")
.setTags(Arrays.asList("a:b", "hostname:h1"))
.setInterval(10)
.addPoint(100, 1.5)
.close();
b.close();

assertSent(expected, fwd, seriesUri, "gauge without host tag");
}

@Test
public void emptyHostTagSendsEmptyHostResource() {
TestForwarder fwd = new TestForwarder();
DirectHttpClient client = DirectHttpClient.builder(fwd).build();
client.gauge("metric", 1.5, 100, Arrays.asList("a:b", "host:"));
client.flush();

ArrayList<byte[]> expected = new ArrayList<>();
PayloadBuilder b = builderInto(expected);
b.gauge("metric")
.setTags(Collections.singletonList("a:b"))
.setResources(Arrays.asList("host", ""))
.setInterval(10)
.addPoint(100, 1.5)
.close();
b.close();

assertSent(expected, fwd, seriesUri, "gauge with empty host tag");

// An empty host resource must still be encoded, unlike a metric with no resources at all.
ArrayList<byte[]> noResources = new ArrayList<>();
PayloadBuilder nb = builderInto(noResources);
nb.gauge("metric")
.setTags(Collections.singletonList("a:b"))
.setInterval(10)
.addPoint(100, 1.5)
.close();
nb.close();

assertFalse(
"empty host resource encodes the same as no resources",
Arrays.equals(noResources.get(0), fwd.payloads.get(0)));
}

@Test
public void hostTagPicksFirstValue() {
assertNull(DirectHttpClient.hostTag(null));
assertNull(DirectHttpClient.hostTag(Collections.<String>emptyList()));
assertNull(DirectHttpClient.hostTag(Arrays.asList("a:b", "hostname:h1", "hos:t")));
assertEquals("h1", DirectHttpClient.hostTag(Collections.singletonList("host:h1")));
assertEquals("h1", DirectHttpClient.hostTag(Arrays.asList("a:b", "host:h1", "host:h2")));
assertEquals("", DirectHttpClient.hostTag(Collections.singletonList("host:")));
}

@Test
public void withoutHostTagsRemovesEveryHostTag() {
assertNull(DirectHttpClient.withoutHostTags(null));

List<String> noHost = Arrays.asList("a:b", "hostname:h1");
assertSame(noHost, DirectHttpClient.withoutHostTags(noHost));

assertEquals(
Collections.<String>emptyList(),
DirectHttpClient.withoutHostTags(Arrays.asList("host:h1", "host:")));
assertEquals(
Arrays.asList("a:b", "c:d"),
DirectHttpClient.withoutHostTags(
Arrays.asList("a:b", "host:h1", "c:d", "host:h2")));
}

@Test
public void hostResourceIsATypeNamePair() {
assertNull(DirectHttpClient.hostResource(null));
assertEquals(Arrays.asList("host", "h1"), DirectHttpClient.hostResource("h1"));
assertEquals(Arrays.asList("host", ""), DirectHttpClient.hostResource(""));
}
}
Loading