diff --git a/dd-smoke-tests/opentelemetry/src/test/java/datadog/smoketest/OpenTelemetrySmokeTest.java b/dd-smoke-tests/opentelemetry/src/test/java/datadog/smoketest/OpenTelemetrySmokeTest.java index e3c87e30d3b..0f686dd7800 100644 --- a/dd-smoke-tests/opentelemetry/src/test/java/datadog/smoketest/OpenTelemetrySmokeTest.java +++ b/dd-smoke-tests/opentelemetry/src/test/java/datadog/smoketest/OpenTelemetrySmokeTest.java @@ -2,6 +2,7 @@ import static datadog.smoketest.backend.AgentBackend.testAgent; import static datadog.smoketest.trace.SmokeTraceAssertions.UNORDERED; +import static datadog.smoketest.trace.SpanEventMatcher.event; import static datadog.smoketest.trace.SpanLinkMatcher.toIndex; import static datadog.smoketest.trace.SpanMatcher.span; import static datadog.smoketest.trace.TraceMatcher.trace; @@ -46,7 +47,7 @@ void receivesTraces() { span() .root() .operationName("Application.loadConfiguration") - .tag("events", validates(events -> events.contains("configuration-loaded")))), + .events(event("configuration-loaded"))), // The batch trace: // batch-job (tracer, server, root) // ├─ Application.splitBatch (annotated, main thread) diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanEventMatcher.java b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanEventMatcher.java new file mode 100644 index 00000000000..81bdd5c6e6e --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanEventMatcher.java @@ -0,0 +1,99 @@ +package datadog.smoketest.trace; + +import static datadog.trace.test.junit.utils.assertions.Matchers.assertValue; +import static datadog.trace.test.junit.utils.assertions.Matchers.is; +import static datadog.trace.test.junit.utils.assertions.Matchers.validates; +import static java.util.Collections.emptyMap; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedSpanEvent; +import datadog.trace.test.junit.utils.assertions.Matcher; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Provides matchers for decoded span events based on their properties such as name, time, and + * attributes. + */ +public final class SpanEventMatcher { + private final Matcher nameMatcher; + private Matcher timeMatcher; + private Matcher> attributesMatcher; + + private SpanEventMatcher(Matcher nameMatcher) { + this.nameMatcher = nameMatcher; + this.attributesMatcher = is(emptyMap()); + } + + /** + * Creates a {@code SpanEventMatcher} that matches a span event with the given name. By default, + * the event must have no attribute and its time must be within the span start and end times. + * + * @param name The span event name to match against. + * @return A {@code SpanEventMatcher} that matches a span event with the given name. + */ + public static SpanEventMatcher event(String name) { + return new SpanEventMatcher(is(name)); + } + + /** + * Checks the event time matches the given value. + * + * @param timestamp The event time to match against. + * @param unit The event time unit. + * @return The current {@link SpanEventMatcher} instance with the time constraint applied. + */ + public SpanEventMatcher time(long timestamp, TimeUnit unit) { + this.timeMatcher = is(unit.toNanos(timestamp)); + return this; + } + + /** + * Checks the event time matches the given matcher. + * + * @param matcher The matcher to check the event time, in nanoseconds since the Unix epoch. + * @return The current {@link SpanEventMatcher} instance with the time constraint applied. + */ + public SpanEventMatcher time(Matcher matcher) { + this.timeMatcher = matcher; + return this; + } + + /** + * Checks the event attributes match the given values. + * + * @param attributes The event attributes to match against, with values being {@link String}, + * {@link Boolean}, {@link Long}, {@link Double}, or a {@link List} of them. + * @return The current {@link SpanEventMatcher} instance with the attributes constraint applied. + */ + public SpanEventMatcher attributes(Map attributes) { + this.attributesMatcher = is(attributes); + return this; + } + + /** + * Checks the event attributes match the given matcher. + * + * @param matcher The matcher to check the event attributes. + * @return The current {@link SpanEventMatcher} instance with the attributes constraint applied. + */ + public SpanEventMatcher attributes(Matcher> matcher) { + this.attributesMatcher = matcher; + return this; + } + + void assertEvent(DecodedSpan span, DecodedSpanEvent event, int eventIndex) { + String at = " (event #" + eventIndex + ")"; + Matcher timeMatcher = this.timeMatcher; + if (timeMatcher == null) { + long startTime = span.getStart(); + long endTime = startTime + span.getDuration(); + timeMatcher = validates(time -> startTime <= time && time <= endTime); + } + assertValue(this.nameMatcher, event.getName(), "Unexpected span event name" + at); + assertValue(timeMatcher, event.getTimeUnixNano(), "Unexpected span event time" + at); + assertValue( + this.attributesMatcher, event.getAttributes(), "Unexpected span event attributes" + at); + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java index 42bb20dc115..5b2eb6a59f7 100644 --- a/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.AssertionFailureBuilder.assertionFailure; import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedSpanEvent; import datadog.trace.test.agent.decoder.DecodedSpanLink; import datadog.trace.test.junit.utils.assertions.Matcher; import java.util.HashMap; @@ -40,6 +41,7 @@ *
  • span metrics (numeric) tags with {@link #metric(String, Matcher)} *
  • span meta_struct (nested structured data) with {@link #metaStruct(String, Matcher)} *
  • span links with {@link #links(SpanLinkMatcher...)} + *
  • span events with {@link #events(SpanEventMatcher...)} * */ public final class SpanMatcher { @@ -51,6 +53,7 @@ public final class SpanMatcher { private Matcher parentIdMatcher; private int parentSpanIndex; private SpanLinkMatcher[] linkMatchers; + private SpanEventMatcher[] eventMatchers; private final Map> metaMatchers; private final Map> metricMatchers; private final Map> metaStructMatchers; @@ -234,6 +237,20 @@ public SpanMatcher links(SpanLinkMatcher... matchers) { return this; } + /** + * Checks the span events, one {@link SpanEventMatcher} per expected event, matched positionally + * and count-exact. Call it with no argument to assert the span carries no event at all; not + * calling it asserts nothing about events. + * + * @param matchers The matchers to verify the span events, one per expected event. + * @return The current {@link SpanMatcher} instance updated with the specified span event + * constraints. + */ + public SpanMatcher events(SpanEventMatcher... matchers) { + this.eventMatchers = matchers; + return this; + } + /** * Checks the span meta (string) tag matches the given matcher. * @@ -298,6 +315,7 @@ void assertSpan(List trace, int spanIndex) { assertSpanMetrics(span.getMetrics()); assertSpanMetaStruct(span.getMetaStruct()); assertSpanLinks(trace, span); + assertSpanEvents(span); } private Matcher parentIdMatcher(List trace, int spanIndex) { @@ -330,6 +348,23 @@ private void assertSpanLinks(List trace, DecodedSpan span) { } } + private void assertSpanEvents(DecodedSpan span) { + if (this.eventMatchers == null) { + return; + } + List events = span.getEvents(); + if (events.size() != this.eventMatchers.length) { + assertionFailure() + .message("Unexpected span event count") + .expected(this.eventMatchers.length) + .actual(events.size()) + .buildAndThrow(); + } + for (int i = 0; i < this.eventMatchers.length; i++) { + this.eventMatchers[i].assertEvent(span, events.get(i), i); + } + } + private void assertSpanTags(Map meta) { for (Entry> entry : this.metaMatchers.entrySet()) { String key = entry.getKey(); diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java index d02a5dd7fb0..dff60d4e32c 100644 --- a/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java @@ -2,6 +2,7 @@ import static datadog.smoketest.trace.SmokeTraceAssertions.IGNORE_ADDITIONAL_TRACES; import static datadog.smoketest.trace.SmokeTraceAssertions.assertTraces; +import static datadog.smoketest.trace.SpanEventMatcher.event; import static datadog.smoketest.trace.SpanLinkMatcher.any; import static datadog.smoketest.trace.SpanLinkMatcher.to; import static datadog.smoketest.trace.SpanLinkMatcher.toIndex; @@ -12,10 +13,12 @@ import static datadog.trace.test.junit.utils.assertions.Matchers.isNonNull; import static datadog.trace.test.junit.utils.assertions.Matchers.validates; import static java.util.Collections.singletonMap; +import static java.util.concurrent.TimeUnit.NANOSECONDS; import static org.junit.jupiter.api.Assertions.assertThrows; import datadog.trace.test.agent.decoder.DecodedTrace; import datadog.trace.test.agent.decoder.Decoder; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -54,7 +57,7 @@ class SmokeMatcherTest { // One span carrying meta_struct: an IAST-style nested "_dd.stack" plus a request-body entry. private static final String META_STRUCT_TRACE = "[[{" - + "\"service\":\"s\",\"name\":\"servlet.request\",\"resource\":\"r\"," + + "\"service\":\"test-service\",\"name\":\"servlet.request\",\"resource\":\"r\"," + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," + "\"meta\":{},\"metrics\":{}," + "\"meta_struct\":{" @@ -242,12 +245,14 @@ void defaultConstraintsRequireDefinedServiceNoTypeAndNoError() { trace(span().root())); // A non-null span type fails the default unless acknowledged with type(...). - List typed = Decoder.decodeJson(spanTraceJson("s", "web", 0)).getTraces(); + List typed = + Decoder.decodeJson(spanTraceJson("test-service", "web", 0)).getTraces(); assertThrows(AssertionError.class, () -> assertTraces(typed, trace(span().root()))); assertTraces(typed, trace(span().root().type("web"))); // An errored span fails the default unless acknowledged with error(true). - List errored = Decoder.decodeJson(spanTraceJson("s", null, 1)).getTraces(); + List errored = + Decoder.decodeJson(spanTraceJson("test-service", null, 1)).getTraces(); assertThrows(AssertionError.class, () -> assertTraces(errored, trace(span().root()))); assertTraces(errored, trace(span().root().error(true))); @@ -410,6 +415,77 @@ void linkTargetsDoNotLeakAcrossCandidates() { span().operationName("root").root(), span().operationName("merge").links(toIndex(0)))); } + // --- Span events --- + + @Test + void matchesEventsPositionally() { + List traces = + Decoder.decodeJson( + eventTraceJson( + "[{\"time_unix_nano\":110,\"name\":\"first\"}," + + "{\"time_unix_nano\":120,\"name\":\"second\"," + + "\"attributes\":{\"key\":\"value\",\"count\":3}}]")) + .getTraces(); + Map attributes = new LinkedHashMap<>(); + attributes.put("key", "value"); + attributes.put("count", 3L); + + assertTraces( + traces, + trace( + span() + .operationName("event") + .events(event("first"), event("second").attributes(attributes)))); + assertThrows( + AssertionError.class, + () -> assertTraces(traces, trace(span().events(event("second"), event("first")))), + "events are matched positionally"); + assertThrows( + AssertionError.class, + () -> assertTraces(traces, trace(span().events(event("first"), event("second")))), + "missing attributes"); + } + + @Test + void eventTimeDefaultsToSpanBounds() { + List traces = + Decoder.decodeJson(eventTraceJson("[{\"time_unix_nano\":42,\"name\":\"early\"}]")) + .getTraces(); + + assertThrows( + AssertionError.class, + () -> assertTraces(traces, trace(span().events(event("early")))), + "event time outside the span bounds"); + assertTraces(traces, trace(span().events(event("early").time(42, NANOSECONDS)))); + } + + @Test + void emptyEventsAssertsNoEventAndAbsentEventsAssertNothing() { + List withEvents = + Decoder.decodeJson(eventTraceJson("[{\"time_unix_nano\":110,\"name\":\"event\"}]")) + .getTraces(); + + assertThrows( + AssertionError.class, + () -> assertTraces(withEvents, trace(span().events())), + "events() requires zero events"); + // Not calling events(...) asserts nothing, even though the span does carry events. + assertTraces(withEvents, trace(span().operationName("event"))); + // A span with no event satisfies events(). + List withoutEvents = + Decoder.decodeJson("[[" + spanJson("plain", 10, 0, 10) + "]]").getTraces(); + assertTraces(withoutEvents, trace(span().operationName("plain").events())); + } + + /** A single-span trace, from 100 to 200 ns, carrying the given events meta tag. */ + private static String eventTraceJson(String eventsTag) { + return "[[{\"service\":\"test-service\",\"name\":\"event\",\"resource\":\"event\"," + + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":100,\"duration\":100," + + "\"error\":0,\"metrics\":{},\"meta\":{\"events\":\"" + + eventsTag.replace("\"", "\\\"") + + "\"}}]]"; + } + /** A single-span trace whose root {@code merge} span links to the given span ids. */ private static String mergeOnlyTraceJson(long... linkedSpanIds) { return "[[" + linkingSpanJson(40, 0, linkedSpanIds) + "]]"; @@ -430,7 +506,7 @@ private static String linkedTraceJson(long shardA, long shardB) { /** A single-span trace whose link carries flags, a trace state and attributes. */ private static String refinedLinkTraceJson() { - return "[[{\"service\":\"s\",\"name\":\"merge\",\"resource\":\"merge\"," + return "[[{\"service\":\"test-service\",\"name\":\"merge\",\"resource\":\"merge\"," + "\"trace_id\":1,\"span_id\":40,\"parent_id\":0,\"start\":40,\"duration\":1," + "\"error\":0,\"metrics\":{},\"meta\":{\"_dd.span_links\":\"" + "[{\\\"trace_id\\\":\\\"00000000000000000000000000000063\\\"," @@ -455,7 +531,7 @@ private static String linkingSpanJson(long id, long parent, long... linkedSpanId .append("\\\"}"); } links.append(']'); - return "{\"service\":\"s\",\"name\":\"merge\",\"resource\":\"merge\"," + return "{\"service\":\"test-service\",\"name\":\"merge\",\"resource\":\"merge\"," + "\"trace_id\":1,\"span_id\":" + id + ",\"parent_id\":" @@ -467,7 +543,7 @@ private static String linkingSpanJson(long id, long parent, long... linkedSpanId } private static String spanJson(String name, long id, long parent, long start) { - return "{\"service\":\"s\",\"name\":\"" + return "{\"service\":\"test-service\",\"name\":\"" + name + "\",\"resource\":\"" + name diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpan.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpan.java index 355863e3924..72d8feb2e54 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpan.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpan.java @@ -41,4 +41,11 @@ public interface DecodedSpan { * @return The span links, empty when the span carries none. */ List getLinks(); + + /** + * Returns the events. + * + * @return The span events, empty when the span carries none. + */ + List getEvents(); } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanEvent.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanEvent.java new file mode 100644 index 00000000000..441f9b3a52f --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanEvent.java @@ -0,0 +1,34 @@ +package datadog.trace.test.agent.decoder; + +import java.util.List; +import java.util.Map; + +/** + * This interface describes an event a decoded span carries, as the tracer serialized it. It is the + * wire-side counterpart of the tracer's {@code DDSpanEvent}. + * + *

    Every accessor is non-null: absent attributes decode to an empty map. + */ +public interface DecodedSpanEvent { + /** + * Returns the event name. + * + * @return The event name. + */ + String getName(); + + /** + * Returns the event time. + * + * @return The event time, in nanoseconds since the Unix epoch. + */ + long getTimeUnixNano(); + + /** + * Returns the attributes attached to this event. + * + * @return The event attributes, with values being {@link String}, {@link Boolean}, {@link Long}, + * {@link Double}, or a {@link List} of them; empty if the tracer sent none. + */ + Map getAttributes(); +} diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanEvents.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanEvents.java new file mode 100644 index 00000000000..e4b526d2cfd --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanEvents.java @@ -0,0 +1,197 @@ +package datadog.trace.test.agent.decoder; + +import static com.squareup.moshi.JsonReader.Token.BEGIN_OBJECT; +import static com.squareup.moshi.JsonReader.Token.END_DOCUMENT; +import static com.squareup.moshi.JsonReader.Token.NULL; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.unmodifiableList; +import static java.util.Collections.unmodifiableMap; + +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.JsonReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import okio.Buffer; + +/** + * This class decodes the span events the tracer serializes into the {@value #SPAN_EVENTS_TAG} meta + * tag, the only way events reach the agent on the {@code v0.4} / {@code v0.5} / test-agent JSON + * protocols. On {@code v1.0} events travel as a structured payload field instead, decoded by the + * {@code v1} span decoder through {@link #event}. + * + *

    JSON loses the attribute value types, so numbers are typed from their text: a number written + * with a fraction or an exponent decodes to a {@link Double}, any other number to a {@link Long}. + * This matches how the tracer writes them. + */ +public final class DecodedSpanEvents { + private static final String SPAN_EVENTS_TAG = "events"; + + private DecodedSpanEvents() {} + + /** + * Decodes the span events from meta tags. + * + * @param meta The span meta collection. + * @return The decoded events, in the order the tracer serialized them; empty if the tag is absent + * or holds no event. + * @throws IllegalStateException If the tag value is not a well-formed span event array. + */ + public static List fromMeta(Map meta) { + String tagValue = meta.get(SPAN_EVENTS_TAG); + if (tagValue == null || tagValue.isEmpty()) { + return emptyList(); + } + try (Buffer buffer = new Buffer(); + JsonReader reader = JsonReader.of(buffer.writeUtf8(tagValue))) { + List events = new ArrayList<>(); + if (reader.peek() == NULL) { + reader.nextNull(); + } else { + reader.beginArray(); + while (reader.hasNext()) { + events.add(readEvent(reader, tagValue)); + } + reader.endArray(); + } + if (reader.peek() != END_DOCUMENT) { + throw new JsonDataException("Unexpected trailing data after span events"); + } + return events.isEmpty() ? emptyList() : unmodifiableList(events); + } catch (IOException | JsonDataException | NumberFormatException e) { + throw new IllegalStateException("Failed to parse span events: " + tagValue, e); + } + } + + /** + * Builds an event from already decoded values, for the structured {@code v1.0} payload field. + * + * @param name The event name. + * @param timeUnixNano The event time, in nanoseconds since the Unix epoch. + * @param attributes The event attributes, {@code null} for none. + * @return The event. + */ + public static DecodedSpanEvent event( + String name, long timeUnixNano, Map attributes) { + return new SpanEvent(name, timeUnixNano, attributes); + } + + private static DecodedSpanEvent readEvent(JsonReader reader, String tagValue) throws IOException { + if (reader.peek() != BEGIN_OBJECT) { + throw new IllegalStateException("Malformed span events with a non-object event: " + tagValue); + } + String name = null; + Long timeUnixNano = null; + Map attributes = null; + reader.beginObject(); + while (reader.hasNext()) { + switch (reader.nextName()) { + case "name": + name = reader.nextString(); + break; + case "time_unix_nano": + timeUnixNano = reader.nextLong(); + break; + case "attributes": + attributes = readAttributes(reader); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + if (name == null || timeUnixNano == null) { + throw new IllegalStateException( + "Span event missing a required field (name, time_unix_nano): " + tagValue); + } + return new SpanEvent(name, timeUnixNano, attributes); + } + + private static Map readAttributes(JsonReader reader) throws IOException { + Map attributes = new LinkedHashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + attributes.put(reader.nextName(), readValue(reader)); + } + reader.endObject(); + return attributes; + } + + private static Object readValue(JsonReader reader) throws IOException { + switch (reader.peek()) { + case STRING: + return reader.nextString(); + case BOOLEAN: + return reader.nextBoolean(); + case NUMBER: + return toNumber(reader.nextString()); + case BEGIN_ARRAY: + List values = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + values.add(readValue(reader)); + } + reader.endArray(); + return values; + default: + // The tracer writes null for any unsupported value + reader.skipValue(); + return null; + } + } + + private static Number toNumber(String text) { + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '.' || c == 'e' || c == 'E') { + return Double.parseDouble(text); + } + } + return Long.parseLong(text); + } + + private static final class SpanEvent implements DecodedSpanEvent { + private final String name; + private final long timeUnixNano; + private final Map attributes; + + private SpanEvent(String name, long timeUnixNano, Map attributes) { + this.name = name; + this.timeUnixNano = timeUnixNano; + this.attributes = + attributes == null || attributes.isEmpty() + ? emptyMap() + : unmodifiableMap(new LinkedHashMap<>(attributes)); + } + + @Override + public String getName() { + return this.name; + } + + @Override + public long getTimeUnixNano() { + return this.timeUnixNano; + } + + @Override + public Map getAttributes() { + return this.attributes; + } + + @Override + public String toString() { + return "SpanEvent{name='" + + this.name + + "', timeUnixNano=" + + this.timeUnixNano + + ", attributes=" + + this.attributes + + '}'; + } + } +} diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java index 42e8971cf06..4a056d62b08 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java @@ -74,6 +74,7 @@ public static MessageJson fromJson(String json) { + span); } span.resolveLinks(); + span.resolveEvents(); decodedSpans.add(span); } traces.add(new TraceJson(decodedSpans)); diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanEventJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanEventJson.java new file mode 100644 index 00000000000..2a7b3f75050 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanEventJson.java @@ -0,0 +1,139 @@ +package datadog.trace.test.agent.decoder.json.raw; + +import com.squareup.moshi.Json; +import datadog.trace.test.agent.decoder.DecodedSpanEvent; +import datadog.trace.test.agent.decoder.DecodedSpanEvents; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * SpanEventJson decodes a span event from the {@code span_events} field of the JSON trace format + * the dd-apm-test-agent exposes. The agent fills that field when the tracer submits a {@code v1.0} + * payload, where events travel as structured span data; on {@code v0.4} / {@code v0.5} they arrive + * as a meta tag instead, decoded by {@link DecodedSpanEvents}. + * + *

    Unlike the tag, attribute values are typed wrappers, as the agent models them: {@code {"type": + * 2, "int_value": 42}}. + */ +final class SpanEventJson { + private static final int STRING_TYPE = 0; + private static final int BOOLEAN_TYPE = 1; + private static final int INT_TYPE = 2; + private static final int DOUBLE_TYPE = 3; + private static final int ARRAY_TYPE = 4; + + @Json(name = "time_unix_nano") + Long timeUnixNano; + + String name; + + Map attributes; + + DecodedSpanEvent toDecodedSpanEvent() { + if (this.name == null || this.timeUnixNano == null) { + throw new IllegalStateException( + "JSON span event missing a required field (name, time_unix_nano): " + this); + } + Map decoded = new LinkedHashMap<>(); + if (this.attributes != null) { + for (Map.Entry attribute : this.attributes.entrySet()) { + decoded.put(attribute.getKey(), toValue(attribute.getValue())); + } + } + return DecodedSpanEvents.event(this.name, this.timeUnixNano, decoded); + } + + private Object toValue(AttributeValueJson value) { + if (value == null) { + throw new IllegalStateException("JSON span event with a null attribute: " + this); + } + if (value.type == null) { + throw new IllegalStateException("JSON span event with an untyped attribute: " + this); + } + switch (value.type) { + case STRING_TYPE: + return requireValue(value.stringValue); + case BOOLEAN_TYPE: + return requireValue(value.boolValue); + case INT_TYPE: + return requireValue(value.intValue); + case DOUBLE_TYPE: + return requireValue(value.doubleValue); + case ARRAY_TYPE: + if (value.arrayValue == null || value.arrayValue.values == null) { + throw new IllegalStateException("JSON span event with a malformed array: " + this); + } + List values = new ArrayList<>(value.arrayValue.values.size()); + for (AttributeValueJson item : value.arrayValue.values) { + values.add(toValue(item)); + } + return values; + default: + throw new IllegalStateException( + "JSON span event with an unknown attribute type " + value.type + ": " + this); + } + } + + private Object requireValue(Object value) { + if (value == null) { + throw new IllegalStateException( + "JSON span event with an attribute missing its typed value: " + this); + } + return value; + } + + @Override + public String toString() { + return "SpanEventJson{name='" + + this.name + + "', timeUnixNano=" + + this.timeUnixNano + + ", attributes=" + + this.attributes + + '}'; + } + + /** A typed attribute value, holding the field matching its type. */ + static final class AttributeValueJson { + Integer type; + + @Json(name = "string_value") + String stringValue; + + @Json(name = "bool_value") + Boolean boolValue; + + @Json(name = "int_value") + Long intValue; + + @Json(name = "double_value") + Double doubleValue; + + @Json(name = "array_value") + ArrayValueJson arrayValue; + + @Override + public String toString() { + return "AttributeValueJson{type=" + + this.type + + ", stringValue=" + + this.stringValue + + ", boolValue=" + + this.boolValue + + ", intValue=" + + this.intValue + + ", doubleValue=" + + this.doubleValue + + ", arrayValue=" + + (this.arrayValue == null ? null : this.arrayValue.values) + + '}'; + } + } + + /** The values of an array attribute. */ + static final class ArrayValueJson { + List values; + } +} diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java index 8c59fd6835b..b9431abb020 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java @@ -6,6 +6,8 @@ import com.squareup.moshi.Json; import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedSpanEvent; +import datadog.trace.test.agent.decoder.DecodedSpanEvents; import datadog.trace.test.agent.decoder.DecodedSpanLink; import datadog.trace.test.agent.decoder.DecodedSpanLinks; import java.util.ArrayList; @@ -57,6 +59,14 @@ public final class SpanJson implements DecodedSpan { // transient so Moshi does not try to decode it transient List links; + // Ony present in V1 payload. Decoded from meta by #resolveEvents otherwise + @Json(name = "span_events") + List spanEvents; + + // Decoded from span events meta tag or #spanEvents + // transient so Moshi does not try to decode it + transient List events; + @Override public String getService() { return this.service; @@ -127,6 +137,11 @@ public List getLinks() { return this.links; } + @Override + public List getEvents() { + return this.events; + } + /** * Decodes the span links, from the {@code span_links} field when the agent filled it (the tracer * submitted a {@code v1.0} payload) and from the meta tag otherwise. @@ -146,6 +161,25 @@ void resolveLinks() { this.links = unmodifiableList(decoded); } + /** + * Decodes the span events, from the {@code span_events} field when the agent filled it (the + * tracer submitted a {@code v1.0} payload) and from the meta tag otherwise. + */ + void resolveEvents() { + if (this.spanEvents == null) { + this.events = DecodedSpanEvents.fromMeta(getMeta()); + return; + } + List decoded = new ArrayList<>(this.spanEvents.size()); + for (SpanEventJson spanEvent : this.spanEvents) { + if (spanEvent == null) { + throw new IllegalStateException("Malformed span_events with a null event: " + this); + } + decoded.add(spanEvent.toDecodedSpanEvent()); + } + this.events = unmodifiableList(decoded); + } + @Override public String toString() { return "SpanJson{" diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java index cbaeb2af1ed..b4be62c4596 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java @@ -4,6 +4,8 @@ import static java.util.Collections.unmodifiableMap; import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedSpanEvent; +import datadog.trace.test.agent.decoder.DecodedSpanEvents; import datadog.trace.test.agent.decoder.DecodedSpanLink; import datadog.trace.test.agent.decoder.DecodedSpanLinks; import java.io.IOException; @@ -72,6 +74,7 @@ static SpanV04 unpack(MessageUnpacker unpacker) { } List links = DecodedSpanLinks.fromMeta(meta); + List events = DecodedSpanEvents.fromMeta(meta); return new SpanV04( service, @@ -87,7 +90,8 @@ static SpanV04 unpack(MessageUnpacker unpacker) { metrics, meta, metaStruct, - links); + links, + events); } catch (Throwable t) { if (t instanceof RuntimeException) { throw (RuntimeException) t; @@ -249,6 +253,7 @@ private static Object convertValueToObject(final Value value) { private final Map metrics; private final String type; private final List links; + private final List events; public SpanV04( String service, @@ -264,7 +269,8 @@ public SpanV04( Map metrics, Map meta, Map metaStruct, - List links) { + List links, + List events) { this.service = service; this.name = name; this.resource = resource; @@ -279,6 +285,7 @@ public SpanV04( this.metrics = unmodifiableMap(metrics); this.type = type; this.links = links; + this.events = events; } public String getService() { @@ -338,6 +345,11 @@ public List getLinks() { return links; } + @Override + public List getEvents() { + return events; + } + @Override public String toString() { return "SpanV04{" @@ -372,6 +384,8 @@ public String toString() { + type + "', links=" + links + + ", events=" + + events + '}'; } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java index 1a3c0b4333b..a861d4e88ee 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java @@ -3,6 +3,8 @@ import static java.util.Collections.emptyMap; import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedSpanEvent; +import datadog.trace.test.agent.decoder.DecodedSpanEvents; import datadog.trace.test.agent.decoder.DecodedSpanLink; import datadog.trace.test.agent.decoder.DecodedSpanLinks; import java.io.IOException; @@ -72,10 +74,11 @@ static SpanV05 unpack(MessageUnpacker unpacker, DictionaryV05 dictionary) { String type = unpackString(unpacker, dictionary); // index into dictionary List links = DecodedSpanLinks.fromMeta(meta); + List events = DecodedSpanEvents.fromMeta(meta); return new SpanV05( service, name, resource, traceId, spanId, parentId, start, duration, error, meta, metrics, - type, links); + type, links, events); } catch (Throwable t) { if (t instanceof RuntimeException) { throw (RuntimeException) t; @@ -128,6 +131,7 @@ static Number unpackNumber(MessageUnpacker unpacker) { private final Map metrics; // index -> metric private final String type; // index into dictionary private final List links; + private final List events; public SpanV05( String service, @@ -142,7 +146,8 @@ public SpanV05( Map meta, Map metrics, String type, - List links) { + List links, + List events) { this.service = service; this.name = name; this.resource = resource; @@ -156,6 +161,7 @@ public SpanV05( this.metrics = Collections.unmodifiableMap(metrics); this.type = type; this.links = links; + this.events = events; } public String getService() { @@ -216,6 +222,11 @@ public List getLinks() { return links; } + @Override + public List getEvents() { + return events; + } + @Override public String toString() { return "SpanV05{" @@ -248,6 +259,8 @@ public String toString() { + type + "', links=" + links + + ", events=" + + events + '}'; } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java index c4ec1e4b6b9..538ad696d9b 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java @@ -6,11 +6,14 @@ import static java.util.Collections.unmodifiableMap; import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedSpanEvent; +import datadog.trace.test.agent.decoder.DecodedSpanEvents; import datadog.trace.test.agent.decoder.DecodedSpanLink; import datadog.trace.test.agent.decoder.DecodedSpanLinks; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.msgpack.core.MessageFormat; @@ -59,6 +62,11 @@ public class SpanV1 implements DecodedSpan { static final int LINK_FIELD_TRACE_STATE = 4; static final int LINK_FIELD_TRACE_FLAGS = 5; + // Span event field IDs + static final int EVENT_FIELD_TIME = 1; + static final int EVENT_FIELD_NAME = 2; + static final int EVENT_FIELD_ATTRIBUTES = 3; + // Attribute value types static final int STRING_VALUE_TYPE = 1; static final int BOOL_VALUE_TYPE = 2; @@ -132,6 +140,7 @@ static SpanV1 unpack(MessageUnpacker unpacker, List stringTable, long tr Map metrics = new HashMap<>(); Map metaStruct = new HashMap<>(); List links = emptyList(); + List events = emptyList(); for (int i = 0; i < mapSize; i++) { int fieldId = unpacker.unpackInt(); @@ -172,10 +181,7 @@ static SpanV1 unpack(MessageUnpacker unpacker, List stringTable, long tr links = unpackSpanLinks(unpacker, stringTable); break; case SPAN_FIELD_SPAN_EVENTS: - // TODO Span events are not decoded yet. This blind skip also consumes any streaming - // string the events introduced without registering it, desynchronizing the string - // table for the rest of the payload (see unpackSpanLinks for the table-aware pattern). - unpacker.skipValue(); + events = unpackSpanEvents(unpacker, stringTable); break; case SPAN_FIELD_ENV: env = unpackStreamingString(unpacker, stringTable); @@ -224,7 +230,8 @@ static SpanV1 unpack(MessageUnpacker unpacker, List stringTable, long tr metrics, meta, metaStruct.isEmpty() ? null : metaStruct, - links); + links, + events); } catch (Throwable t) { if (t instanceof RuntimeException) { throw (RuntimeException) t; @@ -396,6 +403,99 @@ static List unpackSpanLinks(MessageUnpacker unpacker, ListEvent names and attributes are streaming strings, so they must be read through the + * table-aware helpers to keep every later string index in the payload in sync. + * + * @param unpacker The message unpacker. + * @param stringTable The shared string table for streaming string decoding. + * @return The decoded events. + * @throws IOException If unpacking fails. + */ + static List unpackSpanEvents(MessageUnpacker unpacker, List stringTable) + throws IOException { + int eventCount = unpacker.unpackArrayHeader(); + if (eventCount == 0) { + return emptyList(); + } + List events = new ArrayList<>(eventCount); + for (int i = 0; i < eventCount; i++) { + int fieldCount = unpacker.unpackMapHeader(); + Long timeUnixNano = null; + String name = null; + Map attributes = new LinkedHashMap<>(); + for (int field = 0; field < fieldCount; field++) { + switch (unpacker.unpackInt()) { + case EVENT_FIELD_TIME: + timeUnixNano = unpacker.unpackLong(); + break; + case EVENT_FIELD_NAME: + name = unpackStreamingString(unpacker, stringTable); + break; + case EVENT_FIELD_ATTRIBUTES: + unpackEventAttributes(unpacker, stringTable, attributes); + break; + default: + // Unknown event field; numeric or binary, so it cannot hold a streaming string. + unpacker.skipValue(); + break; + } + } + if (name == null || timeUnixNano == null) { + throw new IllegalArgumentException("Span event missing a required field (name, time)"); + } + events.add(DecodedSpanEvents.event(name, timeUnixNano, attributes)); + } + return unmodifiableList(events); + } + + /** Collects event attributes with their types, keeping the string table in sync. */ + private static void unpackEventAttributes( + MessageUnpacker unpacker, List stringTable, Map attributes) + throws IOException { + forEachAttribute( + unpacker, + stringTable, + (attributeUnpacker, table, key, valueType) -> { + if (valueType == ARRAY_VALUE_TYPE) { + int arraySize = attributeUnpacker.unpackArrayHeader(); + // Array contains pairs (type, value), so size must be even + if (arraySize % 2 != 0) { + throw new IllegalArgumentException( + "Array attribute size must be even, got: " + arraySize); + } + List values = new ArrayList<>(arraySize / 2); + for (int i = 0; i < arraySize / 2; i++) { + values.add( + unpackEventAttributeValue( + attributeUnpacker, table, attributeUnpacker.unpackInt())); + } + attributes.put(key, values); + } else { + attributes.put(key, unpackEventAttributeValue(attributeUnpacker, table, valueType)); + } + }); + } + + private static Object unpackEventAttributeValue( + MessageUnpacker unpacker, List stringTable, int valueType) throws IOException { + switch (valueType) { + case STRING_VALUE_TYPE: + return unpackStreamingString(unpacker, stringTable); + case BOOL_VALUE_TYPE: + return unpacker.unpackBoolean(); + case FLOAT_VALUE_TYPE: + return unpacker.unpackDouble(); + case INT_VALUE_TYPE: + return unpacker.unpackLong(); + default: + throw new IllegalArgumentException("Unsupported event attribute value type: " + valueType); + } + } + /** * Unpacks an identifier the writer emitted as an unsigned 64-bit value. * @@ -603,6 +703,7 @@ private static Object convertValueToObject(final Value value) { private final Map metrics; private final String type; private final List links; + private final List events; public SpanV1( String service, @@ -632,6 +733,7 @@ public SpanV1( metrics, meta, metaStruct, + emptyList(), emptyList()); } @@ -649,8 +751,10 @@ public SpanV1( Map metrics, Map meta, Map metaStruct, - List links) { + List links, + List events) { this.links = links == null ? emptyList() : links; + this.events = events == null ? emptyList() : events; this.service = service; this.name = name; this.resource = resource; @@ -671,6 +775,11 @@ public List getLinks() { return this.links; } + @Override + public List getEvents() { + return this.events; + } + @Override public String getService() { return service; diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/TraceV1.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/TraceV1.java index 5c23f0e1fb2..2105768649d 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/TraceV1.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/TraceV1.java @@ -125,7 +125,8 @@ private static DecodedSpan[] withChunkFields( metrics, span.getMeta(), span.getMetaStruct(), - span.getLinks()); + span.getLinks(), + span.getEvents()); } return updated; } diff --git a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java index 78d60bf8e0f..6d7a9805905 100644 --- a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java @@ -1,5 +1,6 @@ package datadog.trace.test.agent.decoder; +import static java.util.Arrays.asList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -10,6 +11,7 @@ import java.io.InputStream; import java.io.UncheckedIOException; import java.math.BigInteger; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -26,6 +28,12 @@ class JsonDecoderTest { // highlighting). private static final String TWO_TRACES = loadJson("/two-traces.json"); + // A span with three events, as the agent returned it for a v1.0 tracer payload. + private static final String SPAN_EVENTS_V1 = loadJson("/span-events-v1.json"); + + private static final String REQUIRED_FIELDS = + "\"name\":\"n\",\"trace_id\":1,\"span_id\":1,\"start\":0,\"duration\":0,"; + @Test void decodesTraceAndSpanStructure() { List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); @@ -276,6 +284,88 @@ void preservesMetaStructNumericTypesAcrossNesting() { assertNull(list.get(4), "null leaf preserved"); } + @Test + void decodesStructuredSpanEventsFromV1Payload() { + DecodedSpan span = Decoder.decodeJson(SPAN_EVENTS_V1).getTraces().get(0).getSpans().get(0); + List events = span.getEvents(); + + assertEquals(3, events.size()); + DecodedSpanEvent plain = events.get(0); + assertEquals("plain", plain.getName()); + assertEquals(1792414083896152583L, plain.getTimeUnixNano()); + assertTrue(plain.getAttributes().isEmpty()); + + DecodedSpanEvent typed = events.get(1); + assertEquals("typed", typed.getName()); + Map expected = new LinkedHashMap<>(); + expected.put("str", "value"); + expected.put("int", 42L); + expected.put("double", 12.5d); + expected.put("whole", 2.0d); + expected.put("bool", true); + expected.put("strs", asList("a", "b")); + expected.put("longs", asList(1L, 2L)); + expected.put("doubles", asList(1.5d, 2.5d)); + expected.put("bools", asList(true, false)); + assertEquals(expected, typed.getAttributes()); + + DecodedSpanEvent exception = events.get(2); + assertEquals("exception", exception.getName()); + assertEquals("boom", exception.getAttributes().get("exception.message")); + assertEquals( + "java.lang.IllegalStateException", exception.getAttributes().get("exception.type")); + } + + @Test + void decodesSpanEventsFromMetaTagWithoutStructuredField() { + String json = + oneSpan( + REQUIRED_FIELDS + + "\"meta\": {\"events\": " + + "\"[{\\\"time_unix_nano\\\":1,\\\"name\\\":\\\"event\\\"}]\"}"); + DecodedSpan span = Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); + + assertEquals(1, span.getEvents().size()); + assertEquals("event", span.getEvents().get(0).getName()); + } + + @Test + void rejectsMalformedStructuredSpanEvents() { + assertThrows( + IllegalStateException.class, + () -> + Decoder.decodeJson( + oneSpan(REQUIRED_FIELDS + "\"span_events\": [{\"name\": \"event\"}]")), + "missing time"); + assertThrows( + IllegalStateException.class, + () -> + Decoder.decodeJson( + oneSpan( + REQUIRED_FIELDS + + "\"span_events\": [{\"name\": \"e\", \"time_unix_nano\": 1," + + " \"attributes\": {\"k\": {\"type\": 9}}}]")), + "unknown attribute type"); + assertThrows( + IllegalStateException.class, + () -> + Decoder.decodeJson( + oneSpan( + REQUIRED_FIELDS + + "\"span_events\": [{\"name\": \"e\", \"time_unix_nano\": 1," + + " \"attributes\": {\"k\": {\"string_value\": \"v\"}}}]")), + "untyped attribute"); + assertThrows( + IllegalStateException.class, + () -> + Decoder.decodeJson( + oneSpan( + REQUIRED_FIELDS + + "\"span_events\": [{\"name\": \"e\", \"time_unix_nano\": 1," + + " \"attributes\": {\"k\": {\"type\": 2}}}]")), + "attribute missing its typed value"); + } + @Test void malformedJsonThrows() { IllegalStateException e = diff --git a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/SpanEventsDecoderTest.java b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/SpanEventsDecoderTest.java new file mode 100644 index 00000000000..201f06c90c0 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/SpanEventsDecoderTest.java @@ -0,0 +1,106 @@ +package datadog.trace.test.agent.decoder; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SpanEventsDecoderTest { + // The decoder keeps the tag name private, so spell it out the way the tracer writes it. + private static final String SPAN_EVENTS_TAG = "events"; + + @Test + void absentOrEmptyTagYieldsNoEvent() { + assertTrue(DecodedSpanEvents.fromMeta(emptyMap()).isEmpty(), "no tag in meta"); + assertTrue(fromTag(null).isEmpty(), "null tag"); + assertTrue(fromTag("").isEmpty(), "empty tag"); + assertTrue(fromTag("[]").isEmpty(), "empty array"); + assertTrue(fromTag("null").isEmpty(), "JSON null"); + } + + @Test + void decodesEventWithoutAttributes() { + List events = fromTag("[{\"time_unix_nano\":1234,\"name\":\"event\"}]"); + + assertEquals(1, events.size()); + DecodedSpanEvent event = events.get(0); + assertEquals("event", event.getName()); + assertEquals(1234L, event.getTimeUnixNano()); + assertTrue(event.getAttributes().isEmpty(), "absent attributes default to empty"); + } + + @Test + void decodesTypedAttributes() { + List events = + fromTag( + "[{\"time_unix_nano\":1,\"name\":\"event\",\"attributes\":{" + + "\"str\":\"value\",\"int\":42,\"double\":12.5,\"whole\":2.0,\"exp\":1e3," + + "\"bool\":true,\"arr\":[\"x\",7,2.5,false],\"unsupported\":null}}]"); + + Map expected = new LinkedHashMap<>(); + expected.put("str", "value"); + expected.put("int", 42L); + expected.put("double", 12.5d); + expected.put("whole", 2.0d); + expected.put("exp", 1000d); + expected.put("bool", true); + expected.put("arr", asList("x", 7L, 2.5d, false)); + expected.put("unsupported", null); + assertEquals(expected, events.get(0).getAttributes()); + } + + @Test + void preservesEventOrderAndIgnoresUnknownFields() { + List events = + fromTag( + "[{\"time_unix_nano\":1,\"name\":\"first\",\"extra\":{\"a\":1}}," + + "{\"name\":\"second\",\"time_unix_nano\":2}]"); + + assertEquals(2, events.size()); + assertEquals("first", events.get(0).getName()); + assertEquals("second", events.get(1).getName()); + assertEquals(2L, events.get(1).getTimeUnixNano()); + } + + @Test + void rejectsMalformedEvents() { + assertThrows(IllegalStateException.class, () -> fromTag("not json"), "not JSON"); + assertThrows(IllegalStateException.class, () -> fromTag("[1]"), "non-object event"); + assertThrows(IllegalStateException.class, () -> fromTag("[]garbage"), "trailing garbage"); + assertThrows(IllegalStateException.class, () -> fromTag("[][]"), "concatenated arrays"); + assertThrows(IllegalStateException.class, () -> fromTag("null x"), "trailing data after null"); + assertThrows( + IllegalStateException.class, () -> fromTag("[{\"name\":\"event\"}]"), "missing time"); + assertThrows( + IllegalStateException.class, () -> fromTag("[{\"time_unix_nano\":1}]"), "missing name"); + assertThrows( + IllegalStateException.class, + () -> + fromTag( + "[{\"time_unix_nano\":1,\"name\":\"event\"," + + "\"attributes\":{\"big\":9223372036854775808}}]"), + "integer beyond the long range"); + } + + @Test + void buildsStructuredEvent() { + DecodedSpanEvent event = DecodedSpanEvents.event("event", 1L, singletonMap("key", "value")); + + assertEquals("event", event.getName()); + assertEquals(1L, event.getTimeUnixNano()); + assertEquals(singletonMap("key", "value"), event.getAttributes()); + assertTrue(DecodedSpanEvents.event("event", 1L, null).getAttributes().isEmpty()); + assertTrue(event.toString().contains("name='event'")); + } + + private static List fromTag(String tagValue) { + return DecodedSpanEvents.fromMeta(singletonMap(SPAN_EVENTS_TAG, tagValue)); + } +} diff --git a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1Test.java b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1Test.java index 03e4ece9d6e..a70a5143209 100644 --- a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1Test.java +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1Test.java @@ -1,13 +1,18 @@ package datadog.trace.test.agent.decoder.v1.raw; +import static java.util.Arrays.asList; import static java.util.Collections.emptyList; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import datadog.trace.test.agent.decoder.DecodedSpanEvent; import datadog.trace.test.agent.decoder.DecodedSpanLink; import java.io.IOException; import java.math.BigInteger; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.msgpack.core.MessageBufferPacker; import org.msgpack.core.MessagePack; @@ -71,6 +76,105 @@ void noSpanLinksDecodeToAnEmptyList() throws IOException { assertEquals(emptyList(), SpanV1.unpackSpanLinks(unpacker(packer), new ArrayList<>())); } + @Test + void decodesSpanEventsWithTypedAttributes() throws IOException { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packArrayHeader(1); + packer.packMapHeader(3); + packer.packInt(SpanV1.EVENT_FIELD_TIME); + packer.packLong(1_234L); + packer.packInt(SpanV1.EVENT_FIELD_NAME); + packer.packString("event"); + packer.packInt(SpanV1.EVENT_FIELD_ATTRIBUTES); + packer.packArrayHeader(5 * 3); + packAttributeKey(packer, "str", SpanV1.STRING_VALUE_TYPE); + packer.packString("value"); + packAttributeKey(packer, "bool", SpanV1.BOOL_VALUE_TYPE); + packer.packBoolean(true); + packAttributeKey(packer, "int", SpanV1.INT_VALUE_TYPE); + packer.packLong(42L); + packAttributeKey(packer, "double", SpanV1.FLOAT_VALUE_TYPE); + packer.packDouble(12.5d); + packAttributeKey(packer, "arr", SpanV1.ARRAY_VALUE_TYPE); + packer.packArrayHeader(2 * 2); + packer.packInt(SpanV1.STRING_VALUE_TYPE); + packer.packString("x"); + packer.packInt(SpanV1.INT_VALUE_TYPE); + packer.packLong(7L); + + List events = SpanV1.unpackSpanEvents(unpacker(packer), new ArrayList<>()); + + assertEquals(1, events.size()); + DecodedSpanEvent event = events.get(0); + assertEquals("event", event.getName()); + assertEquals(1_234L, event.getTimeUnixNano()); + Map expected = new LinkedHashMap<>(); + expected.put("str", "value"); + expected.put("bool", true); + expected.put("int", 42L); + expected.put("double", 12.5d); + expected.put("arr", asList("x", 7L)); + assertEquals(expected, event.getAttributes()); + } + + @Test + void spanEventsKeepTheStringTableInSync() throws IOException { + // The event introduces "event" and "key" in the string table, then the span type refers to + // "key" by its index: decoding the events must have registered both strings. + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packMapHeader(2); + packer.packInt(SpanV1.SPAN_FIELD_SPAN_EVENTS); + packer.packArrayHeader(1); + packer.packMapHeader(3); + packer.packInt(SpanV1.EVENT_FIELD_TIME); + packer.packLong(1L); + packer.packInt(SpanV1.EVENT_FIELD_NAME); + packer.packString("event"); + packer.packInt(SpanV1.EVENT_FIELD_ATTRIBUTES); + packer.packArrayHeader(3); + packAttributeKey(packer, "key", SpanV1.INT_VALUE_TYPE); + packer.packLong(1L); + packer.packInt(SpanV1.SPAN_FIELD_TYPE); + packer.packInt(2); + List stringTable = new ArrayList<>(); + stringTable.add(""); + + SpanV1 span = SpanV1.unpack(unpacker(packer), stringTable); + + assertEquals("key", span.getType(), "string index after the events"); + assertEquals(1, span.getEvents().size()); + assertEquals("event", span.getEvents().get(0).getName()); + } + + @Test + void rejectsSpanEventsMissingRequiredFields() throws IOException { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packArrayHeader(1); + packer.packMapHeader(1); + packer.packInt(SpanV1.EVENT_FIELD_NAME); + packer.packString("event"); + MessageUnpacker unpacker = unpacker(packer); + + assertThrows( + IllegalArgumentException.class, + () -> SpanV1.unpackSpanEvents(unpacker, new ArrayList<>()), + "missing time"); + } + + @Test + void noSpanEventsDecodeToAnEmptyList() throws IOException { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packArrayHeader(0); + + assertEquals(emptyList(), SpanV1.unpackSpanEvents(unpacker(packer), new ArrayList<>())); + } + + private static void packAttributeKey(MessageBufferPacker packer, String key, int valueType) + throws IOException { + packer.packString(key); + packer.packInt(valueType); + } + private static long spanIdOf(BigInteger id) throws IOException { MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); packer.packMapHeader(1); diff --git a/utils/test-agent-utils/decoder/src/test/resources/span-events-v1.json b/utils/test-agent-utils/decoder/src/test/resources/span-events-v1.json new file mode 100644 index 00000000000..3aedc1d007f --- /dev/null +++ b/utils/test-agent-utils/decoder/src/test/resources/span-events-v1.json @@ -0,0 +1,134 @@ +[ + [ + { + "service": "probe", + "name": "internal", + "resource": "probe-span", + "span_id": 5036070619896663290, + "parent_id": 0, + "start": 1792414083893647083, + "duration": 3332208, + "error": 0, + "meta": { + "span.kind": "internal", + "service": "probe", + "language": "jvm" + }, + "metrics": {}, + "type": "", + "span_links": [], + "span_events": [ + { + "time_unix_nano": 1792414083896152583, + "name": "plain", + "attributes": {} + }, + { + "time_unix_nano": 1792414083896513708, + "name": "typed", + "attributes": { + "bools": { + "type": 4, + "array_value": { + "values": [ + { + "type": 1, + "bool_value": true + }, + { + "type": 1, + "bool_value": false + } + ] + } + }, + "str": { + "type": 0, + "string_value": "value" + }, + "longs": { + "type": 4, + "array_value": { + "values": [ + { + "type": 2, + "int_value": 1 + }, + { + "type": 2, + "int_value": 2 + } + ] + } + }, + "bool": { + "type": 1, + "bool_value": true + }, + "strs": { + "type": 4, + "array_value": { + "values": [ + { + "type": 0, + "string_value": "a" + }, + { + "type": 0, + "string_value": "b" + } + ] + } + }, + "doubles": { + "type": 4, + "array_value": { + "values": [ + { + "type": 3, + "double_value": 1.5 + }, + { + "type": 3, + "double_value": 2.5 + } + ] + } + }, + "double": { + "type": 3, + "double_value": 12.5 + }, + "whole": { + "type": 3, + "double_value": 2.0 + }, + "int": { + "type": 2, + "int_value": 42 + } + } + }, + { + "time_unix_nano": 1792414083896975500, + "name": "exception", + "attributes": { + "exception.stacktrace": { + "type": 0, + "string_value": "java.lang.IllegalStateException: boom\n\tat Probe.main(Probe.java:15)\n" + }, + "exception.type": { + "type": 0, + "string_value": "java.lang.IllegalStateException" + }, + "exception.message": { + "type": 0, + "string_value": "boom" + } + } + } + ], + "trace_id": 5991716161018019997 + } + ] +]