diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt index a68d39d6701..63715868802 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt @@ -83,6 +83,7 @@ object KnownTagsEmitter { appendLine(" public static final String ${otelNameC(t.ddName)} = \"${escape(t.otelName)}\";") } append("// makeTagId(serial=${t.serial})") + append(if (t.slot == TagRegistry.NO_SLOT) " + no slot" else " + slot=${t.slot}") if (t.traceLevel) append(" + trace-level") if (t.intercepted) append(" + intercepted") if (t.otelName != null) append(" -> ${escape(t.otelName)}") @@ -100,6 +101,9 @@ object KnownTagsEmitter { // Every known tag's serial is below SERIAL_LIMIT; serial 0 is no tag. val serialLimit = (reg.tags.maxOfOrNull { it.serial } ?: 0) + 1 appendLine(" public static final int SERIAL_LIMIT = $serialLimit;") + // Slots in use: every slotted tag's slot is below SLOT_COUNT. + val slotCount = (reg.tags.filter { it.slot != TagRegistry.NO_SLOT }.maxOfOrNull { it.slot } ?: -1) + 1 + appendLine(" public static final int SLOT_COUNT = $slotCount;") // OpenTelemetry name -> canonical tag name. Validation ensures aliases are distinct from all // canonical names. Sort by OTel name to keep output deterministic. diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt index 248471c228a..cf1eb3e572b 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt @@ -2,12 +2,20 @@ package datadog.buildlogic.tagRegistry /** * Assigns tag ids from a parsed [TagConventions]. The id encoding mirrors KnownTagCodec: [63-48 - * serial][47-32 reserved][31-0 flags]. + * serial][47-32 slot][31-0 flags]. * - *

An id is a globally unique serial plus two classification bits: trace-level and intercepted. It - * carries no storage-layout coordinate -- bits [47-32] are held vacant for the co-occurrence slot - * that the dense tag store assigns by graph coloring, which lands with the dense store itself. - * Nothing here needs to know how (or whether) a tag is stored. + *

An id is a globally unique serial, a slot, and two classification bits: trace-level and + * intercepted. + * + *

The slot comes from GRAPH COLORING the tag co-occurrence graph. Each concrete span type's + * resolved tags appear together on one span, so they form a clique and need distinct slots; the + * trace-level tier is its own clique (it lives in a separate TagMap). Tags that never share a clique + * may share a slot, so the slot count stays bounded by the largest clique, small enough for one + * occupancy `long`. Every tag resolved onto a span type, or trace-level, is slotted whatever its + * requirement level; a tag on no modeled span type (the tracer overlay's routing-only keys, tags of + * unmodeled span types) gets [NO_SLOT]. Nothing in the tracer reads slots yet: they are for + * per-span structures that want an exact, compact presence word, such as a dense tag store or a + * span builder's tag ledger. * *

