From e1c9a645c652a174b5ef22836389f0736f9a8721 Mon Sep 17 00:00:00 2001 From: Alexey Kuznetsov Date: Wed, 14 Oct 2026 18:21:23 -0400 Subject: [PATCH] Preserve v1 span events in test-agent decoders. Spans decoded from v1 payloads lost their span events in both test-agent decoders. The JSON decoder now reads the agent's native span_events field as raw JSON, keeping number literals verbatim, and exposes it as the events meta tag. The msgpack SpanV1 decoder skipped the span events field, which also consumed streaming strings without registering them and desynchronized the string table. It now decodes the events through the table-aware helpers into the same events meta tag the tracer writes for v0.4. OpenTelemetrySmokeTest now runs once per trace agent protocol (v0.4, v0.5 and v1.0), so the v1 span events and links path is covered end to end even though v0.4 is the default protocol. (cherry picked from commits aabb490c8aa8980e24a0a69d4845124fbf1dc05a and c385b63bd89dfea59cde68105f1ec643e82f80cd) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../smoketest/OpenTelemetrySmokeTest.java | 107 ++++++++++++++-- .../agent/decoder/json/raw/MessageJson.java | 87 +++++++++++++ .../test/agent/decoder/json/raw/SpanJson.java | 22 ++++ .../test/agent/decoder/v1/raw/SpanV1.java | 120 +++++++++++++++++- .../test/agent/decoder/JsonDecoderTest.java | 64 ++++++++++ .../test/agent/decoder/v1/raw/SpanV1Test.java | 82 +++++++++++- 6 files changed, 466 insertions(+), 16 deletions(-) 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..957dc4edd1a 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 @@ -6,17 +6,37 @@ import static datadog.smoketest.trace.SpanMatcher.span; import static datadog.smoketest.trace.TraceMatcher.trace; import static datadog.trace.test.junit.utils.assertions.Matchers.validates; +import static java.util.Arrays.asList; +import static java.util.Arrays.stream; import static java.util.Comparator.comparing; import static java.util.concurrent.TimeUnit.SECONDS; import datadog.smoketest.trace.SpanMatcher; import datadog.smoketest.trace.TraceMatcher.Options; +import datadog.trace.api.ProtocolVersion; import datadog.trace.test.agent.decoder.DecodedSpan; import java.io.File; +import java.util.List; import java.util.function.UnaryOperator; +import java.util.stream.Stream; +import org.junit.jupiter.api.ClassTemplate; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.extension.AfterClassTemplateInvocationCallback; +import org.junit.jupiter.api.extension.BeforeClassTemplateInvocationCallback; +import org.junit.jupiter.api.extension.ClassTemplateInvocationContext; +import org.junit.jupiter.api.extension.ClassTemplateInvocationContextProvider; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestInstancePostProcessor; +/** + * Runs the OpenTelemetry app once per trace agent protocol: span events and links travel as meta + * tags with {@code v0.4} and {@code v0.5} but as native span fields with {@code v1.0}, and must + * decode alike. + */ +@ClassTemplate +@ExtendWith(OpenTelemetrySmokeTest.AppPerProtocol.class) class OpenTelemetrySmokeTest { private static final UnaryOperator SORT_BY_RESOURCE_NAME = options -> options.sort(comparing(DecodedSpan::getResource)); @@ -26,14 +46,8 @@ class OpenTelemetrySmokeTest { private static final String APPLICATION_JAR = System.getProperty("datadog.smoketest.shadowJar.path"); - @RegisterExtension - static final SmokeCliApp app = - SmokeCliApp.named("opentelemetry") - .jar(APPLICATION_JAR) - .jvmArgs("-Ddd.trace.otel.enabled=true") - .workingDirectory(WORKING_DIRECTORY) - .backend(testAgent()) - .build(); + /** The app of the current protocol, set by {@link AppLifecycle}. */ + private SmokeCliApp app; @Test void receivesTraces() { @@ -82,6 +96,17 @@ void receivesTraces() { app.assertCompletesWithValue(TIMEOUT_SECONDS, SECONDS, 0); } + private static SmokeCliApp app(ProtocolVersion protocol) { + return SmokeCliApp.named("opentelemetry") + .jar(APPLICATION_JAR) + .jvmArgs( + "-Ddd.trace.otel.enabled=true", + "-Ddd.trace.agent.protocol.version=" + protocol.asConfigValue()) + .workingDirectory(WORKING_DIRECTORY) + .backend(testAgent()) + .build(); + } + private static SpanMatcher shard(int shard) { return span() .operationName("internal") @@ -89,4 +114,68 @@ private static SpanMatcher shard(int shard) { .childOfIndex(2) .metric("batch.shard", validates(value -> value.intValue() == shard)); } + + /** + * Runs the class once per {@link ProtocolVersion}, each invocation with its own app. Class-level + * callbacks such as {@code BeforeAllCallback} run once per class template, not per invocation, so + * {@link AppLifecycle} launches and stops the app around its invocation instead. + */ + static final class AppPerProtocol implements ClassTemplateInvocationContextProvider { + @Override + public boolean supportsClassTemplate(ExtensionContext context) { + return true; + } + + @Override + public Stream provideClassTemplateInvocationContexts( + ExtensionContext context) { + return stream(ProtocolVersion.values()).map(AppPerProtocol::invocation); + } + + private static ClassTemplateInvocationContext invocation(ProtocolVersion protocol) { + SmokeCliApp app = app(protocol); + return new ClassTemplateInvocationContext() { + @Override + public String getDisplayName(int invocationIndex) { + return "protocol " + protocol.asConfigValue(); + } + + @Override + public List getAdditionalExtensions() { + // The app itself for its per-test-method callbacks. + return asList(app, new AppLifecycle(app)); + } + }; + } + } + + /** + * Launches and stops an app around a class template invocation, and sets it on each test instance + * of that invocation. + */ + static final class AppLifecycle + implements BeforeClassTemplateInvocationCallback, + AfterClassTemplateInvocationCallback, + TestInstancePostProcessor { + private final SmokeCliApp app; + + AppLifecycle(SmokeCliApp app) { + this.app = app; + } + + @Override + public void beforeClassTemplateInvocation(ExtensionContext context) throws Exception { + this.app.beforeAll(context); + } + + @Override + public void afterClassTemplateInvocation(ExtensionContext context) { + this.app.afterAll(context); + } + + @Override + public void postProcessTestInstance(Object testInstance, ExtensionContext context) { + ((OpenTelemetrySmokeTest) testInstance).app = this.app; + } + } } 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..dae87d04c92 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 @@ -1,10 +1,12 @@ package datadog.trace.test.agent.decoder.json.raw; +import static java.lang.annotation.RetentionPolicy.RUNTIME; import static java.util.Collections.unmodifiableList; import com.squareup.moshi.FromJson; import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.JsonQualifier; import com.squareup.moshi.JsonReader; import com.squareup.moshi.JsonWriter; import com.squareup.moshi.Moshi; @@ -14,12 +16,15 @@ import datadog.trace.test.agent.decoder.DecodedSpan; import datadog.trace.test.agent.decoder.DecodedTrace; import java.io.IOException; +import java.lang.annotation.Retention; import java.lang.reflect.Type; import java.math.BigInteger; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import okio.Buffer; +import okio.BufferedSink; /** * MessageJson decodes a JSON trace payload — a JSON array of traces, each a JSON array of spans in @@ -35,6 +40,7 @@ public final class MessageJson implements DecodedMessage { new Moshi.Builder() .add(new MetricNumberAdapter()) .add(new MetaStructObjectAdapter()) + .add(new RawJsonAdapter()) .build() .adapter(LIST_OF_TRACES); @@ -74,6 +80,7 @@ public static MessageJson fromJson(String json) { + span); } span.resolveLinks(); + span.resolveEvents(); decodedSpans.add(span); } traces.add(new TraceJson(decodedSpans)); @@ -188,6 +195,86 @@ private static Number parseNumber(String literal) { } } + /** Marks a {@code String} field that holds a JSON value as compact JSON text. */ + @Retention(RUNTIME) + @JsonQualifier + @interface RawJson {} + + /** + * Decodes a {@link RawJson} field by copying the JSON value token by token into compact JSON + * text. Number literals are copied verbatim rather than materialized as {@code Double}, so values + * above 2^53 or beyond {@code long} range, and fractional values, keep their exact textual form. + * A JSON {@code null} decodes to a {@code null} field. + */ + static final class RawJsonAdapter { + @FromJson + @RawJson + String fromJson(JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } + Buffer buffer = new Buffer(); + JsonWriter writer = JsonWriter.of(buffer); + // Keep null members ("key":null) rather than dropping them. + writer.setSerializeNulls(true); + copyValue(reader, writer); + writer.flush(); + return buffer.readUtf8(); + } + + @ToJson + void toJson(JsonWriter writer, @RawJson String value) throws IOException { + if (value == null) { + writer.nullValue(); + return; + } + try (BufferedSink sink = writer.valueSink()) { + sink.writeUtf8(value); + } + } + + private static void copyValue(JsonReader reader, JsonWriter writer) throws IOException { + switch (reader.peek()) { + case BEGIN_OBJECT: + reader.beginObject(); + writer.beginObject(); + while (reader.hasNext()) { + writer.name(reader.nextName()); + copyValue(reader, writer); + } + reader.endObject(); + writer.endObject(); + break; + case BEGIN_ARRAY: + reader.beginArray(); + writer.beginArray(); + while (reader.hasNext()) { + copyValue(reader, writer); + } + reader.endArray(); + writer.endArray(); + break; + case STRING: + writer.value(reader.nextString()); + break; + case NUMBER: + try (BufferedSink sink = writer.valueSink()) { + sink.writeUtf8(reader.nextString()); + } + break; + case BOOLEAN: + writer.value(reader.nextBoolean()); + break; + case NULL: + reader.nextNull(); + writer.nullValue(); + break; + default: + throw new JsonDataException("Unexpected JSON token: " + reader.peek()); + } + } + } + @Override public List getTraces() { return this.traces; 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..7cee1cf20cf 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 @@ -9,6 +9,7 @@ import datadog.trace.test.agent.decoder.DecodedSpanLink; import datadog.trace.test.agent.decoder.DecodedSpanLinks; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -53,6 +54,12 @@ public final class SpanJson implements DecodedSpan { @Json(name = "span_links") List spanLinks; + // Only present in V1 payload. Kept as compact JSON text (number literals verbatim, see + // MessageJson.RawJsonAdapter) and exposed as the events meta tag by #resolveEvents + @Json(name = "span_events") + @MessageJson.RawJson + String spanEvents; + // Decoded from span links meta tag or #spanLinks // transient so Moshi does not try to decode it transient List links; @@ -146,6 +153,21 @@ void resolveLinks() { this.links = unmodifiableList(decoded); } + /** + * Exposes the {@code span_events} field, when the agent filled it (the tracer submitted a {@code + * v1.0} payload), as the {@code events} meta tag, replacing any such tag. The value is the + * agent's JSON re-emitted compactly; its member order and attribute shape are the agent's, not + * those of the tag the tracer writes for older protocols. + */ + void resolveEvents() { + if (this.spanEvents != null) { + if (this.meta == null) { + this.meta = new HashMap<>(); + } + this.meta.put("events", this.spanEvents); + } + } + @Override public String toString() { return "SpanJson{" 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..7baeaf2572b 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 @@ -5,14 +5,17 @@ import static java.util.Collections.unmodifiableList; import static java.util.Collections.unmodifiableMap; +import com.squareup.moshi.JsonWriter; import datadog.trace.test.agent.decoder.DecodedSpan; 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 okio.Buffer; import org.msgpack.core.MessageFormat; import org.msgpack.core.MessagePack; import org.msgpack.core.MessageUnpacker; @@ -59,6 +62,14 @@ 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 (from TraceMapperV1.encodeSpanEvents) + static final int EVENT_FIELD_TIME = 1; + static final int EVENT_FIELD_NAME = 2; + static final int EVENT_FIELD_ATTRIBUTES = 3; + + // The meta tag the tracer encodes span events into for protocols without native events + static final String SPAN_EVENTS_TAG = "events"; + // Attribute value types static final int STRING_VALUE_TYPE = 1; static final int BOOL_VALUE_TYPE = 2; @@ -172,10 +183,10 @@ 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(); + String events = unpackSpanEvents(unpacker, stringTable); + if (events != null) { + meta.put(SPAN_EVENTS_TAG, events); + } break; case SPAN_FIELD_ENV: env = unpackStreamingString(unpacker, stringTable); @@ -396,6 +407,107 @@ static List unpackSpanLinks(MessageUnpacker unpacker, ListEvent names and attributes are streaming strings, so they are 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 JSON tag value, {@code null} if there are no events. + * @throws IOException If unpacking fails. + */ + static String unpackSpanEvents(MessageUnpacker unpacker, List stringTable) + throws IOException { + int eventCount = unpacker.unpackArrayHeader(); + if (eventCount == 0) { + return null; + } + List> events = new ArrayList<>(eventCount); + for (int i = 0; i < eventCount; i++) { + int fieldCount = unpacker.unpackMapHeader(); + long time = 0; + String name = ""; + Map attributes = new LinkedHashMap<>(); + for (int field = 0; field < fieldCount; field++) { + switch (unpacker.unpackInt()) { + case EVENT_FIELD_TIME: + time = unpacker.unpackLong(); + break; + case EVENT_FIELD_NAME: + name = unpackStreamingString(unpacker, stringTable); + break; + case EVENT_FIELD_ATTRIBUTES: + forEachAttribute( + unpacker, + stringTable, + (attributeUnpacker, table, key, valueType) -> + attributes.put( + key, unpackEventAttributeValue(attributeUnpacker, table, valueType))); + break; + default: + // Unknown event field; numeric or binary, so it cannot hold a streaming string. + unpacker.skipValue(); + break; + } + } + Map event = new LinkedHashMap<>(); + event.put("time_unix_nano", time); + event.put("name", name); + if (!attributes.isEmpty()) { + event.put("attributes", attributes); + } + events.add(event); + } + Buffer buffer = new Buffer(); + JsonWriter writer = JsonWriter.of(buffer); + // Unsupported attribute values are written as null and non-finite doubles as bare literals, + // as the tracer's events tag does. + writer.setSerializeNulls(true); + writer.setLenient(true); + writer.jsonValue(events); + writer.flush(); + return buffer.readUtf8(); + } + + /** + * Unpacks an event attribute value: a string, boolean, double, long, or a list of them. Value + * types the tracer never writes for events decode to {@code null}. + */ + 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(); + case ARRAY_VALUE_TYPE: + int arraySize = unpacker.unpackArrayHeader(); + // Array contains pairs (type, value), so size must be even + if (arraySize % 2 != 0) { + throw new IllegalArgumentException( + "Event attribute array size must be even, got: " + arraySize); + } + List items = new ArrayList<>(arraySize / 2); + for (int i = 0; i < arraySize / 2; i++) { + items.add(unpackEventAttributeValue(unpacker, stringTable, unpacker.unpackInt())); + } + return items; + default: + unpacker.skipValue(); + return null; + } + } + /** * Unpacks an identifier the writer emitted as an unsigned 64-bit value. * 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..20a7d55b59a 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 @@ -26,6 +26,70 @@ class JsonDecoderTest { // highlighting). private static final String TWO_TRACES = loadJson("/two-traces.json"); + @Test + void exposesNativeSpanEventsWithNumberLiteralsVerbatim() { + String events = + "[{\"name\":\"configuration-loaded\",\"time_unix_nano\":1791902357478000001," + + "\"attributes\":{\"source\":\"file\",\"count\":3,\"cached\":false," + + "\"ratio\":0.123456789012,\"huge\":18446744073709551616,\"missing\":null," + + "\"list\":[1,2.5,\"three\"]}}]"; + DecodedSpan span = decodeSpanWithEvents("\"span_events\":" + events); + + assertEquals(events, span.getMeta().get("events")); + } + + @Test + void compactsNativeSpanEvents() { + DecodedSpan span = + decodeSpanWithEvents( + "\"span_events\": [ {\"name\": \"a \\\"quoted\\\" name\", \"time_unix_nano\": 1e3} ]"); + + assertEquals( + "[{\"name\":\"a \\\"quoted\\\" name\",\"time_unix_nano\":1e3}]", + span.getMeta().get("events")); + } + + @Test + void preservesLegacySpanEventsTag() { + String events = "[{\"name\":\"configuration-loaded\"}]"; + DecodedSpan span = + decodeSpanWithEvents( + "\"meta\":{\"events\":\"[{\\\"name\\\":\\\"configuration-loaded\\\"}]\"}"); + + assertEquals(events, span.getMeta().get("events")); + } + + @Test + void nativeSpanEventsReplaceLegacySpanEventsTag() { + DecodedSpan span = + decodeSpanWithEvents( + "\"meta\":{\"events\":\"legacy\"},\"span_events\":[{\"name\":\"native\"}]"); + + assertEquals("[{\"name\":\"native\"}]", span.getMeta().get("events")); + } + + @Test + void emptyNativeSpanEventsDecodeToAnEmptyArray() { + assertEquals("[]", decodeSpanWithEvents("\"span_events\":[]").getMeta().get("events")); + } + + @Test + void nullOrAbsentNativeSpanEventsLeaveNoEventsTag() { + assertNull(decodeSpanWithEvents("\"span_events\":null").getMeta().get("events")); + assertNull(decodeSpanWithEvents("\"meta\":{}").getMeta().get("events")); + } + + private static DecodedSpan decodeSpanWithEvents(String fields) { + return Decoder.decodeJson( + "[[{\"name\":\"test\",\"trace_id\":1,\"span_id\":2,\"start\":3,\"duration\":4," + + fields + + "}]]") + .getTraces() + .get(0) + .getSpans() + .get(0); + } + @Test void decodesTraceAndSpanStructure() { List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); 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..1c9f6d50ced 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,7 +1,9 @@ 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.assertFalse; import datadog.trace.test.agent.decoder.DecodedSpanLink; import java.io.IOException; @@ -14,9 +16,9 @@ import org.msgpack.core.MessageUnpacker; /** - * Tests the {@code v1.0} span decoder against identifiers whose high bit is set. {@code - * TraceMapperV1} writes span identifiers with {@code writeUnsignedLong}, so those arrive as msgpack - * {@code UINT64} — a shape the {@code sample_v1.msgpack} fixture never produces. + * Tests the {@code v1.0} span decoder against shapes the {@code sample_v1.msgpack} fixture never + * produces: identifiers whose high bit is set ({@code TraceMapperV1} writes span identifiers with + * {@code writeUnsignedLong}, so those arrive as msgpack {@code UINT64}) and span events. */ class SpanV1Test { // 0xfedcba9876543210: an unsigned 64-bit identifier above Long.MAX_VALUE. @@ -71,6 +73,80 @@ void noSpanLinksDecodeToAnEmptyList() throws IOException { assertEquals(emptyList(), SpanV1.unpackSpanLinks(unpacker(packer), new ArrayList<>())); } + @Test + void decodesSpanEventsIntoTheEventsTag() throws IOException { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packArrayHeader(2); + // First event: every attribute value type, introducing new streaming strings. + packer.packMapHeader(3); + packer.packInt(SpanV1.EVENT_FIELD_TIME); + packer.packLong(1791902357478000001L); + packer.packInt(SpanV1.EVENT_FIELD_NAME); + packer.packString("configuration-loaded"); // string table index 0 + packer.packInt(SpanV1.EVENT_FIELD_ATTRIBUTES); + packer.packArrayHeader(5 * 3); + packer.packString("source").packInt(SpanV1.STRING_VALUE_TYPE).packString("file \"local\""); + packer.packString("count").packInt(SpanV1.INT_VALUE_TYPE).packLong(3); + packer.packString("ratio").packInt(SpanV1.FLOAT_VALUE_TYPE).packDouble(2.5); + packer.packString("cached").packInt(SpanV1.BOOL_VALUE_TYPE).packBoolean(false); + packer.packString("tags").packInt(SpanV1.ARRAY_VALUE_TYPE).packArrayHeader(2 * 2); + packer.packInt(SpanV1.STRING_VALUE_TYPE).packInt(0); // reference to "configuration-loaded" + packer.packInt(SpanV1.INT_VALUE_TYPE).packLong(1); + // Second event: name by reference, no attributes. + packer.packMapHeader(3); + packer.packInt(SpanV1.EVENT_FIELD_TIME); + packer.packLong(2); + packer.packInt(SpanV1.EVENT_FIELD_NAME); + packer.packInt(0); + packer.packInt(SpanV1.EVENT_FIELD_ATTRIBUTES); + packer.packArrayHeader(0); + List stringTable = new ArrayList<>(); + + String events = SpanV1.unpackSpanEvents(unpacker(packer), stringTable); + + assertEquals( + "[{\"time_unix_nano\":1791902357478000001,\"name\":\"configuration-loaded\"," + + "\"attributes\":{\"source\":\"file \\\"local\\\"\",\"count\":3,\"ratio\":2.5," + + "\"cached\":false,\"tags\":[\"configuration-loaded\",1]}}," + + "{\"time_unix_nano\":2,\"name\":\"configuration-loaded\"}]", + events); + assertEquals( + asList( + "configuration-loaded", "source", "file \"local\"", "count", "ratio", "cached", "tags"), + stringTable, + "event strings registered in the string table"); + } + + @Test + void spanEventsKeepTheStringTableInSync() throws IOException { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packMapHeader(2); + packer.packInt(SpanV1.SPAN_FIELD_SPAN_EVENTS); + packer.packArrayHeader(1); + packer.packMapHeader(1); + packer.packInt(SpanV1.EVENT_FIELD_NAME); + packer.packString("event-name"); // string table index 0 + packer.packInt(SpanV1.SPAN_FIELD_SERVICE); + packer.packInt(0); // reference to the string the event introduced + + SpanV1 span = SpanV1.unpack(unpacker(packer), new ArrayList<>()); + + assertEquals("[{\"time_unix_nano\":0,\"name\":\"event-name\"}]", span.getMeta().get("events")); + assertEquals("event-name", span.getService()); + } + + @Test + void noSpanEventsLeaveNoEventsTag() throws IOException { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packMapHeader(1); + packer.packInt(SpanV1.SPAN_FIELD_SPAN_EVENTS); + packer.packArrayHeader(0); + + SpanV1 span = SpanV1.unpack(unpacker(packer), new ArrayList<>()); + + assertFalse(span.getMeta().containsKey("events")); + } + private static long spanIdOf(BigInteger id) throws IOException { MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); packer.packMapHeader(1);