The intercepted bit marks a tag the tracer may route on the set-path (to a span field or a * sampling directive instead of tag storage), as listed by the tracer overlay. A setter called with @@ -26,6 +34,8 @@ class TagRegistry private constructor(val tags: List) { val serial: Int, val traceLevel: Boolean, val intercepted: Boolean, + /** The tag's slot, or [NO_SLOT] for a tag on no modeled span type. */ + val slot: Int, val id: Long, /** The tag's OpenTelemetry name in every direction; null when not renamed, or renamed per direction. */ val otelName: String? = null, @@ -42,13 +52,17 @@ class TagRegistry private constructor(val tags: List) { const val INTERCEPTED = 1L shl 1 // low-32 carve bit 1; mirrors KnownTagCodec.INTERCEPTED const val LEVEL_TRACE = 1L shl 2 // low-32 carve bit 2; mirrors KnownTagCodec.LEVEL_TRACE + const val SLOT_SHIFT = 32 // slot window [47-32]; mirrors KnownTagCodec.SLOT_SHIFT + const val NO_SLOT = 0xFFFF // all-ones slot: no modeled span type; mirrors KnownTagCodec.NO_SLOT + const val MAX_SLOTS = 63 // fits one occupancy long; bit 63 is left for unslotted tags to share + /** - * Mirrors KnownTagCodec.makeTagId(serial) + intercepted() + traceLevel() -- must stay in sync. - * INTERCEPTED at bit 1, LEVEL_TRACE at bit 2, other low bits and the reserved [47-32] window - * zero. + * Mirrors KnownTagCodec's layout -- must stay in sync: serial at [63-48], slot at [47-32], + * INTERCEPTED at bit 1, LEVEL_TRACE at bit 2, other low bits zero. */ - fun encode(serial: Int, traceLevel: Boolean, intercepted: Boolean): Long { - var id = serial.toLong() shl 48 + fun encode(serial: Int, traceLevel: Boolean, intercepted: Boolean, slot: Int = NO_SLOT): Long { + require(slot == NO_SLOT || slot in 0 until MAX_SLOTS) { "slot $slot is outside [0, $MAX_SLOTS)" } + var id = (serial.toLong() shl 48) or (slot.toLong() shl SLOT_SHIFT) if (traceLevel) id = id or LEVEL_TRACE if (intercepted) id = id or INTERCEPTED return id @@ -57,6 +71,7 @@ class TagRegistry private constructor(val tags: List) { fun build(conv: TagConventions): TagRegistry { val traceLevel = conv.traceLevelTags().map { it.ddName }.toSet() val renames = conv.otelMappings().groupBy { it.tag.ddName } + val slots = colorSlots(conv) // One tag per Datadog name, in a stable order (by name) so serials -- and therefore ids -- are // a pure function of the input. A name's declarations per direction merge into its one tag. @@ -70,6 +85,7 @@ class TagRegistry private constructor(val tags: List) { val isTraceLevel = first.ddName in traceLevel val isIntercepted = first.ddName in conv.interceptedNames val (otelName, scoped) = otelNamesOf(first.ddName, renames[first.ddName].orEmpty()) + val slot = slots[first.ddName] ?: NO_SLOT Tag( first.ddName, first.type, @@ -77,7 +93,8 @@ class TagRegistry private constructor(val tags: List) { serial, isTraceLevel, isIntercepted, - id = encode(serial, isTraceLevel, isIntercepted), + slot, + id = encode(serial, isTraceLevel, isIntercepted, slot), otelName = otelName, scopedOtelNames = scoped, ) @@ -87,6 +104,41 @@ class TagRegistry private constructor(val tags: List) { return TagRegistry(tags) } + /** + * Colors the co-occurrence graph of slotted tags: tags resolved onto one concrete span type, or + * both trace-level, must get distinct slots. Greedy, most-constrained first (by clique count, + * then name), so the result is a pure function of the conventions. A name declared per direction + * is one tag, so it is one vertex. + */ + private fun colorSlots(conv: TagConventions): Map { + val cliques = ArrayList>() + for (type in conv.concreteTypes()) { + cliques.add(conv.resolve(type).map { it.ddName }.toSet()) + } + cliques.add(conv.traceLevelTags().map { it.ddName }.toSet()) + + val neighbors = HashMap>() + for (clique in cliques) { + for (a in clique) { + val adjacent = neighbors.getOrPut(a) { HashSet() } + for (b in clique) if (a != b) adjacent.add(b) + } + } + val cliqueCount = neighbors.keys.associateWith { tag -> cliques.count { tag in it } } + val order = neighbors.keys.sortedWith(compareByDescending { cliqueCount.getValue(it) }.thenBy { it }) + val slots = HashMap() + for (tag in order) { + val taken = neighbors.getValue(tag).mapNotNull { slots[it] }.toSet() + var slot = 0 + while (slot in taken) slot++ + require(slot < MAX_SLOTS) { + "coloring needs more than $MAX_SLOTS slots at '$tag'; one occupancy long holds $MAX_SLOTS" + } + slots[tag] = slot + } + return slots + } + /** * A tag's renames, as one name for every direction or one name per direction. The rename of a * declaration made per direction applies in that declaration's direction. diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt index eaae7ccc1a6..3baf2214638 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt @@ -70,16 +70,17 @@ object TagRegistryGenerator { """ # Tag id assignment. tags=${reg.tags.size} - # TAGS serial lvl int id required name + # TAGS serial lvl int slot id required name """.trimIndent() ) for (t in reg.tags) { appendLine( - " %6d %s %s %-18s %-12s %s".format( + " %6d %s %s %4s %-18s %-12s %s".format( Locale.ROOT, t.serial, if (t.traceLevel) "T" else "-", if (t.intercepted) "I" else "-", + if (t.slot == TagRegistry.NO_SLOT) "-" else t.slot.toString(), "0x%016X".format(Locale.ROOT, t.id), t.required, t.name diff --git a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt index a88f7509144..6db58e6f27b 100644 --- a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt +++ b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt @@ -535,9 +535,9 @@ class TagRegistryGeneratorTest { val knownTags = contents(output).getValue("java/datadog/trace/api/KnownTags.java") assertThat(knownTags) .contains( - "COMPONENT_ID = 0x0001000000000000L", - "RESOURCE_NAME_ID = 0x0002000000000002L", - "SERVICE_ID = 0x0003000000000002L", + "COMPONENT_ID = 0x0001FFFF00000000L", + "RESOURCE_NAME_ID = 0x0002FFFF00000002L", + "SERVICE_ID = 0x0003FFFF00000002L", "public static final int SERVICE_SERIAL_NUM = 3;", "public static final int SERIAL_LIMIT = 4;" ) @@ -561,6 +561,44 @@ class TagRegistryGeneratorTest { .withMessageContaining(message) } + @Test + fun `tags on one span type get distinct slots and tags that never co-occur share one`() { + val yaml = directory.conventionsFile( + """ + trace_level: + tags: [{dd-name: env, required: required}] + span_types: + base: + abstract: true + tags: [{dd-name: service, required: required}] + http.server: + extends: base + span-kind: server + tags: [{dd-name: http.route, required: required}] + db.client: + extends: base + span-kind: client + tags: [{dd-name: db.type, required: required}, {dd-name: db.user}] + """ + ) + val output = File(directory, "generated") + + TagRegistryGenerator.generate(yaml, output) + + // service is on both span types, so it takes slot 0; db.type and db.user share db.client, so + // they take distinct slots, and http.route -- never on a span type with either -- reuses one of + // them; env is trace-level (its own TagMap) and reuses slot 0. Every level is slotted. + assertThat(contents(output).getValue("java/datadog/trace/api/KnownTags.java")) + .contains( + "DB_TYPE_ID = 0x0001000100000000L", + "DB_USER_ID = 0x0002000200000000L", + "ENV_ID = 0x0003000000000004L", + "HTTP_ROUTE_ID = 0x0004000100000000L", + "SERVICE_ID = 0x0005000000000000L", + "public static final int SLOT_COUNT = 3;" + ) + } + private fun tagConventions(@Language("yaml") yamlText: String) = TagConventions.parse( ObjectMapper(YAMLFactory()).readValue( directory.conventionsFile(yamlText), diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java index c2bcd4a1cfb..c31ad846ee1 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java @@ -2,6 +2,7 @@ import static java.util.concurrent.TimeUnit.MICROSECONDS; +import datadog.trace.api.KnownTags; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; import datadog.trace.bootstrap.instrumentation.api.Tags; @@ -222,6 +223,26 @@ public void webServerSpanViaBuilder() { span.finish(); } + /** + * {@link #webServerSpanViaBuilder}, setting each builder tag by its {@link KnownTags} id: no name + * lookup when the tag is recorded, and the ledger stores the id directly. + */ + @Benchmark + public void webServerSpanViaBuilderById() { + AgentSpan span = + tracer + .buildSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME) + .withTag(KnownTags.COMPONENT_ID, COMPONENT_VALUE) + .withTag(KnownTags.SPAN_KIND_ID, Tags.SPAN_KIND_SERVER) + .withTag(KnownTags.HTTP_METHOD_ID, HTTP_METHOD_VALUE) + .withTag(KnownTags.HTTP_ROUTE_ID, HTTP_ROUTE_VALUE) + .withTag(KnownTags.HTTP_URL_ID, HTTP_URL_VALUE) + .withTag(KnownTags.HTTP_STATUS_CODE_ID, HTTP_STATUS_VALUE) + .withTag(KnownTags.PEER_PORT_ID, PEER_PORT_VALUE) + .start(); + span.finish(); + } + /** JDBC/DB-client-shaped span: create -> set the typical DB known tags (9) -> finish. */ @Benchmark public void jdbcClientSpan() { diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index 9ed3f34aceb..ccc867858e1 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -43,6 +43,7 @@ import datadog.trace.api.EndpointTracker; import datadog.trace.api.IdGenerationStrategy; import datadog.trace.api.InstrumenterConfig; +import datadog.trace.api.KnownTagCodec; import datadog.trace.api.Pair; import datadog.trace.api.TagMap; import datadog.trace.api.TraceConfig; @@ -2075,6 +2076,24 @@ public final CoreSpanBuilder withTag(final String tag, final Object value) { return this; } + @Override + public final CoreSpanBuilder withTag(final long tagId, final Object value) { + if (!KnownTagCodec.isKnown(tagId)) { + return this; + } + TagMap.Ledger tagLedger = this.tagLedger; + if (tagLedger == null) { + this.tagLedger = tagLedger = TagMap.ledger(); + } + if (value == null) { + // see withTag(String, Object): only a removal of a tag this builder set is recorded + tagLedger.smartRemove(tagId); + } else { + tagLedger.set(tagId, value); + } + return this; + } + @Override public final AgentTracer.SpanBuilder withRequestContextData( RequestContextSlot slot, T data) { diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 9281c377afb..13b12e5d079 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -1322,22 +1322,41 @@ void setAllTags(final TagMap.Ledger ledger) { } synchronized (unsafeTags) { - for (final TagMap.EntryChange entryChange : ledger) { - String tag = entryChange.tag(); - if (entryChange.isRemoval()) { - if (tagEquals(tag, Tags.SPAN_KIND)) { - // mirror removeTag(String): keep the cached ordinal in sync with unsafeTags - spanKindOrdinal = SPAN_KIND_UNSET; - } - unsafeTags.remove(tag); - } else { - TagMap.Entry entry = (TagMap.Entry) entryChange; + // Interception needs an intercepted id in the ledger, or a split-by-tags entry, which is + // config rather than an id bit. Without either, every change goes straight to storage. + if (ledger.hasInterceptedTags() || tagInterceptor.hasSplitServiceTags()) { + ledger.forEach(this, DDSpanContext::interceptOrApplyLedgerChange); + } else { + ledger.forEach(this, DDSpanContext::applyLedgerChange); + } + } + } - if (!tagInterceptor.interceptTag(this, entry)) { - unsafeTags.set(entry); - } - } + /** Call while holding the {@code unsafeTags} lock. */ + private static void interceptOrApplyLedgerChange( + DDSpanContext ctx, TagMap.EntryChangeReader change) { + if (change.isRemoval() || !ctx.tagInterceptor.interceptTag(ctx, change)) { + applyLedgerChange(ctx, change); + } + } + + /** Call while holding the {@code unsafeTags} lock. */ + private static void applyLedgerChange(DDSpanContext ctx, TagMap.EntryChangeReader change) { + long tagId = change.tagId(); + if (change.isRemoval()) { + if (tagId == KnownTags.SPAN_KIND_ID) { + // mirror removeTag(String): keep the cached ordinal in sync with unsafeTags + ctx.spanKindOrdinal = SPAN_KIND_UNSET; } + if (tagId != 0) { + ctx.unsafeTags.getAndRemove(tagId); + } else { + ctx.unsafeTags.remove(change.tag()); + } + } else if (tagId != 0) { + ctx.unsafeTags.internals().setKnown(tagId, change.objectValue()); + } else { + ctx.unsafeTags.set(change); } } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java index b487d4665b7..b651d4e9407 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java @@ -61,6 +61,9 @@ public class TagInterceptor { /** split-by-tags entries naming custom tags; null when there are none. */ private final Set splitServiceCustomNames; + /** Whether any split-by-tags entry is configured; see {@link #hasSplitServiceTags}. */ + private final boolean hasSplitServiceTags; + private final boolean shouldSet404ResourceName; private final boolean shouldSetUrlResourceAsName; private final boolean jeeSplitByDeployment; @@ -84,6 +87,7 @@ public TagInterceptor( this.inferredServiceName = inferredServiceName; this.splitServiceSerials = knownTagSerials(splitServiceTags); this.splitServiceCustomNames = customTagNames(splitServiceTags); + this.hasSplitServiceTags = !splitServiceTags.isEmpty(); this.ruleFlags = ruleFlags; splitByServletContext = isSplitServiceTag(SERVLET_CONTEXT_ID); @@ -129,6 +133,15 @@ private static Set customTagNames(Set names) { return custom; } + /** + * Whether any split-by-tags entry is configured. split-by-tags is tracer config, not an id bit, + * so a batch of tags needs no interception only when this is false and no id in the batch has + * {@link KnownTagCodec#INTERCEPTED}. + */ + public boolean hasSplitServiceTags() { + return hasSplitServiceTags; + } + /** Requires a known id, whose serial is always in range of the table. */ private boolean isSplitServiceTag(long tagId) { return splitServiceSerials[KnownTagCodec.serialNum(tagId)]; diff --git a/dd-trace-core/src/test/java/datadog/trace/core/CoreSpanBuilderWithTagByIdTest.java b/dd-trace-core/src/test/java/datadog/trace/core/CoreSpanBuilderWithTagByIdTest.java new file mode 100644 index 00000000000..7a316930662 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/CoreSpanBuilderWithTagByIdTest.java @@ -0,0 +1,75 @@ +package datadog.trace.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import datadog.trace.api.KnownTagCodec; +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.common.writer.ListWriter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** {@code SpanBuilder.withTag(long, ...)}: a builder tag set by id lands like one set by name. */ +class CoreSpanBuilderWithTagByIdTest extends DDCoreJavaSpecification { + private CoreTracer tracer; + + @BeforeEach + void setup() { + tracer = tracerBuilder().writer(new ListWriter()).build(); + } + + @AfterEach + void tearDown() { + tracer.close(); + } + + @Test + void aTagSetByIdIsStoredUnderItsName() { + AgentSpan span = + tracer + .buildSpan("datadog", "op") + .withTag(KnownTags.HTTP_ROUTE_ID, "/users/{id}") + .withTag(KnownTags.HTTP_STATUS_CODE_ID, 200) + .withTag("my.custom.tag", "x") + .start(); + + assertEquals("/users/{id}", span.getTag(KnownTags.HTTP_ROUTE_NAME)); + assertEquals(200, span.getTag(KnownTags.HTTP_STATUS_CODE_NAME)); + assertEquals("x", span.getTag("my.custom.tag")); + span.finish(); + } + + @Test + void anInterceptedTagSetByIdIsRouted() { + AgentSpan span = + tracer.buildSpan("datadog", "op").withTag(KnownTags.RESOURCE_NAME_ID, "GET /x").start(); + + assertEquals("GET /x", span.getResourceName().toString()); + span.finish(); + } + + @Test + void aNullValueRemovesOnlyATagTheBuilderSet() { + AgentSpan span = + tracer + .buildSpan("datadog", "op") + .withTag(KnownTags.HTTP_ROUTE_ID, "/users") + .withTag(KnownTags.HTTP_ROUTE_ID, null) + .withTag(KnownTags.HTTP_METHOD_ID, null) + .start(); + + assertNull(span.getTag(KnownTags.HTTP_ROUTE_NAME)); + span.finish(); + } + + @Test + void anUnknownIdIsIgnored() { + AgentSpan span = + tracer.buildSpan("datadog", "op").withTag(KnownTagCodec.makeTagId(9999), "x").start(); + + assertNull(span.getTag("x")); + span.finish(); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java index 605f7140d4c..a405caf8e5b 100644 --- a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java +++ b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java @@ -13,11 +13,11 @@ */ public final class KnownTagCodec { /* - * tagId bit layout: [63-48 serialNum (16 bits)] [47-32 reserved, zero] [31-0 flags]. serialNum is + * tagId bit layout: [63-48 serialNum (16 bits)] [47-32 slot] [31-0 flags]. serialNum is * globally unique per known tag and is the whole of the tag's identity — nameOf/ * openTelemetryNameOf switch on it, and the generator emits each id as a literal. Bits [47-32] - * are RESERVED and always zero here: they are the window the dense tag store uses for its - * co-occurrence slot coordinate, which arrives with that store. Of the low 32 flag bits, bit 2 is + * hold the tag's slot ({@link #slotOf}), assigned by graph coloring so tags on one span type never + * share a slot, or {@link #NO_SLOT} for a tag on no modeled span type. Of the low 32 flag bits, bit 2 is * the trace/span LEVEL bit (set ⟹ trace-level), and bit 1 is the INTERCEPTED bit. Unknown * (string-only) custom tags are NOT known ids — {@code keyOf} returns 0 for them. * @@ -65,16 +65,46 @@ public static boolean isIntercepted(long tagId) { return (tagId & INTERCEPTED) != 0L; } + /** Bit position of the slot: bits [47-32] of an id. */ + public static final int SLOT_SHIFT = 32; + + /** The slot of a known tag on no modeled span type. */ + public static final int NO_SLOT = 0xFFFF; + + /** + * The tag's slot, or {@link #NO_SLOT}. The generator colors tags that appear together on a span + * type with distinct slots, so when instrumentation follows its span type, {@code 1L << + * slotOf(id)} gives each of a span's tags its own presence bit. A span can still hold two tags + * that share a slot, so a clear bit means the tag is absent, but a set bit only means it may be + * present. Every tag without a slot shares bit 63, which no real slot uses. + */ + public static int slotOf(long tagId) { + return (int) (tagId >>> SLOT_SHIFT) & 0xFFFF; + } + + /** + * True if the tag has a slot, below {@link #slotCount()}. False for {@code 0L}, the id {@link + * #keyOf} gives a custom tag, which would otherwise decode as slot 0. + */ + public static boolean hasSlot(long tagId) { + return tagId != 0L && slotOf(tagId) != NO_SLOT; + } + + /** Slots in use: every slotted tag's slot is below this. */ + public static int slotCount() { + return KnownTags.SLOT_COUNT; + } + /** Returns the tagId with the {@link #LEVEL_TRACE} flag set. */ public static long traceLevel(long tagId) { return tagId | LEVEL_TRACE; } /** - * Builds a tagId from its {@code serialNum} (globally unique per known tag). The reserved [47-32] - * window and the low 32 bits are zero, so the id is fully determined by the serial — the - * generator emits it as a literal. Inverse of {@link #serialNum}. Intended for the code generator - * and tests. + * Builds an id carrying only {@code serialNum}: slot 0 and no flags, so it equals a tag's real id + * only when that tag has neither. Enough for serial-keyed lookups such as {@link #nameOf}; + * compare against a {@code KnownTags} constant, never against this. Inverse of {@link + * #serialNum}. Intended for tests and serial-keyed lookups. */ public static long makeTagId(int serialNum) { return (long) serialNum << 48; diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index be07fadbeeb..c56f6cba3cc 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -236,6 +236,14 @@ default String openTelemetryTag(int direction) { Entry entry(); } + /** + * A change in a {@link Ledger}: a set, readable as an {@link EntryReader}, or a removal, for + * which only {@link #tag()} and {@link #tagId()} apply. + */ + public interface EntryChangeReader extends EntryReader { + boolean isRemoval(); + } + public static final class Entry extends EntryChange implements Map.Entry, EntryReader { /* @@ -977,17 +985,59 @@ static int _hash(String tag) { * An in-order ledger of changes to be made to a TagMap. * Ledger can also serves as a builder for TagMap-s via build & buildImmutable. */ - public static final class Ledger implements Iterable { - EntryChange[] entryChanges; + /** + * An ordered log of tag changes -- sets and removals -- replayed in order onto a span's tags. + * Changes are kept in parallel arrays rather than as {@link Entry} objects, so recording one + * allocates nothing once the arrays are sized, and a builder that reuses its ledger ({@link + * #reset}) allocates nothing at all. Consumers read changes through a reused {@link + * EntryChangeReader}. + * + *

A known tag is recorded under its id, a custom tag under its name. Two summaries ride along: + * + *

+ * + *

Not thread-safe, like the span builder that owns it. + */ + public static final class Ledger implements Iterable { + /** The value of a recorded removal; never a real tag value. */ + static final Object REMOVED = new Object(); + + /** The occupancy bit every custom tag shares, with the known tags that have no slot. */ + static final long CUSTOM_OCCUPANCY = 1L << 63; + + /** A known tag's id, or a custom tag's {@link Entry#customHash} (upper 32 bits zero). */ + long[] tagHashes; + + /** + * A custom tag's name; null for a known tag, whose name is {@code nameOf(id)}. The array itself + * is allocated on the first custom tag, so a ledger of known tags never allocates it. + */ + String[] tags; + + /** The value set, or {@link #REMOVED}. */ + Object[] values; + int nextPos = 0; boolean containsRemovals = false; + long occupancy = 0L; + long idFlags = 0L; + + /** The reader {@link #forEach} reuses; created on first use. */ + LedgerReader reader; Ledger() { this(8); } Ledger(int size) { - this.entryChanges = new EntryChange[size]; + this.tagHashes = new long[size]; + this.values = new Object[size]; } public boolean isDefinitelyEmpty() { @@ -1008,81 +1058,174 @@ public boolean containsRemovals() { return this.containsRemovals; } + /** + * Whether a recorded known tag carries {@link KnownTagCodec#INTERCEPTED}. Custom tags and + * split-by-tags entries are routed by tracer config, which the caller checks separately. + */ + public boolean hasInterceptedTags() { + return (this.idFlags & KnownTagCodec.INTERCEPTED) != 0L; + } + public Ledger set(String tag, Object value) { - return this.recordEntry(anyEntryFor(tag, value)); + long tagId = KnownTagCodec.keyOf(tag); + return tagId != 0 ? this.recordKnown(tagId, value) : this.recordCustom(tag, value); } public Ledger set(String tag, CharSequence value) { - return this.recordEntry(objectEntryFor(tag, value)); + return this.set(tag, (Object) value); } public Ledger set(String tag, boolean value) { - return this.recordEntry(booleanEntryFor(tag, value)); + return this.set(tag, (Object) Boolean.valueOf(value)); } public Ledger set(String tag, int value) { - return this.recordEntry(intEntryFor(tag, value)); + return this.set(tag, (Object) Integer.valueOf(value)); } public Ledger set(String tag, long value) { - return this.recordEntry(longEntryFor(tag, value)); + return this.set(tag, (Object) Long.valueOf(value)); } public Ledger set(String tag, float value) { - return this.recordEntry(floatEntryFor(tag, value)); + return this.set(tag, (Object) Float.valueOf(value)); } public Ledger set(String tag, double value) { - return this.recordEntry(doubleEntryFor(tag, value)); + return this.set(tag, (Object) Double.valueOf(value)); } - public Ledger set(Entry entry) { - return this.recordEntry(entry); + /** Records a known tag by id; rejects an id that names no known tag. */ + public Ledger set(long tagId, Object value) { + return this.recordKnown(requireKnownLedgerId(tagId), value); } - public Ledger remove(String tag) { - return this.recordRemoval(EntryChange.newRemoval(tag)); + public Ledger set(long tagId, CharSequence value) { + return this.set(tagId, (Object) value); } - private Ledger recordEntry(Entry entry) { - this.recordChange(entry); - return this; + public Ledger set(long tagId, boolean value) { + return this.set(tagId, (Object) Boolean.valueOf(value)); } - private Ledger recordRemoval(EntryRemoval entry) { - this.recordChange(entry); - this.containsRemovals = true; + public Ledger set(long tagId, int value) { + return this.set(tagId, (Object) Integer.valueOf(value)); + } - return this; + public Ledger set(long tagId, long value) { + return this.set(tagId, (Object) Long.valueOf(value)); } - private void recordChange(EntryChange entryChange) { - if (this.nextPos >= this.entryChanges.length) { - this.entryChanges = Arrays.copyOf(this.entryChanges, this.entryChanges.length << 1); - } + public Ledger set(long tagId, float value) { + return this.set(tagId, (Object) Float.valueOf(value)); + } - this.entryChanges[this.nextPos++] = entryChange; + public Ledger set(long tagId, double value) { + return this.set(tagId, (Object) Double.valueOf(value)); } + public Ledger set(Entry entry) { + long tagId = entry.tagId(); + return tagId != 0 + ? this.recordKnown(tagId, entry.objectValue()) + : this.recordCustom(entry.tag(), entry.objectValue()); + } + + public Ledger remove(String tag) { + long tagId = KnownTagCodec.keyOf(tag); + return tagId != 0 ? this.recordKnown(tagId, REMOVED) : this.recordCustom(tag, REMOVED); + } + + public Ledger remove(long tagId) { + return this.recordKnown(requireKnownLedgerId(tagId), REMOVED); + } + + /** + * Records a removal only if the ledger already holds a change for {@code tag}, so a removal of + * a tag set elsewhere (another map applied to the same span) isn't clobbered. + */ public Ledger smartRemove(String tag) { - if (this.contains(tag)) { - this.remove(tag); + long tagId = KnownTagCodec.keyOf(tag); + if (tagId != 0) { + return this.smartRemove(tagId); + } + if (this.containsCustom(tag)) { + this.recordCustom(tag, REMOVED); } return this; } - private boolean contains(String tag) { - // Entries and removals are both recorded under their canonical Datadog name (see newAnyEntry - // et al. and EntryRemoval's own constructor); canonicalize the query the same way or it - // would miss. - String canonicalTag = KnownTagCodec.canonicalTagName(tag); + /** {@link #smartRemove(String)} by id; a tag the ledger never recorded is a one-bit miss. */ + public Ledger smartRemove(long tagId) { + if ((this.occupancy & (1L << KnownTagCodec.slotOf(tagId))) != 0L + && this.containsKnown(tagId)) { + this.recordKnown(tagId, REMOVED); + } + return this; + } - EntryChange[] thisChanges = this.entryChanges; + private Ledger recordKnown(long tagId, Object value) { + this.occupancy |= 1L << KnownTagCodec.slotOf(tagId); + this.idFlags |= tagId & 0xFFFFFFFFL; + return this.record(tagId, null, value); + } + + private Ledger recordCustom(String tag, Object value) { + this.occupancy |= CUSTOM_OCCUPANCY; + return this.record(Entry.customHash(tag), tag, value); + } + private Ledger record(long tagHash, String tag, Object value) { + int pos = this.nextPos; + if (pos >= this.tagHashes.length) { + int newCap = this.tagHashes.length << 1; + this.tagHashes = Arrays.copyOf(this.tagHashes, newCap); + this.values = Arrays.copyOf(this.values, newCap); + if (this.tags != null) { + this.tags = Arrays.copyOf(this.tags, newCap); + } + } + this.tagHashes[pos] = tagHash; + if (tag != null) { + String[] names = this.tags; + if (names == null) { + this.tags = names = new String[this.tagHashes.length]; + } + names[pos] = tag; + } else if (this.tags != null) { + this.tags[pos] = null; + } + this.values[pos] = value; + this.nextPos = pos + 1; + if (value == REMOVED) { + this.containsRemovals = true; + } + return this; + } + + private boolean containsKnown(long tagId) { + long[] hashes = this.tagHashes; // min is to clamp, so bounds check elimination optimization works - int lenClamp = Math.min(this.nextPos, thisChanges.length); + int lenClamp = Math.min(this.nextPos, hashes.length); for (int i = 0; i < lenClamp; ++i) { - if (thisChanges[i].matches(canonicalTag)) return true; + if (hashes[i] == tagId) return true; + } + return false; + } + + private boolean containsCustom(String tag) { + if ((this.occupancy & CUSTOM_OCCUPANCY) == 0L) { + return false; + } + String[] names = this.tags; + if (names == null) { + return false; // bit 63 came from a known tag with no slot + } + long hash = Entry.customHash(tag); + long[] hashes = this.tagHashes; + int lenClamp = Math.min(this.nextPos, Math.min(hashes.length, names.length)); + for (int i = 0; i < lenClamp; ++i) { + if (hashes[i] == hash && tag.equals(names[i])) return true; } return false; } @@ -1091,27 +1234,56 @@ private boolean contains(String tag) { * Just for testing */ Entry findLastEntry(String tag) { - String canonicalTag = KnownTagCodec.canonicalTagName(tag); - EntryChange[] thisChanges = this.entryChanges; - - // min is to clamp, so ArrayBoundsCheckElimination optimization works - int clampLen = Math.min(this.nextPos, thisChanges.length) - 1; - for (int i = clampLen; i >= 0; --i) { - EntryChange thisChange = thisChanges[i]; - if (!thisChange.isRemoval() && thisChange.matches(canonicalTag)) return (Entry) thisChange; + long tagId = KnownTagCodec.keyOf(tag); + long hash = tagId != 0 ? tagId : Entry.customHash(tag); + for (int i = Math.min(this.nextPos, this.tagHashes.length) - 1; i >= 0; --i) { + if (this.tagHashes[i] == hash + && (tagId != 0 || tag.equals(this.tags[i])) + && this.values[i] != REMOVED) { + return tagId != 0 + ? Entry.newAnyEntry(tagId, this.values[i]) + : Entry.newAnyEntry(tag, this.values[i]); + } } return null; } public void reset() { - Arrays.fill(this.entryChanges, null); + int size = this.nextPos; + if (this.tags != null) { + Arrays.fill(this.tags, 0, size, null); + } + Arrays.fill(this.values, 0, size, null); this.nextPos = 0; this.containsRemovals = false; + this.occupancy = 0L; + this.idFlags = 0L; } + /** + * Iterates the changes in order. Every {@code next()} returns the same reader, repositioned: + * read it before advancing, and call {@link EntryReader#entry()} to keep a change. + */ @Override - public Iterator iterator() { - return new IteratorImpl(this.entryChanges, this.nextPos); + public Iterator iterator() { + return new LedgerReader(this); + } + + /** + * Calls {@code consumer} with each change in order, through one reader that this ledger reuses + * across calls: no allocation once the ledger has been read once. Not reentrant: don't iterate + * the same ledger from inside {@code consumer}. The reader is only valid during the call. + */ + public void forEach(T thisObj, BiConsumer consumer) { + LedgerReader reader = this.reader; + if (reader == null) { + this.reader = reader = new LedgerReader(this); + } + int size = this.nextPos; + for (int i = 0; i < size; ++i) { + reader.pos = i; + consumer.accept(thisObj, reader); + } } public TagMap build() { @@ -1121,15 +1293,21 @@ public TagMap build() { } void fill(TagMap map) { - EntryChange[] entryChanges = this.entryChanges; - int size = this.nextPos; - for (int i = 0; i < size && i < entryChanges.length; ++i) { - EntryChange change = entryChanges[i]; - - if (change.isRemoval()) { - map.remove(change.tag()); + int size = Math.min(this.nextPos, this.tagHashes.length); + for (int i = 0; i < size; ++i) { + long tagHash = this.tagHashes[i]; + Object value = this.values[i]; + boolean known = (tagHash >>> 32) != 0; + if (value == REMOVED) { + if (known) { + map.getAndRemove(tagHash); + } else { + map.remove(this.tags[i]); + } + } else if (known) { + map.set(tagHash, value); } else { - map.set((Entry) change); + map.putEntry(Entry.newAnyEntry(this.tags[i], value)); } } } @@ -1142,30 +1320,137 @@ public TagMap buildImmutable() { } } - static final class IteratorImpl implements Iterator { - private final EntryChange[] entryChanges; - private final int size; + static long requireKnownLedgerId(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw new IllegalArgumentException("not a known tag id: " + Long.toHexString(tagId)); + } + return tagId; + } + } - private int pos; + /** + * A reusable cursor over a {@link Ledger}'s changes: both the iterator and the {@link + * EntryChangeReader} it returns. For a removal only {@link #tag()} and {@link #tagId()} apply. + */ + static final class LedgerReader implements EntryChangeReader, Iterator { + final Ledger ledger; + int pos = -1; - IteratorImpl(EntryChange[] entryChanges, int size) { - this.entryChanges = entryChanges; - this.size = size; + LedgerReader(Ledger ledger) { + this.ledger = ledger; + } - this.pos = -1; - } + @Override + public boolean hasNext() { + return this.pos + 1 < this.ledger.nextPos; + } - @Override - public boolean hasNext() { - return (this.pos + 1 < this.size); - } + @Override + public EntryChangeReader next() { + if (!this.hasNext()) throw new NoSuchElementException("no next"); + ++this.pos; + return this; + } + + @Override + public boolean isRemoval() { + return this.ledger.values[this.pos] == Ledger.REMOVED; + } + + @Override + public String tag() { + String[] names = this.ledger.tags; + String tag = names != null ? names[this.pos] : null; + return tag != null ? tag : KnownTagCodec.nameOf(this.ledger.tagHashes[this.pos]); + } + + @Override + public long tagId() { + long tagHash = this.ledger.tagHashes[this.pos]; + // a known tag's hash is its id, whose upper 32 bits are never zero; a custom tag's are + return (tagHash >>> 32) != 0 ? tagHash : 0L; + } + + private Object value() { + return this.ledger.values[this.pos]; + } + + @Override + public byte type() { + return TagValueConversions.typeOf(this.value()); + } + + @Override + public boolean is(byte type) { + return TagValueConversions.isA(this.value(), type); + } + + @Override + public boolean isNumber() { + return TagValueConversions.isNumber(this.value()); + } - @Override - public EntryChange next() { - if (!this.hasNext()) throw new NoSuchElementException("no next"); + @Override + public boolean isNumericPrimitive() { + return TagValueConversions.isNumericPrimitive(this.value()); + } + + @Override + public boolean isObject() { + return TagValueConversions.isObject(this.value()); + } + + @Override + public boolean booleanValue() { + return TagValueConversions.toBoolean(this.value()); + } - return this.entryChanges[++this.pos]; + @Override + public int intValue() { + return TagValueConversions.toInt(this.value()); + } + + @Override + public long longValue() { + return TagValueConversions.toLong(this.value()); + } + + @Override + public float floatValue() { + return TagValueConversions.toFloat(this.value()); + } + + @Override + public double doubleValue() { + return TagValueConversions.toDouble(this.value()); + } + + @Override + public String stringValue() { + return TagValueConversions.toString(this.value()); + } + + @Override + public Object objectValue() { + return this.value(); + } + + /** The change as an {@link Entry}, or null for a removal. */ + @Override + public Entry entry() { + Object value = this.value(); + if (value == Ledger.REMOVED) { + return null; } + long tagId = this.tagId(); + return tagId != 0 + ? Entry.newAnyEntry(tagId, value) + : Entry.newAnyEntry(this.ledger.tags[this.pos], value); + } + + @Override + public Map.Entry mapEntry() { + return this.entry(); } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java index 540a4a2481a..fae09e379cc 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java @@ -436,6 +436,12 @@ public interface SpanBuilder { SpanBuilder withTag(String tag, Object value); + /** + * Sets a known tag by its {@code KnownTags} id; a null value removes it. An id that names no + * known tag is ignored. + */ + SpanBuilder withTag(long tagId, Object value); + SpanBuilder withStartTimestamp(long microseconds); SpanBuilder withServiceName(String serviceName); diff --git a/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java b/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java new file mode 100644 index 00000000000..ded02b397e5 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java @@ -0,0 +1,84 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** {@link KnownTagCodec}'s slot: bits [47-32] of an id, assigned by graph coloring. */ +class KnownTagCodecSlotTest { + @Test + void everySlotIsBelowTheSlotCount() throws IllegalAccessException { + for (Field field : KnownTags.class.getFields()) { + if (field.getType() != long.class) { + continue; + } + long tagId = field.getLong(null); + if (KnownTagCodec.hasSlot(tagId)) { + assertTrue(KnownTagCodec.slotOf(tagId) < KnownTagCodec.slotCount(), field.getName()); + } else { + assertEquals(KnownTagCodec.NO_SLOT, KnownTagCodec.slotOf(tagId), field.getName()); + } + } + } + + @Test + void tagsOnAnHttpServerSpanHaveDistinctSlots() { + long[] httpServerTags = { + KnownTags.SERVICE_ID, + KnownTags.COMPONENT_ID, + KnownTags.SPAN_KIND_ID, + KnownTags.HTTP_METHOD_ID, + KnownTags.HTTP_URL_ID, + KnownTags.HTTP_STATUS_CODE_ID, + KnownTags.HTTP_ROUTE_ID, + KnownTags.HTTP_HOSTNAME_ID, + }; + Set slots = new HashSet<>(); + for (long tagId : httpServerTags) { + assertTrue(KnownTagCodec.hasSlot(tagId), Long.toHexString(tagId)); + assertTrue(slots.add(KnownTagCodec.slotOf(tagId)), Long.toHexString(tagId)); + } + } + + @Test + void anOptionalTagOnASpanTypeHasASlot() { + assertTrue(KnownTagCodec.hasSlot(KnownTags.PEER_PORT_ID)); + assertTrue(KnownTagCodec.hasSlot(KnownTags.DB_POOL_NAME_ID)); + } + + @Test + void aTagOnNoSpanTypeHasNoSlot() { + // the tracer overlay's routing-only keys appear on no modeled span type + assertFalse(KnownTagCodec.hasSlot(KnownTags.RESOURCE_NAME_ID)); + } + + @Test + void theSlotDoesNotChangeTheSerialOrFlags() { + long tagId = KnownTags.HTTP_METHOD_ID; + + assertTrue(KnownTagCodec.hasSlot(tagId)); + assertEquals(KnownTags.HTTP_METHOD_NAME, KnownTagCodec.nameOf(tagId)); + assertTrue(KnownTagCodec.isIntercepted(tagId)); + assertFalse(KnownTagCodec.isTraceLevel(tagId)); + } + + @Test + void aCustomTagHasNoSlot() { + // keyOf gives a custom tag the id 0L, whose slot bits decode as slot 0 + assertEquals(0L, KnownTagCodec.keyOf("my.custom.tag")); + assertFalse(KnownTagCodec.hasSlot(0L)); + } + + @Test + void anUnslottedTagSharesTheReservedTopBit() { + // An occupancy bit of 1L << slotOf(id) needs no branch for NO_SLOT: a long shift uses only the + // low 6 bits of its count, so 0xFFFF lands on bit 63, which no real slot uses + assertEquals(1L << 63, 1L << KnownTagCodec.slotOf(KnownTags.RESOURCE_NAME_ID)); + assertTrue(KnownTagCodec.slotCount() <= 63, "real slots must stay below the shared bit 63"); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java index 685dc1d9cdf..3d31bf57f13 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java @@ -1405,10 +1405,9 @@ protected void _applyToTestMap(TagMap testMap) { @Override protected void _applyToExpectedMap(Map expectedMap) { - for (TagMap.EntryChange change : this.ledger) { - // ledgerOf - doesn't produce / removes, so this is safe - TagMap.Entry entry = (TagMap.Entry) change; - expectedMap.put(entry.tag(), entry.objectValue()); + for (TagMap.EntryChangeReader change : this.ledger) { + // ledgerOf doesn't produce removals + expectedMap.put(change.tag(), change.objectValue()); } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapLedgerByIdTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapLedgerByIdTest.java new file mode 100644 index 00000000000..405165785fb --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapLedgerByIdTest.java @@ -0,0 +1,195 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * {@link TagMap.Ledger} keyed by id: known tags recorded under their ids, custom tags under their + * names, read back through {@link TagMap.EntryChangeReader}, with the slot-occupancy and + * intercepted-id summaries. + */ +class TagMapLedgerByIdTest { + @Test + void aKnownTagByIdAndByNameRecordTheSameChange() { + TagMap byId = TagMap.ledger().set(KnownTags.HTTP_ROUTE_ID, "/users").build(); + TagMap byName = TagMap.ledger().set(KnownTags.HTTP_ROUTE_NAME, "/users").build(); + + assertEquals(new HashMap<>(byName), new HashMap<>(byId)); + assertEquals(KnownTags.HTTP_ROUTE_ID, byId.getEntry(KnownTags.HTTP_ROUTE_NAME).tagId()); + } + + @Test + void anOpenTelemetryNameRecordsItsTagsId() { + TagMap.Ledger ledger = TagMap.ledger().set(KnownTags.HTTP_METHOD_OTEL_NAME, "GET"); + + for (TagMap.EntryChangeReader change : ledger) { + assertEquals(KnownTags.HTTP_METHOD_ID, change.tagId()); + assertEquals(KnownTags.HTTP_METHOD_NAME, change.tag()); + } + } + + @Test + void anUnknownIdIsRejected() { + TagMap.Ledger ledger = TagMap.ledger(); + + assertThrows( + IllegalArgumentException.class, () -> ledger.set(KnownTagCodec.makeTagId(9999), "x")); + assertThrows( + IllegalArgumentException.class, () -> ledger.remove(KnownTagCodec.makeTagId(9999))); + } + + @Test + void theReaderReplaysSetsAndRemovalsInOrder() { + TagMap.Ledger ledger = + TagMap.ledger() + .set(KnownTags.HTTP_ROUTE_ID, "/users") + .set("my.custom.tag", 1) + .remove(KnownTags.HTTP_ROUTE_ID) + .remove("my.custom.tag"); + + List seen = new ArrayList<>(); + ledger.forEach( + seen, + (out, change) -> + out.add( + change.tag() + + (change.isRemoval() ? " removed" : "=" + change.objectValue()) + + " #" + + Long.toHexString(change.tagId()))); + + assertEquals( + Arrays.asList( + KnownTags.HTTP_ROUTE_NAME + "=/users #" + Long.toHexString(KnownTags.HTTP_ROUTE_ID), + "my.custom.tag=1 #0", + KnownTags.HTTP_ROUTE_NAME + " removed #" + Long.toHexString(KnownTags.HTTP_ROUTE_ID), + "my.custom.tag removed #0"), + seen); + assertTrue(ledger.containsRemovals()); + assertTrue(ledger.build().isEmpty()); + } + + @Test + void forEachReusesOneReader() { + TagMap.Ledger ledger = TagMap.ledger().set(KnownTags.HTTP_ROUTE_ID, "/a").set("b", "c"); + List readers = new ArrayList<>(); + + ledger.forEach(readers, List::add); + ledger.forEach(readers, List::add); + + assertEquals(4, readers.size()); + for (TagMap.EntryChangeReader reader : readers) { + assertSame(readers.get(0), reader); + } + } + + @Test + void aRemovalReadsAsNoEntry() { + TagMap.Ledger ledger = TagMap.ledger().remove(KnownTags.HTTP_ROUTE_ID); + + for (TagMap.EntryChangeReader change : ledger) { + assertTrue(change.isRemoval()); + assertNull(change.entry()); + } + } + + @Test + void smartRemoveOfATagNeverRecordedRecordsNothing() { + TagMap.Ledger ledger = TagMap.ledger().set(KnownTags.HTTP_ROUTE_ID, "/users"); + + ledger.smartRemove(KnownTags.HTTP_STATUS_CODE_ID); + ledger.smartRemove(KnownTags.HTTP_STATUS_CODE_NAME); + ledger.smartRemove("never.set"); + + assertEquals(1, ledger.estimateSize()); + assertFalse(ledger.containsRemovals()); + } + + @Test + void smartRemoveOfARecordedTagRecordsTheRemoval() { + TagMap.Ledger ledger = + TagMap.ledger() + .set(KnownTags.HTTP_ROUTE_ID, "/users") + .set(KnownTags.HTTP_METHOD_ID, "GET") + .set("my.custom.tag", "x"); + + ledger.smartRemove(KnownTags.HTTP_ROUTE_ID); + ledger.smartRemove(KnownTags.HTTP_METHOD_OTEL_NAME); // by its OpenTelemetry name + ledger.smartRemove("my.custom.tag"); + + assertEquals(6, ledger.estimateSize()); + assertTrue(ledger.build().isEmpty()); + } + + @Test + void smartRemoveOfACustomTagMatchesByName() { + TagMap.Ledger ledger = TagMap.ledger().set("my.custom.tag", "x"); + + ledger.smartRemove("my.other.tag"); + + assertEquals(1, ledger.estimateSize()); + } + + @Test + void hasInterceptedTagsTracksTheRecordedIds() { + TagMap.Ledger ledger = TagMap.ledger().set(KnownTags.HTTP_ROUTE_ID, "/users").set("x", "y"); + assertFalse(ledger.hasInterceptedTags()); + + ledger.set(KnownTags.SPAN_KIND_ID, "server"); + assertTrue(ledger.hasInterceptedTags()); + } + + @Test + void resetClearsTheChangesAndTheSummaries() { + TagMap.Ledger ledger = + TagMap.ledger() + .set(KnownTags.SPAN_KIND_ID, "server") + .set("my.custom.tag", "x") + .remove(KnownTags.HTTP_ROUTE_ID); + + ledger.reset(); + + assertTrue(ledger.isDefinitelyEmpty()); + assertFalse(ledger.containsRemovals()); + assertFalse(ledger.hasInterceptedTags()); + ledger.smartRemove(KnownTags.SPAN_KIND_ID).smartRemove("my.custom.tag"); + assertTrue(ledger.isDefinitelyEmpty()); + } + + @Test + void aLedgerOfKnownTagsNeverAllocatesItsNamesArray() { + TagMap.Ledger ledger = + TagMap.ledger(2) + .set(KnownTags.HTTP_ROUTE_ID, "/a") + .set(KnownTags.HTTP_METHOD_NAME, "GET") + .set(KnownTags.HTTP_URL_ID, "http://x") // grows past 2 + .smartRemove("never.set"); + + assertNull(ledger.tags); + + ledger.set("my.custom.tag", "x"); + assertEquals("x", ledger.build().getString("my.custom.tag")); + assertEquals("/a", ledger.build().getString(KnownTags.HTTP_ROUTE_NAME)); + } + + @Test + void theArraysGrowPastTheirInitialSize() { + TagMap.Ledger ledger = TagMap.ledger(2); + for (int i = 0; i < 20; ++i) { + ledger.set("tag" + i, i); + } + + TagMap map = ledger.build(); + assertEquals(20, map.size()); + assertEquals(19, map.getInt("tag19")); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapLedgerTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapLedgerTest.java index ddd7dccf2b4..6311cd39816 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapLedgerTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapLedgerTest.java @@ -23,11 +23,10 @@ public void inOrder() { assertEquals(SIZE, ledger.estimateSize()); int i = 0; - for (TagMap.EntryChange entryChange : ledger) { - TagMap.Entry entry = (TagMap.Entry) entryChange; - - assertEquals(key(i), entry.tag()); - assertEquals(value(i), entry.stringValue()); + for (TagMap.EntryChangeReader change : ledger) { + assertFalse(change.isRemoval()); + assertEquals(key(i), change.tag()); + assertEquals(value(i), change.stringValue()); ++i; } @@ -44,13 +43,14 @@ public void testTypes() { ledger.set("object", (Object) "string"); ledger.set("string", "string"); - assertEntryRawType(TagMap.Entry.BOOLEAN, ledger, "bool"); - assertEntryRawType(TagMap.Entry.INT, ledger, "int"); - assertEntryRawType(TagMap.Entry.LONG, ledger, "long"); - assertEntryRawType(TagMap.Entry.FLOAT, ledger, "float"); - assertEntryRawType(TagMap.Entry.DOUBLE, ledger, "double"); - assertEntryRawType(TagMap.Entry.ANY, ledger, "object"); - assertEntryRawType(TagMap.Entry.OBJECT, ledger, "string"); + // The ledger keeps values, not typed entries; what a reader sees is the resolved type. + assertEntryType(TagMap.Entry.BOOLEAN, ledger, "bool"); + assertEntryType(TagMap.Entry.INT, ledger, "int"); + assertEntryType(TagMap.Entry.LONG, ledger, "long"); + assertEntryType(TagMap.Entry.FLOAT, ledger, "float"); + assertEntryType(TagMap.Entry.DOUBLE, ledger, "double"); + assertEntryType(TagMap.Entry.OBJECT, ledger, "object"); + assertEntryType(TagMap.Entry.OBJECT, ledger, "string"); } @Test @@ -220,9 +220,13 @@ static final String value(int i) { return "value-" + i; } - static final void assertEntryRawType(byte expectedType, TagMap.Ledger ledger, String tag) { - TagMap.Entry entry = ledger.findLastEntry(tag); - assertEquals(expectedType, entry.rawType); + static final void assertEntryType(byte expectedType, TagMap.Ledger ledger, String tag) { + assertEquals(expectedType, ledger.findLastEntry(tag).type()); + for (TagMap.EntryChangeReader change : ledger) { + if (tag.equals(change.tag())) { + assertEquals(expectedType, change.type()); + } + } } static final void assertFrozen(TagMap map) {