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 a4c0c1e997e..8234a1be385 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 @@ -209,6 +209,21 @@ object KnownTagsEmitter { """ }; + /** + * Each tag's id, indexed by serial; slot 0 (no tag) is 0. Lets a store that keeps only the + * 16-bit serial recover the full id, flag and slot bits included. + */ + static final long[] IDS_BY_SERIAL = { + 0L, + """.trimIndent() + ) + for (t in reg.tags.sortedBy { it.serial }) { + appendLine(" ${idC(t.ddName)},") + } + appendLine( + """ + }; + /** * The registry's name↔id tables, as a {@link KnownTagCodec.Resolver}. {@code KnownTagCodec} * reads this field from its own holder, so the two classes complete each other: the codec owns @@ -221,6 +236,11 @@ object KnownTagsEmitter { int serial = KnownTagCodec.serialNum(tagId); return serial < NAMES_BY_SERIAL.length ? NAMES_BY_SERIAL[serial] : null; } + + @Override + public long idOf(int serial) { + return IDS_BY_SERIAL[serial]; + } // openTelemetryNameOf: id -> OTel-namespace name on spans of a direction, null when // the tag has none there. The caller (a serializer) owns any fall-back-to-Datadog-name diff --git a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorPluginTest.kt b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorPluginTest.kt index 5874dcfb70d..82d1b0de5ec 100644 --- a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorPluginTest.kt +++ b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorPluginTest.kt @@ -247,6 +247,7 @@ class TagRegistryGeneratorPluginTest { public final class KnownTagCodec { public interface Resolver { String nameOf(long id); + long idOf(int serial); String openTelemetryNameOf(long id, int direction); long keyOf(String name); long directionalKeyOf(String name, int direction); diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/BaseDecorator.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/BaseDecorator.java index 55a8784aee3..cccac3c697e 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/BaseDecorator.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/BaseDecorator.java @@ -1,7 +1,5 @@ package datadog.trace.bootstrap.instrumentation.decorator; -import static datadog.trace.bootstrap.instrumentation.java.net.HostNameResolver.hostName; - import datadog.appsec.api.blocking.BlockingException; import datadog.context.Context; import datadog.context.ContextScope; @@ -15,8 +13,6 @@ import datadog.trace.bootstrap.instrumentation.api.ErrorPriorities; import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; import java.lang.reflect.Method; -import java.net.Inet4Address; -import java.net.Inet6Address; import java.net.InetAddress; import java.net.InetSocketAddress; import java.util.concurrent.ExecutionException; @@ -194,11 +190,14 @@ protected void doOnError(final AgentSpan span, final Throwable throwable, byte e throwable instanceof ExecutionException ? throwable.getCause() : throwable, errorPriority); } + // The peer extractors write straight to the span rather than through setTagsFrom: resolving the + // hostname can mean a reverse DNS lookup, which mustn't run under the span's tag lock. To skip + // the lookup per span, freeze the extraction once per address instead (FrozenTags). + public final void onPeerConnection( final AgentSpan span, @Nullable final InetSocketAddress remoteConnection) { if (remoteConnection != null) { - onPeerConnection(span, remoteConnection.getAddress(), !remoteConnection.isUnresolved()); - setPeerPort(span, remoteConnection.getPort()); + PeerSocketAddress.INSTANCE.extract(remoteConnection, span); } } @@ -210,15 +209,7 @@ public final void onPeerConnection( public final void onPeerConnection( AgentSpan span, @Nullable InetAddress remoteAddress, boolean resolved) { if (remoteAddress != null) { - String ip = remoteAddress.getHostAddress(); - if (resolved && Config.get().isPeerHostNameEnabled()) { - span.setTag(KnownTags.PEER_HOSTNAME_ID, hostName(remoteAddress, ip)); - } - if (remoteAddress instanceof Inet4Address) { - span.setTag(KnownTags.PEER_IPV4_ID, ip); - } else if (remoteAddress instanceof Inet6Address) { - span.setTag(KnownTags.PEER_IPV6_ID, ip); - } + (resolved ? PeerAddress.RESOLVED : PeerAddress.UNRESOLVED).extract(remoteAddress, span); } } diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/PeerAddress.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/PeerAddress.java new file mode 100644 index 00000000000..35691fade5d --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/PeerAddress.java @@ -0,0 +1,47 @@ +package datadog.trace.bootstrap.instrumentation.decorator; + +import static datadog.trace.bootstrap.instrumentation.java.net.HostNameResolver.hostName; + +import datadog.trace.api.Config; +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.SpanData; +import datadog.trace.bootstrap.instrumentation.api.TagExtractor; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; + +/** + * Extracts the peer tags of a remote {@link InetAddress}: its IP, as {@code peer.ipv4} or {@code + * peer.ipv6}, and, when the address was resolved and peer hostnames are enabled, {@code + * peer.hostname}. + * + *

Resolving the hostname may do a reverse DNS lookup, so don't apply this with {@code + * span.setTagsFrom(address, extractor)}, which holds the span's tag lock while it runs: call {@code + * extract} on the span directly, or freeze it once per address ({@code FrozenTags.of}). + */ +public final class PeerAddress implements TagExtractor { + /** For an address that was resolved from a hostname. */ + public static final PeerAddress RESOLVED = new PeerAddress(true); + + /** For an address that wasn't resolved: no {@code peer.hostname}. */ + public static final PeerAddress UNRESOLVED = new PeerAddress(false); + + private final boolean resolved; + + private PeerAddress(boolean resolved) { + this.resolved = resolved; + } + + @Override + public void extract(InetAddress address, SpanData span) { + String ip = address.getHostAddress(); + if (resolved && Config.get().isPeerHostNameEnabled()) { + span.setTag(KnownTags.PEER_HOSTNAME_ID, hostName(address, ip)); + } + if (address instanceof Inet4Address) { + span.setTag(KnownTags.PEER_IPV4_ID, ip); + } else if (address instanceof Inet6Address) { + span.setTag(KnownTags.PEER_IPV6_ID, ip); + } + } +} diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/PeerSocketAddress.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/PeerSocketAddress.java new file mode 100644 index 00000000000..3a77831775d --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/PeerSocketAddress.java @@ -0,0 +1,30 @@ +package datadog.trace.bootstrap.instrumentation.decorator; + +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.SpanData; +import datadog.trace.bootstrap.instrumentation.api.TagExtractor; +import java.net.InetAddress; +import java.net.InetSocketAddress; + +/** + * Extracts the peer tags of a remote {@link InetSocketAddress}: those of its address (see {@link + * PeerAddress}), when it has one, and {@code peer.port}, when set. + */ +public final class PeerSocketAddress implements TagExtractor { + public static final PeerSocketAddress INSTANCE = new PeerSocketAddress(); + + private PeerSocketAddress() {} + + @Override + public void extract(InetSocketAddress socketAddress, SpanData span) { + // an unresolved socket address is exactly one with no address + InetAddress address = socketAddress.getAddress(); + if (address != null) { + PeerAddress.RESOLVED.extract(address, span); + } + int port = socketAddress.getPort(); + if (port > BaseDecorator.UNSET_PORT) { + span.setTag(KnownTags.PEER_PORT_ID, port); + } + } +} diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/UrlConnectionDecorator.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/UrlConnectionDecorator.java index e606481c3e4..e132923ac6b 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/UrlConnectionDecorator.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/UrlConnectionDecorator.java @@ -7,6 +7,7 @@ import datadog.trace.api.naming.SpanNaming; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes; +import datadog.trace.bootstrap.instrumentation.api.SpanData; import datadog.trace.bootstrap.instrumentation.api.TagExtractor; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.bootstrap.instrumentation.api.http.OtelClientUrl; @@ -73,7 +74,7 @@ public CharSequence operationName(@Nonnull final String protocol) { private static final class FullUrl implements TagExtractor { @Override - public void extract(URI uri, AgentSpan span) { + public void extract(URI uri, SpanData span) { // URLConnection historically records the original URL, including its query and fragment. span.setTag(KnownTags.HTTP_URL_ID, uri.toString()); } diff --git a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/PeerAddressTest.java b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/PeerAddressTest.java new file mode 100644 index 00000000000..a0083183716 --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/PeerAddressTest.java @@ -0,0 +1,96 @@ +package datadog.trace.bootstrap.instrumentation.decorator; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.SpanData; +import datadog.trace.test.junit.utils.config.WithConfig; +import datadog.trace.test.junit.utils.config.WithConfigExtension; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +// registered for the class so the one method-level @WithConfig is undone after that method +@ExtendWith(WithConfigExtension.class) +class PeerAddressTest { + private final SpanData span = mock(SpanData.class); + + // getByAddress pairs a hostname with an IP without a DNS lookup + private static InetAddress ipv4() throws Exception { + return InetAddress.getByAddress("db.example", new byte[] {10, 0, 0, 1}); + } + + private static InetAddress ipv6() throws Exception { + byte[] ip = new byte[16]; + ip[15] = 1; + return InetAddress.getByAddress("db6.example", ip); + } + + @Test + void resolvedIpv4SetsHostnameAndIpv4() throws Exception { + PeerAddress.RESOLVED.extract(ipv4(), span); + + verify(span).setTag(KnownTags.PEER_HOSTNAME_ID, (CharSequence) "db.example"); + verify(span).setTag(KnownTags.PEER_IPV4_ID, (CharSequence) "10.0.0.1"); + verifyNoMoreInteractions(span); + } + + @Test + void resolvedIpv6SetsHostnameAndIpv6() throws Exception { + InetAddress address = ipv6(); + PeerAddress.RESOLVED.extract(address, span); + + verify(span).setTag(KnownTags.PEER_HOSTNAME_ID, (CharSequence) "db6.example"); + verify(span).setTag(KnownTags.PEER_IPV6_ID, (CharSequence) address.getHostAddress()); + verifyNoMoreInteractions(span); + } + + @Test + void unresolvedSkipsHostname() throws Exception { + PeerAddress.UNRESOLVED.extract(ipv4(), span); + + verify(span).setTag(KnownTags.PEER_IPV4_ID, (CharSequence) "10.0.0.1"); + verifyNoMoreInteractions(span); + } + + @Test + @WithConfig(key = "trace.peer.hostname.enabled", value = "false") + void hostnameDisabledSkipsHostname() throws Exception { + PeerAddress.RESOLVED.extract(ipv4(), span); + + verify(span).setTag(KnownTags.PEER_IPV4_ID, (CharSequence) "10.0.0.1"); + verifyNoMoreInteractions(span); + } + + @Test + void socketAddressAddsPort() throws Exception { + PeerSocketAddress.INSTANCE.extract(new InetSocketAddress(ipv4(), 5432), span); + + verify(span).setTag(KnownTags.PEER_HOSTNAME_ID, (CharSequence) "db.example"); + verify(span).setTag(KnownTags.PEER_IPV4_ID, (CharSequence) "10.0.0.1"); + verify(span).setTag(KnownTags.PEER_PORT_ID, 5432); + verifyNoMoreInteractions(span); + } + + @Test + void unresolvedSocketAddressSetsOnlyPort() { + PeerSocketAddress.INSTANCE.extract( + InetSocketAddress.createUnresolved("bad.address.local", 999), span); + + verify(span).setTag(KnownTags.PEER_PORT_ID, 999); + verifyNoMoreInteractions(span); + } + + @Test + void unsetPortIsSkipped() throws Exception { + PeerSocketAddress.INSTANCE.extract(new InetSocketAddress(ipv4(), 0), span); + + verify(span, never()).setTag(eq(KnownTags.PEER_PORT_ID), anyInt()); + } +} diff --git a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/UrlConnectionDecoratorUrlTest.java b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/UrlConnectionDecoratorUrlTest.java index e621e1bdf2a..710b27a2f14 100644 --- a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/UrlConnectionDecoratorUrlTest.java +++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/UrlConnectionDecoratorUrlTest.java @@ -1,7 +1,7 @@ package datadog.trace.bootstrap.instrumentation.decorator; import static datadog.trace.api.config.GeneralConfig.TRACE_OTEL_SEMANTICS_ENABLED; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; @@ -24,7 +24,8 @@ void datadogUrlConnectionKeepsOriginalUrl() { UrlConnectionDecorator.DECORATE.onURI(span, URI.create(url)); - verify(span).setTag(KnownTags.HTTP_URL_ID, url); - verify(span, never()).setTag(eq(KnownTags.HTTP_QUERY_STRING_ID), anyString()); + // the URL extractors set tags through SpanData, whose id setters take a CharSequence + verify(span).setTag(KnownTags.HTTP_URL_ID, (CharSequence) url); + verify(span, never()).setTag(eq(KnownTags.HTTP_QUERY_STRING_ID), any(CharSequence.class)); } } diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerInstrumentation.java b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerInstrumentation.java index 756f59aad4b..b4f4b2608a3 100644 --- a/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerInstrumentation.java +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerInstrumentation.java @@ -66,6 +66,7 @@ public String[] helperClassNames() { packageName + ".TextMapInjectAdapterInterface", packageName + ".KafkaConsumerInfo", packageName + ".KafkaConsumerInstrumentationHelper", + packageName + ".KafkaConsumerTags", packageName + ".KafkaDecorator", packageName + ".TextMapExtractAdapter", packageName + ".TracingIterableDelegator", diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerTags.java b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerTags.java new file mode 100644 index 00000000000..3f885fcd1e1 --- /dev/null +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerTags.java @@ -0,0 +1,56 @@ +package datadog.trace.instrumentation.kafka_clients; + +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.CONSUMER_GROUP; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.KAFKA_BOOTSTRAP_SERVERS; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.KAFKA_CLUSTER_ID; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.MESSAGING_DESTINATION_NAME; + +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; +import datadog.trace.bootstrap.instrumentation.api.SpanData; +import datadog.trace.bootstrap.instrumentation.api.TagExtractor; +import org.apache.kafka.clients.consumer.ConsumerRecord; + +/** + * The consumer tags every record of one topic in one poll shares -- destination, group, cluster id + * and bootstrap servers -- extracted from the topic and frozen once, then applied to each record's + * span. Records come out of a poll partition by partition, so the topic rarely changes between + * records and one cached topic suffices. Used by one iterator, on one thread. + */ +public final class KafkaConsumerTags implements TagExtractor { + private final String group; + private final String clusterId; + private final String bootstrapServers; + + private String frozenTopic; + private FrozenTags frozen; + + public KafkaConsumerTags(String group, String clusterId, String bootstrapServers) { + this.group = group; + this.clusterId = clusterId; + this.bootstrapServers = bootstrapServers; + } + + /** The frozen tags for {@code record}'s topic, frozen when the topic changes. */ + public FrozenTags of(final ConsumerRecord record) { + final String topic = KafkaDecorator.topicOf(record); + if (topic != frozenTopic && !topic.equals(frozenTopic)) { + frozen = FrozenTags.of(topic, this); + frozenTopic = topic; + } + return frozen; + } + + @Override + public void extract(final String topic, final SpanData span) { + span.setTag(MESSAGING_DESTINATION_NAME, topic); + if (group != null) { + span.setTag(CONSUMER_GROUP, group); + } + if (clusterId != null) { + span.setTag(KAFKA_CLUSTER_ID, clusterId); + } + if (bootstrapServers != null) { + span.setTag(KAFKA_BOOTSTRAP_SERVERS, bootstrapServers); + } + } +} diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaDecorator.java b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaDecorator.java index 53c579a4dee..5d5023eb778 100644 --- a/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaDecorator.java +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaDecorator.java @@ -1,6 +1,5 @@ package datadog.trace.instrumentation.kafka_clients; -import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.CONSUMER_GROUP; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.KAFKA_BOOTSTRAP_SERVERS; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.KAFKA_CLUSTER_ID; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.MESSAGING_DESTINATION_NAME; @@ -16,6 +15,7 @@ import datadog.trace.api.cache.DDCaches; import datadog.trace.api.naming.SpanNaming; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; @@ -114,27 +114,23 @@ protected String spanKind() { return spanKind; } + /** {@code record}'s topic, or {@code "kafka"} when it has none. */ + static String topicOf(final ConsumerRecord record) { + return record.topic() == null ? "kafka" : record.topic(); + } + + /** + * Decorates a consumed record's span. {@code consumerTags} are the destination, group, cluster id + * and bootstrap servers, frozen once per topic per poll ({@link KafkaConsumerTags}). + */ public void onConsume( - final AgentSpan span, - final ConsumerRecord record, - String consumerGroup, - String clusterId, - String bootstrapServers) { + final AgentSpan span, final ConsumerRecord record, final FrozenTags consumerTags) { if (record != null) { - final String topic = record.topic() == null ? "kafka" : record.topic(); + final String topic = topicOf(record); span.setResourceName(CONSUMER_RESOURCE_NAME_CACHE.computeIfAbsent(topic, CONSUMER_PREFIX)); span.setTag(PARTITION, record.partition()); span.setTag(OFFSET, record.offset()); - span.setTag(MESSAGING_DESTINATION_NAME, topic); - if (consumerGroup != null) { - span.setTag(CONSUMER_GROUP, consumerGroup); - } - if (clusterId != null) { - span.setTag(KAFKA_CLUSTER_ID, clusterId); - } - if (bootstrapServers != null) { - span.setTag(KAFKA_BOOTSTRAP_SERVERS, bootstrapServers); - } + span.setTagsFrom(consumerTags); // TODO - do we really need both? This mechanism already adds a lot of... baggage. // check to not record a duration if the message was sent from an old Kafka client if (record.timestampType() != TimestampType.NO_TIMESTAMP_TYPE) { diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/TracingIterator.java b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/TracingIterator.java index 93179e4e3f2..2339192cee6 100644 --- a/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/TracingIterator.java +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/TracingIterator.java @@ -46,7 +46,9 @@ public class TracingIterator implements Iterator> { private final KafkaDecorator decorator; private final String group; private final String clusterId; - private final String bootstrapServers; + + /** This poll's consumer tags, frozen per topic. */ + private final KafkaConsumerTags consumerTags; public TracingIterator( final Iterator> delegateIterator, @@ -60,7 +62,7 @@ public TracingIterator( this.decorator = decorator; this.group = group; this.clusterId = clusterId; - this.bootstrapServers = bootstrapServers; + this.consumerTags = new KafkaConsumerTags(group, clusterId, bootstrapServers); } @Override @@ -146,7 +148,7 @@ protected void startNewRecordSpan(ConsumerRecord val) { span.setTag(InstrumentationTags.TOMBSTONE, true); } decorator.afterStart(span); - decorator.onConsume(span, val, group, clusterId, bootstrapServers); + decorator.onConsume(span, val, consumerTags.of(val)); if (InstrumenterConfig.get().isLegacyContextManagerEnabled()) { activateNext(span); } else { diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/test/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerTagsTest.java b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/test/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerTagsTest.java new file mode 100644 index 00000000000..b22a69c73dc --- /dev/null +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/test/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerTagsTest.java @@ -0,0 +1,66 @@ +package datadog.trace.instrumentation.kafka_clients; + +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.CONSUMER_GROUP; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.KAFKA_BOOTSTRAP_SERVERS; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.KAFKA_CLUSTER_ID; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.MESSAGING_DESTINATION_NAME; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import datadog.trace.api.TagMap; +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.Test; + +class KafkaConsumerTagsTest { + private static ConsumerRecord record(String topic, long offset) { + return new ConsumerRecord<>(topic, 0, offset, "key", "value"); + } + + @Test + void freezesTheConsumerTagsOfATopic() { + KafkaConsumerTags tags = new KafkaConsumerTags("group", "cluster", "broker:9092"); + + TagMap frozen = tags.of(record("orders", 0)).tags(); + + assertEquals(4, frozen.size()); + assertEquals("orders", frozen.getObject(MESSAGING_DESTINATION_NAME)); + assertEquals("group", frozen.getObject(CONSUMER_GROUP)); + assertEquals("cluster", frozen.getObject(KAFKA_CLUSTER_ID)); + assertEquals("broker:9092", frozen.getObject(KAFKA_BOOTSTRAP_SERVERS)); + } + + @Test + void recordsOfTheSameTopicShareOneFreeze() { + KafkaConsumerTags tags = new KafkaConsumerTags("group", "cluster", "broker:9092"); + + FrozenTags first = tags.of(record("orders", 0)); + // an equal but distinct topic string, as a later fetch may hand out + assertSame(first, tags.of(record(new String("orders"), 1))); + } + + @Test + void aNewTopicFreezesAgain() { + KafkaConsumerTags tags = new KafkaConsumerTags("group", "cluster", "broker:9092"); + + FrozenTags orders = tags.of(record("orders", 0)); + FrozenTags payments = tags.of(record("payments", 0)); + + assertNotSame(orders, payments); + assertEquals("payments", payments.tags().getObject(MESSAGING_DESTINATION_NAME)); + assertEquals( + "orders", tags.of(record("orders", 1)).tags().getObject(MESSAGING_DESTINATION_NAME)); + } + + @Test + void missingConsumerInfoIsLeftOut() { + KafkaConsumerTags tags = new KafkaConsumerTags(null, null, null); + + TagMap frozen = tags.of(record("orders", 0)).tags(); + + assertEquals(1, frozen.size()); + assertNull(frozen.getObject(CONSUMER_GROUP)); + } +} diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java/datadog/trace/instrumentation/kafka_clients38/KafkaConsumerInstrumentation.java b/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java/datadog/trace/instrumentation/kafka_clients38/KafkaConsumerInstrumentation.java index 70f3f6dbc92..4da2b5be2a6 100644 --- a/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java/datadog/trace/instrumentation/kafka_clients38/KafkaConsumerInstrumentation.java +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java/datadog/trace/instrumentation/kafka_clients38/KafkaConsumerInstrumentation.java @@ -54,6 +54,7 @@ public String[] helperClassNames() { packageName + ".TextMapInjectAdapterInterface", packageName + ".KafkaConsumerInfo", packageName + ".KafkaConsumerInstrumentationHelper", + packageName + ".KafkaConsumerTags", packageName + ".KafkaDecorator", packageName + ".TextMapExtractAdapter", packageName + ".TracingIterableDelegator", diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/KafkaConsumerTags.java b/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/KafkaConsumerTags.java new file mode 100644 index 00000000000..70eeecbd772 --- /dev/null +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/KafkaConsumerTags.java @@ -0,0 +1,56 @@ +package datadog.trace.instrumentation.kafka_clients38; + +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.CONSUMER_GROUP; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.KAFKA_BOOTSTRAP_SERVERS; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.KAFKA_CLUSTER_ID; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.MESSAGING_DESTINATION_NAME; + +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; +import datadog.trace.bootstrap.instrumentation.api.SpanData; +import datadog.trace.bootstrap.instrumentation.api.TagExtractor; +import org.apache.kafka.clients.consumer.ConsumerRecord; + +/** + * The consumer tags every record of one topic in one poll shares -- destination, group, cluster id + * and bootstrap servers -- extracted from the topic and frozen once, then applied to each record's + * span. Records come out of a poll partition by partition, so the topic rarely changes between + * records and one cached topic suffices. Used by one iterator, on one thread. + */ +public final class KafkaConsumerTags implements TagExtractor { + private final String group; + private final String clusterId; + private final String bootstrapServers; + + private String frozenTopic; + private FrozenTags frozen; + + public KafkaConsumerTags(String group, String clusterId, String bootstrapServers) { + this.group = group; + this.clusterId = clusterId; + this.bootstrapServers = bootstrapServers; + } + + /** The frozen tags for {@code record}'s topic, frozen when the topic changes. */ + public FrozenTags of(final ConsumerRecord record) { + final String topic = KafkaDecorator.topicOf(record); + if (topic != frozenTopic && !topic.equals(frozenTopic)) { + frozen = FrozenTags.of(topic, this); + frozenTopic = topic; + } + return frozen; + } + + @Override + public void extract(final String topic, final SpanData span) { + span.setTag(MESSAGING_DESTINATION_NAME, topic); + if (group != null) { + span.setTag(CONSUMER_GROUP, group); + } + if (clusterId != null) { + span.setTag(KAFKA_CLUSTER_ID, clusterId); + } + if (bootstrapServers != null) { + span.setTag(KAFKA_BOOTSTRAP_SERVERS, bootstrapServers); + } + } +} diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/KafkaDecorator.java b/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/KafkaDecorator.java index d2d6f53b8a9..d09533cd8a2 100644 --- a/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/KafkaDecorator.java +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/KafkaDecorator.java @@ -1,6 +1,5 @@ package datadog.trace.instrumentation.kafka_clients38; -import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.CONSUMER_GROUP; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.KAFKA_BOOTSTRAP_SERVERS; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.KAFKA_CLUSTER_ID; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.MESSAGING_DESTINATION_NAME; @@ -16,6 +15,7 @@ import datadog.trace.api.cache.DDCaches; import datadog.trace.api.naming.SpanNaming; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; @@ -114,27 +114,23 @@ protected String spanKind() { return spanKind; } + /** {@code record}'s topic, or {@code "kafka"} when it has none. */ + static String topicOf(final ConsumerRecord record) { + return record.topic() == null ? "kafka" : record.topic(); + } + + /** + * Decorates a consumed record's span. {@code consumerTags} are the destination, group, cluster id + * and bootstrap servers, frozen once per topic per poll ({@link KafkaConsumerTags}). + */ public void onConsume( - final AgentSpan span, - final ConsumerRecord record, - String consumerGroup, - String clusterId, - String bootstrapServers) { + final AgentSpan span, final ConsumerRecord record, final FrozenTags consumerTags) { if (record != null) { - final String topic = record.topic() == null ? "kafka" : record.topic(); + final String topic = topicOf(record); span.setResourceName(CONSUMER_RESOURCE_NAME_CACHE.computeIfAbsent(topic, CONSUMER_PREFIX)); span.setTag(PARTITION, record.partition()); span.setTag(OFFSET, record.offset()); - span.setTag(MESSAGING_DESTINATION_NAME, topic); - if (consumerGroup != null) { - span.setTag(CONSUMER_GROUP, consumerGroup); - } - if (clusterId != null) { - span.setTag(KAFKA_CLUSTER_ID, clusterId); - } - if (bootstrapServers != null) { - span.setTag(KAFKA_BOOTSTRAP_SERVERS, bootstrapServers); - } + span.setTagsFrom(consumerTags); // TODO - do we really need both? This mechanism already adds a lot of... baggage. // check to not record a duration if the message was sent from an old Kafka client if (record.timestampType() != TimestampType.NO_TIMESTAMP_TYPE) { diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/TracingIterator.java b/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/TracingIterator.java index 7beba848473..d92426ae035 100644 --- a/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/TracingIterator.java +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/TracingIterator.java @@ -42,7 +42,9 @@ public class TracingIterator implements Iterator> { private final KafkaDecorator decorator; private final String group; private final String clusterId; - private final String bootstrapServers; + + /** This poll's consumer tags, frozen per topic. */ + private final KafkaConsumerTags consumerTags; public TracingIterator( final Iterator> delegateIterator, @@ -56,7 +58,7 @@ public TracingIterator( this.decorator = decorator; this.group = group; this.clusterId = clusterId; - this.bootstrapServers = bootstrapServers; + this.consumerTags = new KafkaConsumerTags(group, clusterId, bootstrapServers); } @Override @@ -142,7 +144,7 @@ protected void startNewRecordSpan(ConsumerRecord val) { span.setTag(InstrumentationTags.TOMBSTONE, true); } decorator.afterStart(span); - decorator.onConsume(span, val, group, clusterId, bootstrapServers); + decorator.onConsume(span, val, consumerTags.of(val)); if (InstrumenterConfig.get().isLegacyContextManagerEnabled()) { activateNext(span); } else { diff --git a/dd-java-agent/instrumentation/netty/netty-4.1/src/main/java/datadog/trace/instrumentation/netty41/client/HttpClientRequestTracingHandler.java b/dd-java-agent/instrumentation/netty/netty-4.1/src/main/java/datadog/trace/instrumentation/netty41/client/HttpClientRequestTracingHandler.java index 47134f4a0cf..c61ed2d7d37 100644 --- a/dd-java-agent/instrumentation/netty/netty-4.1/src/main/java/datadog/trace/instrumentation/netty41/client/HttpClientRequestTracingHandler.java +++ b/dd-java-agent/instrumentation/netty/netty-4.1/src/main/java/datadog/trace/instrumentation/netty41/client/HttpClientRequestTracingHandler.java @@ -6,6 +6,8 @@ import static datadog.trace.instrumentation.netty41.AttributeKeys.CLIENT_PARENT_ATTRIBUTE_KEY; import static datadog.trace.instrumentation.netty41.AttributeKeys.CONNECT_PARENT_CONTINUATION_ATTRIBUTE_KEY; import static datadog.trace.instrumentation.netty41.AttributeKeys.CONTEXT_ATTRIBUTE_KEY; +import static datadog.trace.instrumentation.netty41.AttributeKeys.PEER_TAGS_ADDRESS_ATTRIBUTE_KEY; +import static datadog.trace.instrumentation.netty41.AttributeKeys.PEER_TAGS_ATTRIBUTE_KEY; import static datadog.trace.instrumentation.netty41.client.NettyHttpClientDecorator.DECORATE; import static datadog.trace.instrumentation.netty41.client.NettyHttpClientDecorator.DECORATE_SECURE; import static datadog.trace.instrumentation.netty41.client.NettyHttpClientDecorator.NETTY_CLIENT; @@ -17,12 +19,15 @@ import datadog.context.ContextScope; import datadog.trace.api.Config; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; +import datadog.trace.bootstrap.instrumentation.decorator.PeerSocketAddress; import io.netty.channel.Channel; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelOutboundHandlerAdapter; import io.netty.channel.ChannelPromise; import io.netty.handler.codec.http.HttpRequest; +import io.netty.util.Attribute; import java.net.InetSocketAddress; import java.net.SocketAddress; @@ -84,10 +89,7 @@ public void write(final ChannelHandlerContext ctx, final Object msg, final Chann decorate.afterStart(span); decorate.onRequest(span, request); - SocketAddress socketAddress = ctx.channel().remoteAddress(); - if (socketAddress instanceof InetSocketAddress) { - decorate.onPeerConnection(span, (InetSocketAddress) socketAddress); - } + span.setTagsFrom(peerTags(ctx.channel())); // AWS calls are often signed, so we can't add headers without breaking the signature. if (!awsClientCall) { @@ -111,6 +113,33 @@ public void write(final ChannelHandlerContext ctx, final Object msg, final Chann } } + /** + * The peer tags of {@code channel}'s remote address, frozen once per address and kept on the + * channel, so later requests skip rebuilding the IP string and resolving the hostname. An HTTP/2 + * stream reports its connection's address object, so its tags are kept on the connection and + * shared by its streams; any other parent (a server channel) reports a different address, so the + * channel keeps its own. The cached tags are reused only while the channel still reports the + * address object they were frozen from. Nothing is cached while there is no remote address yet. + * Called on the channel's event loop, which HTTP/2 streams share with their connection. + */ + private static FrozenTags peerTags(final Channel channel) { + final SocketAddress address = channel.remoteAddress(); + if (!(address instanceof InetSocketAddress)) { + return FrozenTags.EMPTY; + } + final Channel parent = channel.parent(); + final Channel holder = parent != null && parent.remoteAddress() == address ? parent : channel; + final Attribute tagsAttribute = holder.attr(PEER_TAGS_ATTRIBUTE_KEY); + final Attribute addressAttribute = holder.attr(PEER_TAGS_ADDRESS_ATTRIBUTE_KEY); + FrozenTags tags = tagsAttribute.get(); + if (tags == null || addressAttribute.get() != address) { + tags = FrozenTags.of((InetSocketAddress) address, PeerSocketAddress.INSTANCE); + tagsAttribute.set(tags); + addressAttribute.set(address); + } + return tags; + } + private static ContextContinuation takeConnectParentContinuation( final ChannelHandlerContext ctx) { final Channel channel = ctx.channel(); diff --git a/dd-java-agent/instrumentation/netty/netty-common/src/main/java/datadog/trace/instrumentation/netty41/AttributeKeys.java b/dd-java-agent/instrumentation/netty/netty-common/src/main/java/datadog/trace/instrumentation/netty41/AttributeKeys.java index addf40b55d6..d70aafc2226 100644 --- a/dd-java-agent/instrumentation/netty/netty-common/src/main/java/datadog/trace/instrumentation/netty41/AttributeKeys.java +++ b/dd-java-agent/instrumentation/netty/netty-common/src/main/java/datadog/trace/instrumentation/netty41/AttributeKeys.java @@ -6,8 +6,10 @@ import datadog.context.ContextContinuation; import datadog.trace.api.GenericClassValue; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; import datadog.trace.bootstrap.instrumentation.websocket.HandlerContext; import io.netty.util.AttributeKey; +import java.net.SocketAddress; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -28,6 +30,14 @@ public final class AttributeKeys { public static final AttributeKey HTTP2_CONNECTION_CODEC_ATTRIBUTE_KEY = attributeKey("datadog.http2.connection.codec"); + /** A client channel's peer tags, frozen from its remote address. */ + public static final AttributeKey PEER_TAGS_ATTRIBUTE_KEY = + attributeKey("datadog.client.peer.tags"); + + /** The remote address {@link #PEER_TAGS_ATTRIBUTE_KEY}'s tags were frozen from. */ + public static final AttributeKey PEER_TAGS_ADDRESS_ATTRIBUTE_KEY = + attributeKey("datadog.client.peer.tags.address"); + public static final AttributeKey PARENT_CONTEXT_ATTRIBUTE_KEY = attributeKey("datadog.server.parent-context"); diff --git a/dd-java-agent/instrumentation/opentelemetry/opentelemetry-0.3/src/test/groovy/TypeConverterTest.groovy b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-0.3/src/test/groovy/TypeConverterTest.groovy index 60c0f43d9be..9dcf709d75f 100644 --- a/dd-java-agent/instrumentation/opentelemetry/opentelemetry-0.3/src/test/groovy/TypeConverterTest.groovy +++ b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-0.3/src/test/groovy/TypeConverterTest.groovy @@ -53,7 +53,6 @@ class TypeConverterTest extends InstrumentationSpecification { [:], false, "fakeType", - 0, trace, null, null, diff --git a/dd-java-agent/instrumentation/opentracing/opentracing-0.31/src/test/groovy/TypeConverterTest.groovy b/dd-java-agent/instrumentation/opentracing/opentracing-0.31/src/test/groovy/TypeConverterTest.groovy index 3a3b5c6efca..db29f8de153 100644 --- a/dd-java-agent/instrumentation/opentracing/opentracing-0.31/src/test/groovy/TypeConverterTest.groovy +++ b/dd-java-agent/instrumentation/opentracing/opentracing-0.31/src/test/groovy/TypeConverterTest.groovy @@ -65,7 +65,6 @@ class TypeConverterTest extends InstrumentationSpecification { [:], false, "fakeType", - 0, trace, null, null, diff --git a/dd-java-agent/instrumentation/opentracing/opentracing-0.32/src/test/groovy/TypeConverterTest.groovy b/dd-java-agent/instrumentation/opentracing/opentracing-0.32/src/test/groovy/TypeConverterTest.groovy index 9ecfd5221ef..1506f3eb983 100644 --- a/dd-java-agent/instrumentation/opentracing/opentracing-0.32/src/test/groovy/TypeConverterTest.groovy +++ b/dd-java-agent/instrumentation/opentracing/opentracing-0.32/src/test/groovy/TypeConverterTest.groovy @@ -65,7 +65,6 @@ class TypeConverterTest extends InstrumentationSpecification { [:], false, "fakeType", - 0, trace, null, null, diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java index d31137c3aef..2fa5365f935 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java @@ -190,5 +190,11 @@ public final class TracerConfig { public static final String TRACE_ORG_GUARD_STRICT = "trace.org.guard.strict"; public static final String TRACE_ORG_GUARD_TRUSTED_OPMS = "trace.org.guard.trusted.opms"; + /** + * Stores known tags that have a dense-store slot in TagMap's dense store (no per-tag entry) + * instead of its hash buckets. Experimental, off by default. + */ + public static final String TRACE_DENSE_TAGS_ENABLED = "trace.experimental.dense.tags.enabled"; + private TracerConfig() {} } diff --git a/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/TraceMapperBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/TraceMapperBenchmark.java index 54192804b05..f7a6fec6e2c 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/TraceMapperBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/TraceMapperBenchmark.java @@ -116,7 +116,6 @@ public void init(Blackhole blackhole) { Collections.emptyMap(), false, UTF8BytesString.create("type"), - 0, traceCollector, null, null, diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/GetTagBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/GetTagBenchmark.java new file mode 100644 index 00000000000..57ce57b404f --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/GetTagBenchmark.java @@ -0,0 +1,116 @@ +package datadog.trace.core; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; + +import datadog.trace.api.KnownTags; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Measures reading tags back from a live span, by how the tag is named: a known tag's name, a + * custom name, an OpenTelemetry name ({@code server.address}, which the core treats as a custom + * name), and misses. {@code getTag} takes the span's tag lock; {@code unsafeGetTag} reads without + * it, as the tracer's internal readers do. + */ +@State(Scope.Thread) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(NANOSECONDS) +@Fork(value = 3) +public class GetTagBenchmark { + static final CoreTracer TRACER = CoreTracer.builder().build(); + + private DDSpan span; + + /** + * The known tags the span holds, read in turn by the {@code *_cycle} arms so a read's cost is + * averaged over the span's tags rather than decided by whether one particular tag collides in its + * bucket. A power-of-two length lets the index wrap with a mask. + */ + private static final String[] KNOWN_HELD = { + "span.kind", + KnownTags.HTTP_ROUTE_NAME, + KnownTags.PEER_HOSTNAME_NAME, + KnownTags.HTTP_STATUS_CODE_NAME + }; + + private int next; + + @Setup + public void setup() { + span = (DDSpan) TRACER.startSpan("benchmark", "get.tag"); + span.setTag("span.kind", "client"); + span.setTag(KnownTags.HTTP_ROUTE_NAME, "/users/{id}"); + span.setTag(KnownTags.PEER_HOSTNAME_NAME, "db.example"); + span.setTag(KnownTags.HTTP_STATUS_CODE_NAME, 200); + span.setTag("my.custom.tag", "custom"); + span.setTag("my.other.tag", 42); + } + + @TearDown + public void tearDown() { + span.finish(); + } + + @Benchmark + public Object knownTag_getTag() { + return span.getTag(KnownTags.HTTP_ROUTE_NAME); + } + + @Benchmark + public Object customTag_getTag() { + return span.getTag("my.custom.tag"); + } + + @Benchmark + public Object aliasName_getTag() { + return span.getTag("server.address"); + } + + @Benchmark + public Object missingTag_getTag() { + return span.getTag("not.there"); + } + + /** A known tag the span doesn't hold: a clear slot bit can answer without a probe. */ + @Benchmark + public Object missingKnownTag_getTag() { + return span.getTag(KnownTags.HTTP_METHOD_NAME); + } + + @Benchmark + public Object missingKnownTag_unsafeGetTag() { + return span.unsafeGetTag(KnownTags.HTTP_METHOD_NAME); + } + + @Benchmark + public Object knownTag_unsafeGetTag() { + return span.unsafeGetTag(KnownTags.HTTP_ROUTE_NAME); + } + + /** Every known tag the span holds, in turn; see {@link #KNOWN_HELD}. */ + @Benchmark + public Object knownTags_cycle_unsafeGetTag() { + return span.unsafeGetTag(KNOWN_HELD[next++ & (KNOWN_HELD.length - 1)]); + } + + @Benchmark + public Object knownTags_cycle_getTag() { + return span.getTag(KNOWN_HELD[next++ & (KNOWN_HELD.length - 1)]); + } + + @Benchmark + public Object customTag_unsafeGetTag() { + return span.unsafeGetTag("my.custom.tag"); + } +} diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/HeaderPathBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/HeaderPathBenchmark.java new file mode 100644 index 00000000000..e9e2d73b517 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/HeaderPathBenchmark.java @@ -0,0 +1,232 @@ +package datadog.trace.core; + +import static datadog.context.Context.root; +import static datadog.trace.bootstrap.instrumentation.api.AgentSpan.fromContext; +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.context.Context; +import datadog.context.ContextScope; +import datadog.context.propagation.CarrierSetter; +import datadog.context.propagation.CarrierVisitor; +import datadog.context.propagation.Propagators; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiConsumer; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * One whole request per op, through the header code paths: extract the inbound headers with the + * default propagator, start the server span from what was extracted, run a few children, make one + * outbound HTTP call that injects its headers, then finish everything. + * + *

The existing propagation benchmarks each measure one step (extracting into a context, + * injecting from a prebuilt one, parsing one header value). They can't see costs that the extracted + * state causes later, on every span of the trace: for example, a {@code tracestate} that leads each + * span to allocate sampling state. Measuring the whole request catches those. + * + *

{@code headers} selects the inbound headers, under the default propagation styles: + * + *

+ */ +@State(Scope.Benchmark) +@Warmup(iterations = 5) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.Throughput) +@Threads(8) +@OutputTimeUnit(MICROSECONDS) +@Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn") +public class HeaderPathBenchmark { + static final int JDBC_SPANS = 3; + + static final String TRACE_ID_HEX = "66a0f6c800000000a3ce929d0e0e4736"; + static final String TRACE_ID_LOW = "11803532876627986230"; // a3ce929d0e0e4736 + static final String PARENT_ID = "67667974448284343"; // 00f067aa0ba902b7 + static final String PARENT_ID_HEX = "00f067aa0ba902b7"; + + static final String SERVER_INSTRUMENTATION = "tomcat-server"; + static final CharSequence SERVER_OPERATION = UTF8BytesString.create("servlet.request"); + static final String JDBC_INSTRUMENTATION = "java-jdbc-prepared_statement"; + static final CharSequence JDBC_OPERATION = UTF8BytesString.create("database.query"); + static final String CLIENT_INSTRUMENTATION = "okhttp"; + static final CharSequence CLIENT_OPERATION = UTF8BytesString.create("okhttp.request"); + + static final CarrierVisitor> GETTER = + new CarrierVisitor>() { + @Override + public void forEachKeyValue( + Map carrier, BiConsumer visitor) { + carrier.forEach(visitor); + } + }; + static final CarrierSetter> SETTER = Map::put; + + @Param({"none", "datadog", "tracecontext", "both", "bothBaggage"}) + String headers; + + CoreTracer tracer; + Map inbound; + SpanPrototype serverProto; + SpanPrototype jdbcProto; + SpanPrototype clientProto; + + /** One outbound carrier per thread, reused across requests. */ + @State(Scope.Thread) + public static class Outbound { + final Map headers = new HashMap<>(); + } + + @Setup + public void setup(Blackhole blackhole) { + this.tracer = CoreTracer.builder().writer(new DropWriter(blackhole)).build(); + this.inbound = inboundHeaders(headers); + this.serverProto = + SpanPrototype.builder() + .initInstrumentationName(SERVER_INSTRUMENTATION) + .initOperationName(SERVER_OPERATION) + .initSpanType("web") + .initComponentAndIntegration("tomcat-server") + .initKind(Tags.SPAN_KIND_SERVER) + .initTag("language", "jvm") + .build(); + this.jdbcProto = + SpanPrototype.builder() + .initInstrumentationName(JDBC_INSTRUMENTATION) + .initOperationName(JDBC_OPERATION) + .initSpanType("sql") + .initComponentAndIntegration("java-jdbc") + .initKind(Tags.SPAN_KIND_CLIENT) + .build(); + this.clientProto = + SpanPrototype.builder() + .initInstrumentationName(CLIENT_INSTRUMENTATION) + .initOperationName(CLIENT_OPERATION) + .initSpanType("http") + .initComponentAndIntegration("okhttp") + .initKind(Tags.SPAN_KIND_CLIENT) + .build(); + checkPropagation(); + } + + /** + * Fails fast if the headers aren't actually extracted and injected, or if the outbound call + * doesn't continue the extracted trace, either of which would measure something else. + */ + void checkPropagation() { + Context parent = Propagators.defaultPropagator().extract(root(), inbound, GETTER); + if (!headers.equals("none") && fromContext(parent) == null) { + throw new IllegalStateException("nothing extracted from " + headers + " headers"); + } + Outbound outbound = new Outbound(); + request(outbound); + String injectedTraceId = outbound.headers.get("x-datadog-trace-id"); + if (injectedTraceId == null) { + throw new IllegalStateException("nothing injected: " + outbound.headers); + } + if (!headers.equals("none") && !injectedTraceId.equals(TRACE_ID_LOW)) { + throw new IllegalStateException( + "outbound call didn't continue the extracted trace: " + outbound.headers); + } + } + + @TearDown + public void tearDown() { + this.tracer.close(); + } + + static Map inboundHeaders(String style) { + Map h = new HashMap<>(); + h.put("host", "localhost:8080"); + h.put("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0"); + h.put("accept", "application/json"); + if (style.equals("datadog") || style.startsWith("both")) { + h.put("x-datadog-trace-id", TRACE_ID_LOW); + h.put("x-datadog-parent-id", PARENT_ID); + h.put("x-datadog-sampling-priority", "1"); + h.put("x-datadog-tags", "_dd.p.dm=-0,_dd.p.tid=66a0f6c800000000"); + } + if (style.equals("tracecontext") || style.startsWith("both")) { + h.put("traceparent", "00-" + TRACE_ID_HEX + "-" + PARENT_ID_HEX + "-01"); + h.put( + "tracestate", + "dd=s:1;p:" + + PARENT_ID_HEX + + ";t.dm:-0;t.tid:66a0f6c800000000," + + "ot=th:8;rv:9b8233f7e3a151,rojo=00f067aa0ba902b7"); + } + if (style.equals("bothBaggage")) { + h.put("baggage", "userId=alice,serverNode=DF%2028,isProduction=false"); + } + return h; + } + + @Benchmark + public void request(Outbound outbound) { + outbound.headers.clear(); + // HttpServerDecorator.extract / startSpan + Context parent = Propagators.defaultPropagator().extract(root(), inbound, GETTER); + AgentSpan extractedSpan = fromContext(parent); + AgentSpanContext extracted = extractedSpan == null ? null : extractedSpan.spanContext(); + AgentSpan server = tracer.startSpan(SERVER_INSTRUMENTATION, SERVER_OPERATION, extracted); + server.setMeasured(true); + server.applyOverwriting(serverProto); + server.setTag(Tags.HTTP_METHOD, "GET"); + server.setTag(Tags.HTTP_URL, "http://localhost:8080/owners/42"); + server.setTag(Tags.HTTP_ROUTE, "/owners/{ownerId}"); + server.setTag(Tags.PEER_PORT, 54321); + + try (ContextScope serverScope = tracer.attach(parent.with(server))) { + for (int i = 0; i < JDBC_SPANS; ++i) { + AgentSpan jdbc = tracer.startSpan(JDBC_INSTRUMENTATION, JDBC_OPERATION); + jdbc.applyOverwriting(jdbcProto); + jdbc.setTag(Tags.DB_TYPE, "postgresql"); + jdbc.setTag(Tags.DB_INSTANCE, "petclinic"); + jdbc.setTag(Tags.PEER_HOSTNAME, "db.internal"); + jdbc.setResourceName("SELECT * FROM owners WHERE id = ?"); + try (ContextScope s = tracer.activateSpan(jdbc)) { + // the statement executes + } + jdbc.finish(); + } + + // the outbound call: start the client span, inject its headers, finish + AgentSpan client = tracer.startSpan(CLIENT_INSTRUMENTATION, CLIENT_OPERATION); + client.applyOverwriting(clientProto); + client.setTag(Tags.HTTP_METHOD, "GET"); + client.setTag(Tags.HTTP_URL, "http://inventory.internal/items/7"); + client.setTag(Tags.PEER_HOSTNAME, "inventory.internal"); + client.setTag(Tags.PEER_PORT, 8080); + Propagators.defaultPropagator() + .inject(tracer.currentContext().with(client), outbound.headers, SETTER); + client.setHttpStatusCode(200); + client.finish(); + } + server.setHttpStatusCode(200); + server.finish(); + } +} diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/PendingTraceWrite.java b/dd-trace-core/src/jmh/java/datadog/trace/core/PendingTraceWrite.java index b0a90c7bffb..dda438dfc16 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/PendingTraceWrite.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/PendingTraceWrite.java @@ -55,7 +55,6 @@ public void init(TraceCounters counters, Blackhole blackhole) { Collections.emptyMap(), false, "type", - 0, traceCollector, null, null, @@ -80,7 +79,6 @@ public void init(TraceCounters counters, Blackhole blackhole) { Collections.emptyMap(), false, "type", - 0, traceCollector, null, null, diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SetTagsFromBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SetTagsFromBenchmark.java new file mode 100644 index 00000000000..f7fc315872e --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SetTagsFromBenchmark.java @@ -0,0 +1,147 @@ +package datadog.trace.core; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; + +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.SpanData; +import datadog.trace.bootstrap.instrumentation.api.TagContributor; +import datadog.trace.bootstrap.instrumentation.api.TagExtractor; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Measures {@code DDSpan.setTagsFrom}, which takes the span's tag lock once for a whole batch, + * against the same tags set one {@code setTag} call (and one lock) at a time. + * + *

The batch is a jdbc span's nine tags: eight known tags by id, among them an int ({@code + * peer.port}) and an intercepted tag ({@code span.kind}), and one custom tag. They come from a + * {@code DbInfo}-style POJO, the case {@link TagContributor} is for, or through a {@link + * TagExtractor}. + * + *

    + *
  • {@code reused_*}: the tags overwritten on one live span, so only the writes are measured. + *
  • {@code fresh_*}: a span started, tagged and finished per op, so the batch's lock saving is + * seen next to a span's fixed cost. Its writer records each span's size, as serialization + * would, so the dense store's sizing hints converge. + *
+ * + *

Run with {@code -prof gc} to check that the batch's short-lived {@code SpanData} is + * scalar-replaced: the batched arms should allocate no more than the one-by-one arms. + */ +@State(Scope.Thread) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(NANOSECONDS) +@Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn") +public class SetTagsFromBenchmark { + /** A jdbc span's connection facts, contributed as tags: the shape TagContributor is for. */ + static final class DbInfo implements TagContributor { + final String type = "postgresql"; + final String pool = "HikariPool-1"; + final String user = "app"; + final String instance = "petclinic"; + final String host = "db.internal"; + final int port = 54321; // outside Integer's box cache, like a real port + final String operation = "SELECT"; + final String statement = "SELECT * FROM owners WHERE id = ?"; + + @Override + public void addTo(SpanData span) { + span.setTag(KnownTags.SPAN_KIND_ID, Tags.SPAN_KIND_CLIENT); + span.setTag(KnownTags.COMPONENT_ID, "java-jdbc-prepared_statement"); + span.setTag(KnownTags.DB_TYPE_ID, type); + span.setTag(KnownTags.DB_POOL_NAME_ID, pool); + span.setTag(KnownTags.DB_USER_ID, user); + span.setTag(KnownTags.DB_INSTANCE_ID, instance); + span.setTag(KnownTags.PEER_HOSTNAME_ID, host); + span.setTag(KnownTags.PEER_PORT_ID, port); + span.setTag(KnownTags.DB_OPERATION_ID, operation); + span.setTag("db.statement.custom", statement); + } + } + + /** The same writes from a foreign source, through an extractor held in a constant. */ + static final class DbInfoExtractor implements TagExtractor { + static final DbInfoExtractor INSTANCE = new DbInfoExtractor(); + + @Override + public void extract(DbInfo info, SpanData span) { + info.addTo(span); + } + } + + static final DbInfo DB_INFO = new DbInfo(); + + private CoreTracer tracer; + private DDSpan span; + + @Setup + public void setup(Blackhole blackhole) { + tracer = + CoreTracer.builder().writer(new TraceShapeBenchmark.SizeRecordingWriter(blackhole)).build(); + span = (DDSpan) tracer.startSpan("java-jdbc", "database.query"); + } + + @TearDown + public void tearDown() { + span.finish(); + tracer.close(); + } + + /** The batch one setTag at a time: DDSpan is a SpanData too, so the same addTo applies. */ + private static void oneByOne(DDSpan span) { + DB_INFO.addTo(span); + } + + @Benchmark + public Object reused_oneByOne() { + oneByOne(span); + return span; + } + + @Benchmark + public Object reused_contributor() { + return span.setTagsFrom(DB_INFO); + } + + @Benchmark + public Object reused_extractor() { + return span.setTagsFrom(DB_INFO, DbInfoExtractor.INSTANCE); + } + + @Benchmark + public Object fresh_oneByOne() { + DDSpan fresh = (DDSpan) tracer.startSpan("java-jdbc", "database.query"); + oneByOne(fresh); + fresh.finish(); + return fresh; + } + + @Benchmark + public Object fresh_contributor() { + DDSpan fresh = (DDSpan) tracer.startSpan("java-jdbc", "database.query"); + fresh.setTagsFrom(DB_INFO); + fresh.finish(); + return fresh; + } + + @Benchmark + public Object fresh_extractor() { + DDSpan fresh = (DDSpan) tracer.startSpan("java-jdbc", "database.query"); + fresh.setTagsFrom(DB_INFO, DbInfoExtractor.INSTANCE); + fresh.finish(); + return fresh; + } +} 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..eba7c661aff 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 @@ -255,6 +255,40 @@ public void webServerSpanViaPrototype() { span.finish(); } + /** + * Web-server-shaped span the way decorators build it today: started by name, then the decorator's + * prototype stamped over it after start ({@code BaseDecorator.afterStart} -> {@code + * applyOverwriting}). The baseline the construction-time prototype paths have to beat. + */ + @Benchmark + public void webServerSpanRetrofit() { + AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME); + span.applyOverwriting(webProto); + span.setTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE); + span.setTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE); + span.setTag(Tags.HTTP_URL, HTTP_URL_VALUE); + span.setTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE); + span.setTag(Tags.PEER_PORT, PEER_PORT_VALUE); + span.finish(); + } + + /** + * JDBC-client-shaped span the way decorators build it today: started by name, then the prototype + * stamped over it after start; see {@link #webServerSpanRetrofit}. + */ + @Benchmark + public void jdbcClientSpanRetrofit() { + AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, JDBC_OPERATION_NAME); + span.applyOverwriting(jdbcProto); + span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE); + span.setTag(Tags.DB_USER, DB_USER_VALUE); + span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE); + span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE); + span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE); + span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE); + span.finish(); + } + /** * JDBC/DB-client-shaped span via {@link SpanPrototype}: component, span.kind, and db.type ride * the prototype; the dynamic db.* / peer.* tags are set per-span. Compare against {@link @@ -272,6 +306,23 @@ public void jdbcClientSpanViaPrototype() { span.finish(); } + /** + * JDBC-client-shaped span via {@code singleSpanBuilder(SpanPrototype, ...)}: the thread's + * reusable builder carries the prototype. Compare against {@link #jdbcClientSpanViaPrototype} (a + * fresh multi-span builder) and {@link #jdbcClientSpanViaPrototypeStartSpan} (no builder). + */ + @Benchmark + public void jdbcClientSpanViaPrototypeSingleSpanBuilder() { + AgentSpan span = tracer.singleSpanBuilder(jdbcProto, null).start(); + span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE); + span.setTag(Tags.DB_USER, DB_USER_VALUE); + span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE); + span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE); + span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE); + span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE); + span.finish(); + } + /** * Web-server-shaped span via {@code startSpan(SpanPrototype, ...)} — the builder-free * construction entry (no MultiSpanBuilder allocation), the auto-instrumentation path. Compare diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java new file mode 100644 index 00000000000..12a8583a61c --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java @@ -0,0 +1,322 @@ +package datadog.trace.core; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.context.ContextScope; +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; +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import datadog.trace.common.writer.Writer; +import java.util.List; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * One whole trace per op, shaped like a web request: a server root, {@code children} jdbc + * prepared-statement children and one {@code @Trace} internal span, all finished. A trace has + * {@code children + 2} spans. + * + *

{@code children} varies the trace size, because single-span and small-trace results can hide + * costs that land on every child span: a change can make the root cheaper while charging each + * child, which only the larger traces show. + * + *

For retrospective comparison across tracer versions, each version keeps its own copy of this + * benchmark, so the arms reflect that version's code: + * + *

    + *
  • {@code legacy}: every tag set by name. It runs unchanged on every version, so it isolates + * the tracer's own costs. + *
  • {@code normal}: the calls this version's decorators make (copied from {@code + * HttpServerDecorator}, {@code JDBCDecorator} / {@code DatabaseClientDecorator} and {@code + * BaseDecorator} at this version), with request parsing replaced by fixed values. This is the + * version's real cost. + *
  • {@code newApi}: this version's newest span APIs, where they exist: here, construction from + * a {@link SpanPrototype} and tags set by id. + *
+ * + *

Tag values are realistic on purpose: the client port, the database port and the status fall + * outside {@code Integer}'s box cache, so primitive boxing shows up. + * + *

This copy: 1.68 snapshot (decorators apply a prototype in {@code afterStart}, #12037; peer + * tags by id, #12715), with the dense store's per-operation sizing hints, which the writer feeds as + * serialization would. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 5) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.Throughput) +@Threads(8) +@OutputTimeUnit(MICROSECONDS) +@Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn") +public class TraceShapeBenchmark { + /** The number of jdbc children, so the trace has {@code children + 2} spans. */ + @Param({"1", "5", "20"}) + int children; + + // server: HttpServerDecorator (tomcat) + static final String SERVER_INSTRUMENTATION = "tomcat-server"; + static final CharSequence SERVER_OPERATION = UTF8BytesString.create("servlet.request"); + static final CharSequence SERVER_COMPONENT = UTF8BytesString.create("tomcat-server"); + static final CharSequence WEB = UTF8BytesString.create("web"); + static final String USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0"; + static final String METHOD = "GET"; + static final String URL = "http://localhost:8080/owners/42"; + static final String HOSTNAME = "localhost"; + static final String CLIENT_IP = "127.0.0.1"; + static final int CLIENT_PORT = 54321; // ephemeral, outside the box cache + static final String ROUTE = "/owners/{ownerId}"; + static final CharSequence SERVER_RESOURCE = UTF8BytesString.create("GET /owners/{ownerId}"); + static final int STATUS = 200; // outside the box cache + + // jdbc: JDBCDecorator over a postgres prepared statement + static final String JDBC_INSTRUMENTATION = "java-jdbc-prepared_statement"; + static final CharSequence JDBC_OPERATION = UTF8BytesString.create("database.query"); + static final CharSequence JDBC_RENAMED_OPERATION = UTF8BytesString.create("postgresql.query"); + static final CharSequence JAVA_JDBC = UTF8BytesString.create("java-jdbc"); + static final CharSequence JDBC_PREPARED_STATEMENT = + UTF8BytesString.create("java-jdbc-prepared_statement"); + static final CharSequence SQL = UTF8BytesString.create("sql"); + static final String DB_TYPE = "postgresql"; + static final String DB_SERVICE = "postgresql"; + static final String DB_POOL = "HikariPool-1"; + static final String DB_USER = "app"; + static final String DB_INSTANCE = "petclinic"; + static final String DB_HOST = "db.internal"; + static final String DB_OPERATION = "SELECT"; + static final CharSequence STATEMENT = UTF8BytesString.create("SELECT * FROM owners WHERE id = ?"); + + // internal: TraceDecorator (@Trace) + static final String TRACE_INSTRUMENTATION = "trace"; + static final CharSequence TRACE_OPERATION = UTF8BytesString.create("trace.annotation"); + static final CharSequence TRACE_COMPONENT = UTF8BytesString.create("trace"); + static final CharSequence TRACE_RESOURCE = UTF8BytesString.create("OwnerService.findOwner"); + + CoreTracer tracer; + + SpanPrototype serverProto; + SpanPrototype jdbcProto; + SpanPrototype traceProto; + + @Setup + public void setup(Blackhole blackhole) { + this.tracer = CoreTracer.builder().writer(new SizeRecordingWriter(blackhole)).build(); + // the decorators' prototype chains: Base (type, component) -> Server / Client (kind, language) + this.serverProto = + SpanPrototype.builder() + .initInstrumentationName(SERVER_INSTRUMENTATION) + .initOperationName(SERVER_OPERATION) + .initSpanType(WEB) + .initComponentAndIntegration(SERVER_COMPONENT) + .initKind(Tags.SPAN_KIND_SERVER) + .initTag("language", "jvm") + .build(); + this.jdbcProto = + SpanPrototype.builder() + .initInstrumentationName(JDBC_INSTRUMENTATION) + .initOperationName(JDBC_OPERATION) + .initSpanType(SQL) + .initComponentAndIntegration(JAVA_JDBC) + .initKind(Tags.SPAN_KIND_CLIENT) + .build(); + this.traceProto = + SpanPrototype.builder() + .initInstrumentationName(TRACE_INSTRUMENTATION) + .initOperationName(TRACE_OPERATION) + .initComponentAndIntegration(TRACE_COMPONENT) + .build(); + } + + @TearDown + public void tearDown() { + this.tracer.close(); + } + + // ---- legacy: every tag by name ---- + + @Benchmark + public void legacy() { + AgentSpan server = tracer.startSpan(SERVER_INSTRUMENTATION, SERVER_OPERATION); + server.setSpanType(WEB); + server.setTag(Tags.COMPONENT, SERVER_COMPONENT); + server.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER); + server.setTag("language", "jvm"); + legacyRequest(server); + try (ContextScope scope = tracer.activateSpan(server)) { + for (int i = 0; i < children; ++i) { + AgentSpan jdbc = tracer.startSpan(JDBC_INSTRUMENTATION, JDBC_OPERATION); + jdbc.setSpanType(SQL); + jdbc.setTag(Tags.COMPONENT, JAVA_JDBC); + jdbc.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_CLIENT); + legacyJdbc(jdbc); + finishScoped(jdbc); + } + AgentSpan internal = tracer.startSpan(TRACE_INSTRUMENTATION, TRACE_OPERATION); + internal.setTag(Tags.COMPONENT, TRACE_COMPONENT); + internal.setResourceName(TRACE_RESOURCE); + finishScoped(internal); + } + server.setHttpStatusCode(STATUS); + server.finish(); + } + + static void legacyRequest(AgentSpan server) { + server.setTag(Tags.HTTP_USER_AGENT, USER_AGENT); + server.setTag(Tags.HTTP_METHOD, METHOD); + server.setTag(Tags.HTTP_URL, URL); + server.setTag(Tags.HTTP_HOSTNAME, HOSTNAME); + server.setResourceName(SERVER_RESOURCE); + server.setTag(Tags.PEER_HOST_IPV4, CLIENT_IP); + server.setTag(Tags.PEER_PORT, CLIENT_PORT); + server.setTag(Tags.HTTP_ROUTE, ROUTE); + } + + static void legacyJdbc(AgentSpan jdbc) { + jdbc.setTag(Tags.DB_TYPE, DB_TYPE); + jdbc.setServiceName(DB_SERVICE, JAVA_JDBC); + jdbc.setOperationName(JDBC_RENAMED_OPERATION); + jdbc.setTag(Tags.DB_POOL_NAME, DB_POOL); + jdbc.setTag(Tags.DB_USER, DB_USER); + jdbc.setTag(Tags.DB_INSTANCE, DB_INSTANCE); + jdbc.setTag(Tags.PEER_HOSTNAME, DB_HOST); + jdbc.setResourceName(STATEMENT); + jdbc.setTag(Tags.DB_OPERATION, DB_OPERATION); + jdbc.setTag(Tags.COMPONENT, JDBC_PREPARED_STATEMENT); + } + + // ---- normal: what this version's decorators do ---- + + @Benchmark + public void normal() { + // HttpServerDecorator: startSpan, afterStart, onRequest, then onResponseStatus at the end + AgentSpan server = tracer.startSpan(SERVER_INSTRUMENTATION, SERVER_OPERATION); + server.applyOverwriting(serverProto); // BaseDecorator.afterStart + server.setTag(Tags.HTTP_USER_AGENT, USER_AGENT); + server.setTag(Tags.HTTP_METHOD, METHOD); + server.setTag(Tags.HTTP_URL, URL); + server.setTag(Tags.HTTP_HOSTNAME, HOSTNAME); + server.setResourceName(SERVER_RESOURCE); // HttpResourceDecorator.withServerPath + server.setTag(Tags.PEER_HOST_IPV4, CLIENT_IP); + server.setTag(Tags.PEER_PORT, CLIENT_PORT); // setPeerPort + server.setTag(Tags.HTTP_ROUTE, ROUTE); // the framework, once routing resolves + try (ContextScope scope = tracer.activateSpan(server)) { + for (int i = 0; i < children; ++i) { + // AbstractPreparedStatementInstrumentation: startSpan, afterStart, onConnection, + // onPreparedStatement + AgentSpan jdbc = tracer.startSpan(JDBC_INSTRUMENTATION, JDBC_OPERATION); + jdbc.applyOverwriting(jdbcProto); + jdbc.setTag(Tags.DB_TYPE, DB_TYPE); // processDatabaseType + jdbc.setServiceName(DB_SERVICE, JAVA_JDBC); // postProcessServiceAndOperationName + jdbc.setOperationName(JDBC_RENAMED_OPERATION); + jdbc.setTag(Tags.DB_POOL_NAME, DB_POOL); + jdbc.setTag(Tags.DB_USER, DB_USER); // DatabaseClientDecorator.onConnection + jdbc.setTag(Tags.DB_INSTANCE, DB_INSTANCE); + jdbc.setTag(Tags.PEER_HOSTNAME, DB_HOST); + jdbc.setResourceName(STATEMENT); // withQueryInfo + jdbc.setTag(Tags.DB_OPERATION, DB_OPERATION); + jdbc.spanContext().setIntegrationName(JDBC_PREPARED_STATEMENT); + jdbc.setTag(Tags.COMPONENT, JDBC_PREPARED_STATEMENT); + finishScoped(jdbc); + } + AgentSpan internal = tracer.startSpan(TRACE_INSTRUMENTATION, TRACE_OPERATION); + internal.applyOverwriting(traceProto); + internal.setResourceName(TRACE_RESOURCE); + finishScoped(internal); + } + server.setHttpStatusCode(STATUS); // onResponseStatus + server.finish(); + } + + // ---- newApi: construction from a prototype, tags by id ---- + + @Benchmark + public void newApi() { + AgentSpan server = tracer.startSpan(serverProto, SERVER_OPERATION); + server.setTag(KnownTags.HTTP_USERAGENT_ID, USER_AGENT); + server.setTag(KnownTags.HTTP_METHOD_ID, METHOD); + server.setTag(KnownTags.HTTP_URL_ID, URL); + server.setTag(KnownTags.HTTP_HOSTNAME_ID, HOSTNAME); + server.setResourceName(SERVER_RESOURCE); + server.setTag(KnownTags.PEER_IPV4_ID, CLIENT_IP); + server.setTag(KnownTags.PEER_PORT_ID, CLIENT_PORT); + server.setTag(KnownTags.HTTP_ROUTE_ID, ROUTE); + try (ContextScope scope = tracer.activateSpan(server)) { + for (int i = 0; i < children; ++i) { + AgentSpan jdbc = tracer.startSpan(jdbcProto, JDBC_OPERATION); + jdbc.setTag(KnownTags.DB_TYPE_ID, DB_TYPE); + jdbc.setServiceName(DB_SERVICE, JAVA_JDBC); + jdbc.setOperationName(JDBC_RENAMED_OPERATION); + jdbc.setTag(KnownTags.DB_POOL_NAME_ID, DB_POOL); + jdbc.setTag(KnownTags.DB_USER_ID, DB_USER); + jdbc.setTag(KnownTags.DB_INSTANCE_ID, DB_INSTANCE); + jdbc.setTag(KnownTags.PEER_HOSTNAME_ID, DB_HOST); + jdbc.setResourceName(STATEMENT); + jdbc.setTag(KnownTags.DB_OPERATION_ID, DB_OPERATION); + jdbc.spanContext().setIntegrationName(JDBC_PREPARED_STATEMENT); + jdbc.setTag(KnownTags.COMPONENT_ID, JDBC_PREPARED_STATEMENT); + finishScoped(jdbc); + } + AgentSpan internal = tracer.startSpan(traceProto, TRACE_OPERATION); + internal.setResourceName(TRACE_RESOURCE); + finishScoped(internal); + } + server.setHttpStatusCode(STATUS); + server.finish(); + } + + /** + * {@link DropWriter}, except that it first records each span's dense-store size, as serialization + * does for a published trace. Without it the per-operation sizing hints never learn here, and + * every span would be presized to the initial hint and then grow. + */ + static final class SizeRecordingWriter implements Writer { + private final Blackhole blackhole; + + SizeRecordingWriter(Blackhole blackhole) { + this.blackhole = blackhole; + } + + @Override + public void write(List trace) { + for (DDSpan span : trace) { + span.spanContext().recordDenseSize(); + } + blackhole.consume(trace); + } + + @Override + public void start() {} + + @Override + public boolean flush() { + return true; + } + + @Override + public void close() {} + + @Override + public void incrementDropCounts(int spanCount) {} + } + + /** The advice's exit: activate around the call, then close and finish. */ + void finishScoped(AgentSpan span) { + ContextScope scope = tracer.activateSpan(span); + scope.close(); + span.finish(); + } +} diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/TracerMapperMap.java b/dd-trace-core/src/jmh/java/datadog/trace/core/TracerMapperMap.java index 4075274cca2..0e9f4c57ac2 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/TracerMapperMap.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TracerMapperMap.java @@ -135,7 +135,6 @@ private DDSpan createSpanWithOrigin(int iter, final String origin) { Collections.emptyMap(), false, "type", - 0, traceCollector, null, null, diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/propagation/InjectorBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/propagation/InjectorBenchmark.java index 1c070167377..b06ce7b3558 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/propagation/InjectorBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/propagation/InjectorBenchmark.java @@ -122,7 +122,6 @@ public void setUp(Blackhole blackhole) { Collections.emptyMap(), false, "type", - 0, tracer.createTraceCollector(traceId), null, null, 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 2f10cceba4b..4597e0f218a 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 @@ -42,6 +42,7 @@ import datadog.trace.api.IdGenerationStrategy; import datadog.trace.api.InstrumenterConfig; import datadog.trace.api.Pair; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; import datadog.trace.api.TraceConfig; import datadog.trace.api.civisibility.config.BazelMode; @@ -207,10 +208,43 @@ private static final class LocalRootSpanTags { final TagMap tags; final boolean needsIntercept; + /** The last {@link #traceLevelParent}, with the merged tracer tags it was built over. */ + private volatile TraceLevelTags traceLevel; + LocalRootSpanTags(final TagMap tags, final boolean needsIntercept) { this.tags = tags; this.needsIntercept = needsIntercept; } + + /** + * All of a root span's trace-level tags in one frozen map -- these tags over {@code + * mergedTracerTags} -- for the root span to read through instead of copying them in. The tag + * registry colors trace-level tags separately, so they reuse span-level slot bits; keeping them + * out of the span's own store keeps its slot bits exact. Built once per config snapshot; a racy + * rebuild is harmless, since it builds the same map. + */ + TagMap traceLevelParent(final TagMap mergedTracerTags) { + TraceLevelTags cached = this.traceLevel; + if (cached != null && cached.mergedTracerTags == mergedTracerTags) { + return cached.parent; + } + TagMap parent = TagMap.create(); + parent.putAll(mergedTracerTags); + parent.putAll(this.tags); + parent.freeze(); + this.traceLevel = new TraceLevelTags(mergedTracerTags, parent); + return parent; + } + } + + private static final class TraceLevelTags { + final TagMap mergedTracerTags; + final TagMap parent; + + TraceLevelTags(final TagMap mergedTracerTags, final TagMap parent) { + this.mergedTracerTags = mergedTracerTags; + this.parent = parent; + } } private volatile LocalRootSpanTags localRootSpanTags; @@ -1128,8 +1162,26 @@ public CoreSpanBuilder buildSpan( if (operationName == null) { operationName = prototype.operationName(); } - CoreSpanBuilder builder = - createMultiSpanBuilder(prototype.instrumentationName(), operationName); + return seedFrom( + createMultiSpanBuilder(prototype.instrumentationName(), operationName), prototype); + } + + /** + * Like {@link #buildSpan(SpanPrototype, CharSequence)}, for one span only: the thread's reusable + * builder carries the prototype (see {@link #singleSpanBuilder(String, CharSequence)}), which it + * drops when reset for the next span. + */ + @Override + public CoreSpanBuilder singleSpanBuilder( + @Nonnull final SpanPrototype prototype, CharSequence operationName) { + if (operationName == null) { + operationName = prototype.operationName(); + } + return seedFrom(singleSpanBuilder(prototype.instrumentationName(), operationName), prototype); + } + + /** Seeds {@code builder}'s prototype and span type; its tags are seeded when the span starts. */ + private static CoreSpanBuilder seedFrom(CoreSpanBuilder builder, SpanPrototype prototype) { builder.spanPrototype = prototype; if (prototype.spanType() != null) { builder.spanType = prototype.spanType(); @@ -1410,8 +1462,9 @@ void write(final SpanList trace) { writtenTrace.get(i).setTag(APM_ENABLED, 0); } } - writer.write(writtenTrace); + writer.write(writtenTrace); // serialization records these spans' sizes } else { + recordDenseSizes(writtenTrace); // with span streaming this won't work - it needs to be changed // to track an effective sampling rate instead, however, tests // checking that a hard reference on a continuation prevents @@ -1423,6 +1476,19 @@ void write(final SpanList trace) { } } + /** + * Tunes each span's per-operation sizing hint, for a trace that is dropped rather than written: + * published spans are recorded when they're serialized, with every tag present. Done here rather + * than at span finish: the early post-processors and the root's sampling decision add tags after + * a span finishes, and a hint that missed them would leave every later span of the operation + * growing its store. + */ + private static void recordDenseSizes(List trace) { + for (int i = 0; i < trace.size(); i++) { + trace.get(i).spanContext().recordDenseSize(); + } + } + private List interceptCompleteTrace(SpanList originalTrace) { return interceptCompleteTrace(interceptors, originalTrace); } @@ -2130,6 +2196,7 @@ protected static final DDSpanContext buildSpanContext( final CharSequence origin; final TagMap coreTags; final boolean coreTagsNeedsIntercept; + final LocalRootSpanTags localRootSpanTags; final TagMap rootSpanTags; final boolean rootSpanTagsNeedsIntercept; final DDSpanContext context; @@ -2159,6 +2226,7 @@ protected static final DDSpanContext buildSpanContext( origin = null; coreTags = null; coreTagsNeedsIntercept = false; + localRootSpanTags = null; rootSpanTags = null; rootSpanTagsNeedsIntercept = false; parentServiceName = ddsc.getServiceName(); @@ -2233,7 +2301,7 @@ protected static final DDSpanContext buildSpanContext( ciVisibilityContextData = null; } - LocalRootSpanTags localRootSpanTags = tracer.localRootSpanTags; + localRootSpanTags = tracer.localRootSpanTags; rootSpanTags = localRootSpanTags.tags; rootSpanTagsNeedsIntercept = localRootSpanTags.needsIntercept; @@ -2312,12 +2380,22 @@ protected static final DDSpanContext buildSpanContext( final TagMap mergedTracerTags = traceConfig.mergedTracerTags; boolean mergedTracerTagsNeedsIntercept = traceConfig.mergedTracerTagsNeedsIntercept; - final int tagsSize = - mergedTracerTags.size() - + (null == tagLedger ? 0 : tagLedger.estimateSize()) - + (null == coreTags ? 0 : coreTags.size()) - + (null == rootSpanTags ? 0 : rootSpanTags.size()) - + (null == contextualTags ? 0 : contextualTags.size()); + // With builder tags taking precedence, a root span reads its trace-level tags (root-span tags + // over the merged tracer tags) through one shared frozen parent instead of copying the + // root-span tags in. They become the precedence floor: the flag's order already puts them + // below builder and contextual tags, and now inbound header tags and prototype defaults also + // win over them. Tags needing interception are still copied in. + final boolean rootSpanTagsReadThrough = + localRootSpanTags != null + && tracer.builderTagsPrecedence + && !rootSpanTagsNeedsIntercept + && !mergedTracerTagsNeedsIntercept; + final TagMap tagParent; + if (rootSpanTagsReadThrough) { + tagParent = localRootSpanTags.traceLevelParent(mergedTracerTags); + } else { + tagParent = mergedTracerTagsNeedsIntercept ? null : mergedTracerTags; + } if (builderRequestContextDataAppSec != null) { requestContextDataAppSec = builderRequestContextDataAppSec; @@ -2329,6 +2407,15 @@ protected static final DDSpanContext buildSpanContext( requestContextDataIast = builderRequestContextDataIast; } + // Per-operation dense-store sizing: an entry (local-root) span carries tags a child doesn't, + // so the lane is chosen by whether there's a local parent. A span using its prototype's own + // operation name gets the hint cached on the prototype; any other span (including NONE's, + // which has no operation name) gets its operation's hint from the table. Null with the dense + // store off -- the flag test folds, so the default path does no lookup. + final SizingHint sizingHint = + spanPrototype.sizingHint( + !(resolvedParentSpanContext instanceof DDSpanContext), operationName); + // some attributes are inherited from the parent context = new DDSpanContext( @@ -2346,7 +2433,6 @@ protected static final DDSpanContext buildSpanContext( w3cBaggage, errorFlag, spanType, - tagsSize, parentTraceCollector, requestContextDataAppSec, requestContextDataIast, @@ -2357,7 +2443,8 @@ protected static final DDSpanContext buildSpanContext( tracer.profilingContextIntegration, tracer.injectBaggageAsTags, tracer.injectLinksAsTags, - mergedTracerTagsNeedsIntercept ? null : mergedTracerTags); + tagParent, + sizingHint); // By setting the tags on the context we apply decorators to any tags that have been set via // the builder. The `mergedTracerTags` are always applied first (the precedence floor: @@ -2382,7 +2469,9 @@ protected static final DDSpanContext buildSpanContext( } if (tracer.builderTagsPrecedence) { context.setAllTags(coreTags, coreTagsNeedsIntercept); - context.setAllTags(rootSpanTags, rootSpanTagsNeedsIntercept); + if (!rootSpanTagsReadThrough) { + context.setAllTags(rootSpanTags, rootSpanTagsNeedsIntercept); + } context.setAllTags(contextualTags); context.setAllTags(tagLedger); } else { diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java index e15a7ed49cb..41bd614d1f3 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java @@ -22,6 +22,7 @@ import datadog.trace.api.TagMap; import datadog.trace.api.TraceConfig; import datadog.trace.api.debugger.DebuggerConfigBridge; +import datadog.trace.api.function.StrategyConsumer; import datadog.trace.api.gateway.Flow; import datadog.trace.api.gateway.RequestContext; import datadog.trace.api.internal.VisibleForTesting; @@ -34,9 +35,12 @@ import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink; import datadog.trace.bootstrap.instrumentation.api.AttachableWrapper; import datadog.trace.bootstrap.instrumentation.api.ErrorPriorities; +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; import datadog.trace.bootstrap.instrumentation.api.ResourceNamePriorities; import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; import datadog.trace.bootstrap.instrumentation.api.SpanWrapper; +import datadog.trace.bootstrap.instrumentation.api.TagContributor; +import datadog.trace.bootstrap.instrumentation.api.TagExtractor; import datadog.trace.core.util.StackTraces; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; @@ -589,6 +593,38 @@ public DDSpan setTag(final long tagId, final Object value) { return this; } + /** + * Applies {@code extractor} holding the span's tag lock once for the whole call, instead of once + * per {@code setTag}; the extractor sees the span's usual tag semantics. Kept small so it inlines + * and each call site sees its exact extractor type. + */ + @Override + @StrategyConsumer + public DDSpan setTagsFrom(final T source, final TagExtractor extractor) { + if (source != null) { + context.setTagsFrom(source, extractor); + } + return this; + } + + /** Like {@link #setTagsFrom(Object, TagExtractor)}, for a {@link TagContributor}. */ + @Override + public DDSpan setTagsFrom(final TagContributor contributor) { + if (contributor != null) { + context.setTagsFrom(contributor); + } + return this; + } + + /** Applies frozen tags under the span's tag lock, copying them in at once where it can. */ + @Override + public DDSpan setTagsFrom(final FrozenTags frozen) { + if (frozen != null && !frozen.isEmpty()) { + context.setTagsFrom(frozen); + } + return this; + } + @Override public AgentSpan setAllTags(Map map) { context.setAllTags(map); 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 24b652e7593..535d0c0f2b0 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 @@ -15,14 +15,19 @@ import datadog.trace.api.DDTags; import datadog.trace.api.DDTraceId; import datadog.trace.api.Functions; +import datadog.trace.api.HashStore; +import datadog.trace.api.IdStore; import datadog.trace.api.KnownTagCodec; import datadog.trace.api.KnownTags; import datadog.trace.api.ProcessTags; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import datadog.trace.api.cache.DDCache; import datadog.trace.api.cache.DDCaches; import datadog.trace.api.config.TracerConfig; import datadog.trace.api.datastreams.PathwayContext; +import datadog.trace.api.function.NoEscape; import datadog.trace.api.gateway.BlockResponseFunction; import datadog.trace.api.gateway.RequestContext; import datadog.trace.api.gateway.RequestContextSlot; @@ -34,16 +39,22 @@ import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; import datadog.trace.bootstrap.instrumentation.api.Baggage; import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData; +import datadog.trace.bootstrap.instrumentation.api.ErrorPriorities; +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; import datadog.trace.bootstrap.instrumentation.api.ProfilerContext; import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration; import datadog.trace.bootstrap.instrumentation.api.ResourceNamePriorities; +import datadog.trace.bootstrap.instrumentation.api.SpanData; import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.bootstrap.instrumentation.api.TagContributor; +import datadog.trace.bootstrap.instrumentation.api.TagExtractor; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.core.propagation.PropagationTags; import datadog.trace.core.taginterceptor.TagInterceptor; import datadog.trace.core.tagprocessor.TagsPostProcessorFactory; import datadog.trace.util.TagsHelper; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.Closeable; import java.io.IOException; import java.util.Collections; @@ -56,6 +67,8 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.function.Function; import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.annotation.concurrent.GuardedBy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -68,7 +81,7 @@ * the associated Span instance */ public class DDSpanContext - implements AgentSpanContext, RequestContext, TraceSegment, ProfilerContext { + implements AgentSpanContext, RequestContext, TraceSegment, ProfilerContext, TagStore, SpanData { private static final Logger log = LoggerFactory.getLogger(DDSpanContext.class); public static final String PRIORITY_SAMPLING_KEY = "_sampling_priority_v1"; @@ -138,15 +151,51 @@ public class DDSpanContext private CharSequence serviceNameSource; /** - * Tags are associated to the current span, they will not propagate to the children span. + * The span's known tags, by id. Also the lock for all of the span's tag state. Tags belong to + * this span; they don't propagate to its children. * - *

The underlying assumption for using a normal Map with synchronized access instead of a - * ConcurrentHashMap is that even though the tags can be accessed and modified from multiple - * threads, they will rarely, if ever, be read and modified concurrently by multiple threads but - * rather read and accessed in a serial fashion on thread after thread. The synchronization can - * then be wrapped around bulk operations to minimize the costly atomic operations. + *

Storage. The span owns its tag stores directly. This store holds the known tags and + * is created with the span. {@link #customTags} holds the custom tags by name and is created on + * the first custom write. Until then it is null, or the read-through parent's custom store, + * borrowed read-only while {@link #customTagsBorrowed}. Every read and write is routed by name or + * id straight to one of the stores. Internal bulk readers (post-processors, serializers, {@link + * #getTags()}) go through the cached {@link #tagView()} instead. + * + *

Locking. One lock, this store itself, guards both stores, {@link #customTagsBorrowed} + * and {@link #tagView}. It is this store because it always exists, while {@link #customTags} may + * be null or borrowed. + * + *

    + *
  • The stores are unsynchronized; they rely on this lock. + *
  • Read-through parents are frozen, so reading through them needs no lock. + *
  • The lock is reentrant, and that matters: code that runs while it is held may call back + * into the span's setters. The tag interceptor does so from {@code setAllTags}, and + * post-processors do so through the view from {@code processTagsAndBaggage}. + *
  • {@link #unsafeGetTag} and {@code unsafeSetTag} skip the lock. Callers either hold it + * already or guarantee that nothing mutates the span concurrently. + *
+ * + *

A plain lock rather than concurrent structures: tags are rarely read and written by several + * threads at once. They are touched by one thread after another, so a lock around each operation, + * or around a bulk operation, costs less than atomic operations on every access. */ - private final TagMap unsafeTags; + @GuardedBy("knownTags") + private final IdStore knownTags; + + /** Custom tags by name: null, borrowed from the read-through parent, or the span's own. */ + @GuardedBy("knownTags") + private HashStore customTags; + + /** True while {@link #customTags} is the read-through parent's store, borrowed read-only. */ + @GuardedBy("knownTags") + @SuppressFBWarnings( + value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", + justification = "guarded by the knownTags lock, which every accessor's caller holds") + private boolean customTagsBorrowed; + + /** This span's view of its tag stores for its internal readers; see {@link #tagView()}. */ + @GuardedBy("knownTags") + private TagMap tagView; /** The service name is required, otherwise the span are dropped by the agent */ private volatile String serviceName; @@ -216,7 +265,6 @@ public DDSpanContext( final Map baggageItems, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -238,7 +286,6 @@ public DDSpanContext( null, errorFlag, spanType, - tagsSize, traceCollector, requestContextDataAppSec, requestContextDataIast, @@ -265,7 +312,6 @@ public DDSpanContext( final Map baggageItems, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -289,7 +335,6 @@ public DDSpanContext( null, errorFlag, spanType, - tagsSize, traceCollector, requestContextDataAppSec, requestContextDataIast, @@ -319,7 +364,6 @@ public DDSpanContext( final Baggage w3cBaggage, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -345,7 +389,6 @@ public DDSpanContext( w3cBaggage, errorFlag, spanType, - tagsSize, traceCollector, requestContextDataAppSec, requestContextDataIast, @@ -359,6 +402,10 @@ public DDSpanContext( null); } + /** + * Without a sizing hint: for tests and benchmarks that build contexts directly. The tracer uses + * the constructor that takes one. + */ public DDSpanContext( final DDTraceId traceId, final long spanId, @@ -374,7 +421,6 @@ public DDSpanContext( final Baggage w3cBaggage, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -386,6 +432,62 @@ public DDSpanContext( final boolean injectBaggageAsTags, final boolean injectLinksAsTags, final TagMap readThroughParent) { + this( + traceId, + spanId, + parentId, + parentServiceName, + serviceNameSource, + serviceName, + operationName, + resourceName, + samplingPriority, + origin, + baggageItems, + w3cBaggage, + errorFlag, + spanType, + traceCollector, + requestContextDataAppSec, + requestContextDataIast, + CiVisibilityContextData, + pathwayContext, + disableSamplingMechanismValidation, + propagationTags, + profilingContextIntegration, + injectBaggageAsTags, + injectLinksAsTags, + readThroughParent, + null); + } + + public DDSpanContext( + final DDTraceId traceId, + final long spanId, + final long parentId, + final CharSequence parentServiceName, + final CharSequence serviceNameSource, + final String serviceName, + final CharSequence operationName, + final CharSequence resourceName, + final int samplingPriority, + final CharSequence origin, + final Map baggageItems, + final Baggage w3cBaggage, + final boolean errorFlag, + final CharSequence spanType, + final TraceCollector traceCollector, + final Object requestContextDataAppSec, + final Object requestContextDataIast, + final Object CiVisibilityContextData, + final PathwayContext pathwayContext, + final boolean disableSamplingMechanismValidation, + final PropagationTags propagationTags, + final ProfilingContextIntegration profilingContextIntegration, + final boolean injectBaggageAsTags, + final boolean injectLinksAsTags, + final TagMap readThroughParent, + @Nullable final SizingHint sizingHint) { assert traceCollector != null; this.traceCollector = traceCollector; @@ -411,13 +513,11 @@ public DDSpanContext( assert pathwayContext != null; this.pathwayContext = pathwayContext; - // The +1 is the magic number from the tags below that we set at the end, - // and "* 4 / 3" is to make sure that we don't resize immediately - final int capacity = Math.max((tagsSize <= 0 ? 3 : (tagsSize + 1)) * 4 / 3, 8); - this.unsafeTags = - readThroughParent != null - ? TagMap.createFromParent(readThroughParent) - : TagMap.create(capacity); + // Reads through to the frozen tracer-level tags, if any. + TagMap parentTags = TagMap.readThroughParent(readThroughParent); + this.knownTags = IdStore.create(parentTags, sizingHint); + this.customTags = HashStore.inheritedFrom(parentTags); + this.customTagsBorrowed = this.customTags != null; // must set this before setting the service and resource names below this.profilingContextIntegration = profilingContextIntegration; @@ -582,10 +682,12 @@ public boolean isMeasured() { return measured; } - public void setMeasured(boolean measured) { + @Override + public DDSpanContext setMeasured(boolean measured) { if (measured != this.measured) { this.measured = measured; } + return this; } public boolean isTopLevel() { @@ -615,11 +717,8 @@ public void setSpanType(final CharSequence spanType) { * *

This is the construction-time seam ({@code CoreSpanBuilder}) -- see {@link * #applyOverwriting} for the decorator {@code afterStart} seam (via {@link - * DDSpan#applyOverwriting}), which needs different precedence. The context owns the tag map, so - * the eventual cheaper bulk-share path (skipping interception for non-intercepted tags) and the - * identity short-circuit will land here, with the dense store; a tag's intercept status is - * already on its id ({@link KnownTagCodec#isIntercepted}). Until then each constant tag is - * prechecked by the interceptor, identical to the per-tag calls this replaces. + * DDSpan#applyOverwriting}), which needs different precedence. The tags are seeded by {@link + * #seedPrototypeTags}, in bulk where the store and the prototype allow it. */ public void apply(@Nonnull final SpanPrototype prototype) { if (this.spanType == null) { @@ -628,7 +727,7 @@ public void apply(@Nonnull final SpanPrototype prototype) { setSpanType(spanType); } } - seedAbsentTags(prototype.tags()); + seedPrototypeTags(prototype); if (this.integrationName == null) { final CharSequence integrationName = prototype.integrationName(); if (integrationName != null) { @@ -649,7 +748,9 @@ public void applyOverwriting(@Nonnull final SpanPrototype prototype) { setSpanType(spanType); } final TagMap tags = prototype.tags(); - setAllTags(tags, true); + if (!overwriteInBulk(prototype)) { + setAllTags(tags, true); + } tagInterceptor.resolveSplitByTagsPrecedence(this, tags); final CharSequence integrationName = prototype.integrationName(); if (integrationName != null) { @@ -658,26 +759,171 @@ public void applyOverwriting(@Nonnull final SpanPrototype prototype) { } /** - * Seeds tags that are not already present, routed through the interceptor. Mirrors {@link - * #setAllTags(TagMap, boolean)}'s intercepting path but skips any key already set, so explicit - * tags keep precedence over the prototype's constant defaults. + * How a {@link SpanPrototype}'s or {@link FrozenTags}' tags are copied into a span, cached on the + * prototype or frozen tags for the interceptor it was checked against ({@link + * SpanPrototype#seedPlan}, {@link FrozenTags#applyPlan}). */ - private void seedAbsentTags(final TagMap map) { - if (map == null) { - return; + private static final class SeedPlan { + final TagInterceptor interceptor; + + /** + * The store can seed in bulk (the dense store) and no known tag needs the interceptor beyond + * span.kind (TagInterceptor#canSeedInBulk). False with the dense store off, so the default path + * never tries. + */ + final boolean bulk; + + /** The ordinal of the prototype's span.kind, or SPAN_KIND_UNSET. */ + final byte kindOrdinal; + + SeedPlan(TagInterceptor interceptor, boolean bulk, byte kindOrdinal) { + this.interceptor = interceptor; + this.bulk = bulk; + this.kindOrdinal = kindOrdinal; + } + } + + private SeedPlan seedPlanFor(final SpanPrototype prototype) { + Object cached = prototype.seedPlan(); + if (isCurrentPlan(cached)) { + return (SeedPlan) cached; + } + SeedPlan plan = newSeedPlan(prototype.tags()); + prototype.seedPlan(plan); + return plan; + } + + private SeedPlan seedPlanFor(final FrozenTags frozen) { + Object cached = frozen.applyPlan(); + if (isCurrentPlan(cached)) { + return (SeedPlan) cached; + } + TagMap tags = frozen.tags(); + SeedPlan plan = newSeedPlan(tags); + if (plan.bulk && (frozen.isReplayOnly() || anyCustomTagIntercepted(tags))) { + // copying the net result in wouldn't be the same as the writes: replay them instead + plan = new SeedPlan(tagInterceptor, false, plan.kindOrdinal); + } + frozen.applyPlan(plan); + return plan; + } + + /** + * Whether split-by-tags routes any of {@code tags}' custom tags. canSeedInBulk leaves custom tags + * out, since a prototype seeds them one at a time; frozen tags in bulk set them in no particular + * order, which only matters when they're routed, the last routed write deciding the service. + */ + private boolean anyCustomTagIntercepted(final TagMap tags) { + HashStore custom = tags.customStore(); + if (custom == null) { + return false; + } + for (final TagMap.EntryReader tagEntry : tags) { + if (tagEntry.tagId() == 0 && tagInterceptor.needsInterceptCustomTag(tagEntry.tag())) { + return true; + } + } + return false; + } + + private boolean isCurrentPlan(final Object cached) { + return cached instanceof SeedPlan && ((SeedPlan) cached).interceptor == tagInterceptor; + } + + private SeedPlan newSeedPlan(final TagMap tags) { + TagMap.Entry kind = tags.getEntry(KnownTags.SPAN_KIND_ID); + return new SeedPlan( + tagInterceptor, + tags.knownStore().supportsBulkSeed() && tagInterceptor.canSeedInBulk(tags), + kind == null ? SPAN_KIND_UNSET : spanKindOrdinalOf(kind.stringValue())); + } + + /** + * Seeds a prototype's tags where the span doesn't already have them, so explicit tags keep + * precedence over the prototype's constant defaults. Two paths: + * + *

    + *
  • Bulk, when no known tag needs the interceptor beyond span.kind and none of them + * can already be present: the known tags are copied in at once ({@link + * IdStore#seedAbsent}), and span.kind's one effect, the cached kind ordinal, is set + * directly. + *
  • Tag by tag otherwise, through the interceptor, mirroring {@link + * #setAllTags(TagMap, boolean)}'s intercepting path but skipping any tag already set. + *
+ * + * Custom tags always go tag by tag. + */ + private void seedPrototypeTags(final SpanPrototype prototype) { + final TagMap tags = prototype.tags(); + final SeedPlan plan = seedPlanFor(prototype); + synchronized (knownTags) { + if (seedKnownInBulk(plan, tags)) { + HashStore custom = tags.customStore(); + if (custom != null) { + // rare: a prototype with custom tags seeds those one at a time + custom.forEach(this, (ctx, tagEntry) -> ctx.seedIfAbsent(tagEntry)); + } + return; + } + tags.forEach(this, (ctx, tagEntry) -> ctx.seedIfAbsent(tagEntry)); + } + } + + /** + * The bulk step shared by {@link #seedPrototypeTags} and {@link #overwriteInBulk}: when the plan + * allows it and the masks prove none of the prototype's known tags can already be on the span, + * copies them in at once ({@link IdStore#seedAbsent}) and sets span.kind's ordinal directly, and + * returns true. Otherwise changes nothing and returns false. Call under the {@code knownTags} + * lock; custom tags are the caller's. + */ + private boolean seedKnownInBulk(final SeedPlan plan, final TagMap tags) { + if (!plan.bulk || !knownTags.seedAbsent(tags.knownStore())) { + return false; + } + if (plan.kindOrdinal != SPAN_KIND_UNSET) { + spanKindOrdinal = plan.kindOrdinal; + } + return true; + } + + /** + * The overwriting counterpart of {@link #seedPrototypeTags}'s bulk path: when the masks prove + * none of the prototype's known tags is on the span, overwriting them is just appending them, so + * they are copied in at once ({@link IdStore#seedAbsent}) and span.kind's ordinal set directly. + * Custom tags go through the interceptor one at a time. Returns false, having changed nothing, + * when the prototype needs the interceptor or a tag may already be present; the caller then + * overwrites tag by tag. + */ + private boolean overwriteInBulk(final SpanPrototype prototype) { + final SeedPlan plan = seedPlanFor(prototype); + if (!plan.bulk) { + return false; // checked before taking the lock: the caller locks again on its fallback } - synchronized (unsafeTags) { - map.forEach( - this, - (ctx, tagEntry) -> { - final String tag = tagEntry.tag(); - if (ctx.unsafeTags.containsKey(tag)) { - return; - } - if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.unsafeTags.set(tagEntry); - } - }); + final TagMap tags = prototype.tags(); + synchronized (knownTags) { + if (!seedKnownInBulk(plan, tags)) { + return false; + } + HashStore custom = tags.customStore(); + if (custom != null) { + // rare: a prototype with custom tags overwrites those one at a time + custom.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); + } + return true; + } + } + + /** Seeds one tag if the span doesn't have it yet. Call under the {@code knownTags} lock. */ + private void seedIfAbsent(final TagMap.EntryReader tagEntry) { + if (!isPresent(tagEntry)) { + putIntercepted(tagEntry); + } + } + + /** Puts one tag, unless the interceptor routes it. Call under the {@code knownTags} lock. */ + private void putIntercepted(final TagMap.EntryReader tagEntry) { + if (!tagInterceptor.interceptTag(this, tagEntry)) { + routePut(tagEntry); } } @@ -800,7 +1046,7 @@ public int getSamplingPriority() { } public void setSpanSamplingPriority(double rate, int limit) { - synchronized (unsafeTags) { + synchronized (knownTags) { unsafeSetTag(SPAN_SAMPLING_MECHANISM_TAG, SamplingMechanism.SPAN_SAMPLING_RATE); unsafeSetTag(SPAN_SAMPLING_RULE_RATE_TAG, rate); if (limit != Integer.MAX_VALUE) { @@ -929,6 +1175,139 @@ public void setSpanKindOrdinal(String kind) { spanKindOrdinal = spanKindOrdinalOf(kind); } + /** + * This span's cached view of its tag stores, for its internal bulk readers (post-processors, the + * serializer, the snapshot copy) -- never its own set/get paths. The view keeps the stores it was + * created over: {@link #customTagsForWrite} drops it when the span creates its own custom store. + * Bulk readers run late in a span's life, after its custom writes, so that is rare. Call under + * the {@code knownTags} lock. + */ + private TagMap tagView() { + TagMap view = this.tagView; + if (view == null) { + // a view handed to the post-processor chain escapes, so allocate it once per span + this.tagView = view = TagMap.viewOf(knownTags, customTags, customTagsBorrowed); + } + return view; + } + + /** + * Adopts the custom store a reader may have created through the view (by writing a custom tag), + * so the span's own paths see it. Call under the {@code knownTags} lock, after using the view. + */ + private void adoptViewStores(TagMap view) { + // Skipped when the span dropped this view during its use: a callback into a span setter + // created the span's own custom store, which must not be replaced by the view's older one. + if (view == this.tagView) { + this.customTags = view.customStore(); + this.customTagsBorrowed = view.isCustomStoreBorrowed(); + } + } + + /** + * The custom store to write to: created on the first custom write, ending any borrowing. A cached + * view still holds the old store, so it is dropped. + */ + private HashStore customTagsForWrite() { + HashStore custom = this.customTags; + if (custom == null || this.customTagsBorrowed) { + custom = HashStore.create(custom); // reads through to the store borrowed till now + this.customTags = custom; + this.customTagsBorrowed = false; + this.tagView = null; + } + return custom; + } + + /** + * Removes the custom tag {@code name}: hiding an inherited one needs a store of the span's own. + */ + private void removeCustomTag(String name) { + HashStore custom = this.customTags; + if (custom == null) { + return; + } + if (this.customTagsBorrowed) { + if (custom.getEntry(name) == null) { + return; // nothing to hide: keep borrowing + } + custom = customTagsForWrite(); + } + custom.remove(name); + } + + /** Removes {@code tag}, known or custom. Call under the {@code knownTags} lock. */ + private void routeRemoval(String tag) { + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + knownTags.remove(key); + } else { + removeCustomTag(tag); + } + } + + /** + * Copies {@code map}'s tags into the stores, sharing its Entry objects where a store keeps them. + */ + private void routePutAll(TagMap map) { + // straight into the stores: a view created this early would likely be dropped by a later + // custom write + map.forEach(this, (ctx, entryReader) -> ctx.routePut(entryReader)); + } + + /* + * The route* operations send a tag to the store that holds it: the known store by id, else the + * custom store by name. Call them while holding the knownTags lock. + */ + + private void routeSet(long key, String tag, Object value) { + if (key != 0) { + knownTags.store(key, value); + } else { + customTagsForWrite().store(tag, value); + } + } + + private void routeSet(long key, String tag, CharSequence value) { + if (key != 0) { + knownTags.store(key, value); + } else { + customTagsForWrite().store(tag, value); + } + } + + private void routeSet(long key, String tag, int value) { + if (key != 0) { + knownTags.store(key, value); + } else { + customTagsForWrite().store(tag, value); + } + } + + private void routeSet(long key, String tag, long value) { + if (key != 0) { + knownTags.store(key, value); + } else { + customTagsForWrite().store(tag, value); + } + } + + private void routeSet(long key, String tag, float value) { + if (key != 0) { + knownTags.store(key, value); + } else { + customTagsForWrite().store(tag, value); + } + } + + private void routeSet(long key, String tag, double value) { + if (key != 0) { + knownTags.store(key, value); + } else { + customTagsForWrite().store(tag, value); + } + } + static byte spanKindOrdinalOf(String kind) { if (kind == null) { return SPAN_KIND_UNSET; @@ -960,8 +1339,9 @@ public String getSpanKindString() { return SPAN_KIND_VALUES[ordinal]; } // UNSET or CUSTOM -- fall through to tag map - synchronized (unsafeTags) { - return unsafeTags.getString(Tags.SPAN_KIND); + synchronized (knownTags) { + TagMap.EntryReader kind = knownTags.getEntryReader(KnownTags.SPAN_KIND_ID); + return kind == null ? null : kind.stringValue(); } } @@ -972,32 +1352,32 @@ public void setOrigin(final CharSequence origin) { } public void setMetric(final CharSequence key, final Number value) { - synchronized (unsafeTags) { + synchronized (knownTags) { unsafeSetTag(key.toString(), value); } } public void setMetric(final CharSequence key, final int value) { - synchronized (unsafeTags) { - unsafeTags.set(key.toString(), value); + synchronized (knownTags) { + routeSet(key.toString(), value); } } public void setMetric(final CharSequence key, final long value) { - synchronized (unsafeTags) { - unsafeTags.set(key.toString(), value); + synchronized (knownTags) { + routeSet(key.toString(), value); } } public void setMetric(final CharSequence key, final float value) { - synchronized (unsafeTags) { - unsafeTags.set(key.toString(), value); + synchronized (knownTags) { + routeSet(key.toString(), value); } } public void setMetric(final CharSequence key, final double value) { - synchronized (unsafeTags) { - unsafeTags.set(key.toString(), value); + synchronized (knownTags) { + routeSet(key.toString(), value); } } @@ -1006,24 +1386,64 @@ public void setMetric(final TagMap.EntryReader entry) { return; } - synchronized (unsafeTags) { - unsafeTags.set(entry); + synchronized (knownTags) { + routePut(entry); + } + } + + /* + * Route a tag that interception let through, by name: resolved once to the store that holds it. + */ + + private void routeSet(String tag, Object value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + private void routeSet(String tag, CharSequence value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + private void routeSet(String tag, int value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + private void routeSet(String tag, long value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + private void routeSet(String tag, float value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + private void routeSet(String tag, double value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + /** Stores {@code entry}, sharing its Entry where the store keeps Entry objects. */ + private void routePut(TagMap.EntryReader entryReader) { + if (entryReader.tagId() != 0) { + knownTags.put(entryReader); // no Entry built for a dense store, or for a flyweight from one + } else { + TagMap.Entry entry = entryReader.entry(); + if (entry != null) { // a reader with no entry is a no-op, as for TagMap.set + customTagsForWrite().put(entry); + } } } public void removeTag(String tag) { if (tagEquals(tag, Tags.SPAN_KIND)) { - // Clear the cached ordinal; unsafeTags still needs to be updated below. + // Clear the cached ordinal; the tag store still needs to be updated below. spanKindOrdinal = SPAN_KIND_UNSET; } - synchronized (unsafeTags) { - unsafeTags.remove(tag); + synchronized (knownTags) { + routeRemoval(tag); } } /** - * Removes a known tag by id rather than by its name. An id the id-keyed setters ignore is ignored - * here too. + * Removes a known tag by id rather than by its name. An id that names no known tag is ignored, + * like the id-keyed setters do. * * @param tagId a {@code KnownTags.*_ID} constant */ @@ -1034,11 +1454,21 @@ public void removeTag(long tagId) { if (tagId == KnownTags.SPAN_KIND_ID) { spanKindOrdinal = SPAN_KIND_UNSET; } - synchronized (unsafeTags) { - unsafeTags.getAndRemove(tagId); + synchronized (knownTags) { + knownTags.remove(tagId); } } + /** Whether {@code entry}'s tag is already on this span. */ + private boolean isPresent(TagMap.EntryReader entry) { + long key = entry.tagId(); // the reader already carries its id: no name lookup + if (key != 0) { + return knownTags.load(key) != null; + } + HashStore custom = customTags; + return custom != null && custom.getEntry(entry.tag()) != null; + } + /** * Sets a tag to the span. Tags are not propagated to the children. * @@ -1051,9 +1481,10 @@ public void removeTag(long tagId) { * @param tag The tag name. * @param value The nullable tag value. */ - public void setTag(final String tag, final Object value) { + @Override + public DDSpanContext setTag(final String tag, final Object value) { if (null == tag) { - return; + return this; } final long tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { @@ -1061,15 +1492,17 @@ public void setTag(final String tag, final Object value) { } else if (null == value) { removeTag(tag); } else if (!tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().store(tag, value); // already known to be custom } } + return this; } - public void setTag(final String tag, final String value) { + @Override + public DDSpanContext setTag(final String tag, final String value) { if (null == tag) { - return; + return this; } final long tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { @@ -1077,10 +1510,11 @@ public void setTag(final String tag, final String value) { } else if (null == value) { removeTag(tag); } else if (!tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().store(tag, value); // already known to be custom } } + return this; } /* @@ -1088,110 +1522,128 @@ public void setTag(final String tag, final String value) { * KnownTagCodec.INTERCEPTED test folds, leaving only the split-by-tags table load; an id set never * takes the custom-tag path. An id that names no known tag is ignored, like a null tag. */ - public void setTag(final long tagId, final Object value) { + @Override + public DDSpanContext setTag(final long tagId, final Object value) { if (!KnownTagCodec.isKnown(tagId)) { - return; + return this; } if (null == value) { removeTag(tagId); - } else if (!tagInterceptor.needsIntercept(tagId) - || !tagInterceptor.interceptTag(this, tagId, value)) { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + return this; + } + if (!tagInterceptor.needsIntercept(tagId) || !tagInterceptor.interceptTag(this, tagId, value)) { + synchronized (knownTags) { + knownTags.store(tagId, value); } } + return this; } - public void setTag(final long tagId, final CharSequence value) { + @Override + public DDSpanContext setTag(final long tagId, final CharSequence value) { if (!KnownTagCodec.isKnown(tagId)) { - return; + return this; } if (null == value) { removeTag(tagId); - } else if (!tagInterceptor.needsIntercept(tagId) - || !tagInterceptor.interceptTag(this, tagId, value)) { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + return this; + } + if (!tagInterceptor.needsIntercept(tagId) || !tagInterceptor.interceptTag(this, tagId, value)) { + synchronized (knownTags) { + knownTags.store(tagId, value); } } + return this; } - public void setTag(final long tagId, final boolean value) { + @Override + public DDSpanContext setTag(final long tagId, final boolean value) { if (!KnownTagCodec.isKnown(tagId)) { - return; + return this; } if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.store(tagId, value); } } + return this; } - public void setTag(final long tagId, final int value) { + @Override + public DDSpanContext setTag(final long tagId, final int value) { if (!KnownTagCodec.isKnown(tagId)) { - return; + return this; } if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.store(tagId, value); } } + return this; } - public void setTag(final long tagId, final long value) { + @Override + public DDSpanContext setTag(final long tagId, final long value) { if (!KnownTagCodec.isKnown(tagId)) { - return; + return this; } if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.store(tagId, value); } } + return this; } - public void setTag(final long tagId, final float value) { + @Override + public DDSpanContext setTag(final long tagId, final float value) { if (!KnownTagCodec.isKnown(tagId)) { - return; + return this; } if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.store(tagId, value); } } + return this; } - public void setTag(final long tagId, final double value) { + @Override + public DDSpanContext setTag(final long tagId, final double value) { if (!KnownTagCodec.isKnown(tagId)) { - return; + return this; } if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.store(tagId, value); } } + return this; } - public void setTag(TagMap.EntryReader entry) { + @Override + public DDSpanContext setTag(TagMap.EntryReader entry) { if (entry == null) { - return; + return this; } // the interceptor prechecks the entry, to avoid boxing if (!tagInterceptor.interceptTag(this, entry)) { - synchronized (unsafeTags) { - unsafeTags.set(entry); + synchronized (knownTags) { + routePut(entry); } } + return this; } /* @@ -1208,110 +1660,547 @@ public void setTag(TagMap.EntryReader entry) { */ private void setBox(long tagId, Object box) { if (!tagInterceptor.interceptTag(this, tagId, box)) { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, box); + synchronized (knownTags) { + knownTags.store(tagId, box); } } } - public void setTag(final String tag, final boolean value) { + @Override + public DDSpanContext setTag(final String tag, final boolean value) { if (null == tag) { - return; + return this; } final long tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().store(tag, value); // already known to be custom } } + return this; } - public void setTag(final String tag, final int value) { + @Override + public DDSpanContext setTag(final String tag, final int value) { if (null == tag) { - return; + return this; } final long tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().store(tag, value); // already known to be custom } } + return this; } - public void setTag(final String tag, final long value) { + @Override + public DDSpanContext setTag(final String tag, final long value) { if (null == tag) { - return; + return this; } final long tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().store(tag, value); // already known to be custom } } + return this; } - public void setTag(final String tag, final float value) { + @Override + public DDSpanContext setTag(final String tag, final float value) { if (null == tag) { - return; + return this; } final long tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().store(tag, value); // already known to be custom } } + return this; } - public void setTag(final String tag, final double value) { + @Override + public DDSpanContext setTag(final String tag, final double value) { if (null == tag) { - return; + return this; } final long tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().store(tag, value); // already known to be custom } } + return this; } - void setAllTags(final TagMap map) { - setAllTags(map, true); - } + /* + * The *HoldingLock variants: the same tag semantics as setTag / removeTag / getTag (a null value + * removes, the tag interceptor runs), for a caller that already holds the knownTags lock, so a + * batch takes the lock once. Only HeldSpanData calls them, during setTagsFrom. + */ - void setAllTags(final TagMap map, boolean needsIntercept) { + void setTagHoldingLock(final String tag, final Object value) { + if (null == tag) { + return; + } + final long tagId = KnownTagCodec.keyOf(tag); + if (tagId != 0) { + setTagHoldingLock(tagId, value); + } else if (null == value) { + removeTagHoldingLock(tag); + } else if (!tagInterceptor.interceptCustomTag(this, tag, value)) { + customTagsForWrite().store(tag, value); // already known to be custom + } + } + + void setTagHoldingLock(final String tag, final String value) { + if (null == tag) { + return; + } + final long tagId = KnownTagCodec.keyOf(tag); + if (tagId != 0) { + setTagHoldingLock(tagId, value); + } else if (null == value) { + removeTagHoldingLock(tag); + } else if (!tagInterceptor.interceptCustomTag(this, tag, value)) { + customTagsForWrite().store(tag, value); // already known to be custom + } + } + + void setTagHoldingLock(final long tagId, final Object value) { + if (!KnownTagCodec.isKnown(tagId)) { + return; + } + if (null == value) { + removeTagHoldingLock(tagId); + return; + } + if (!tagInterceptor.needsIntercept(tagId) || !tagInterceptor.interceptTag(this, tagId, value)) { + knownTags.store(tagId, value); + } + } + + void setTagHoldingLock(final long tagId, final CharSequence value) { + if (!KnownTagCodec.isKnown(tagId)) { + return; + } + if (null == value) { + removeTagHoldingLock(tagId); + return; + } + if (!tagInterceptor.needsIntercept(tagId) || !tagInterceptor.interceptTag(this, tagId, value)) { + knownTags.store(tagId, value); + } + } + + void setTagHoldingLock(final long tagId, final boolean value) { + if (!KnownTagCodec.isKnown(tagId)) { + return; + } + if (tagInterceptor.needsIntercept(tagId)) { + this.setBoxHoldingLock(tagId, value); + } else { + knownTags.store(tagId, value); + } + } + + void setTagHoldingLock(final long tagId, final int value) { + if (!KnownTagCodec.isKnown(tagId)) { + return; + } + if (tagInterceptor.needsIntercept(tagId)) { + this.setBoxHoldingLock(tagId, value); + } else { + knownTags.store(tagId, value); + } + } + + void setTagHoldingLock(final long tagId, final long value) { + if (!KnownTagCodec.isKnown(tagId)) { + return; + } + if (tagInterceptor.needsIntercept(tagId)) { + this.setBoxHoldingLock(tagId, value); + } else { + knownTags.store(tagId, value); + } + } + + void setTagHoldingLock(final long tagId, final float value) { + if (!KnownTagCodec.isKnown(tagId)) { + return; + } + if (tagInterceptor.needsIntercept(tagId)) { + this.setBoxHoldingLock(tagId, value); + } else { + knownTags.store(tagId, value); + } + } + + void setTagHoldingLock(final long tagId, final double value) { + if (!KnownTagCodec.isKnown(tagId)) { + return; + } + if (tagInterceptor.needsIntercept(tagId)) { + this.setBoxHoldingLock(tagId, value); + } else { + knownTags.store(tagId, value); + } + } + + void setTagHoldingLock(TagMap.EntryReader entry) { + if (entry == null) { + return; + } + + // the interceptor prechecks the entry, to avoid boxing + if (!tagInterceptor.interceptTag(this, entry)) { + routePut(entry); + } + } + + void setTagHoldingLock(final String tag, final boolean value) { + if (null == tag) { + return; + } + final long tagId = KnownTagCodec.keyOf(tag); + if (tagId != 0) { + setTagHoldingLock(tagId, value); + } else if (!tagInterceptor.needsInterceptCustomTag(tag) + || !tagInterceptor.interceptCustomTag(this, tag, value)) { + customTagsForWrite().store(tag, value); // already known to be custom + } + } + + void setTagHoldingLock(final String tag, final int value) { + if (null == tag) { + return; + } + final long tagId = KnownTagCodec.keyOf(tag); + if (tagId != 0) { + setTagHoldingLock(tagId, value); + } else if (!tagInterceptor.needsInterceptCustomTag(tag) + || !tagInterceptor.interceptCustomTag(this, tag, value)) { + customTagsForWrite().store(tag, value); // already known to be custom + } + } + + void setTagHoldingLock(final String tag, final long value) { + if (null == tag) { + return; + } + final long tagId = KnownTagCodec.keyOf(tag); + if (tagId != 0) { + setTagHoldingLock(tagId, value); + } else if (!tagInterceptor.needsInterceptCustomTag(tag) + || !tagInterceptor.interceptCustomTag(this, tag, value)) { + customTagsForWrite().store(tag, value); // already known to be custom + } + } + + void setTagHoldingLock(final String tag, final float value) { + if (null == tag) { + return; + } + final long tagId = KnownTagCodec.keyOf(tag); + if (tagId != 0) { + setTagHoldingLock(tagId, value); + } else if (!tagInterceptor.needsInterceptCustomTag(tag) + || !tagInterceptor.interceptCustomTag(this, tag, value)) { + customTagsForWrite().store(tag, value); // already known to be custom + } + } + + void setTagHoldingLock(final String tag, final double value) { + if (null == tag) { + return; + } + final long tagId = KnownTagCodec.keyOf(tag); + if (tagId != 0) { + setTagHoldingLock(tagId, value); + } else if (!tagInterceptor.needsInterceptCustomTag(tag) + || !tagInterceptor.interceptCustomTag(this, tag, value)) { + customTagsForWrite().store(tag, value); // already known to be custom + } + } + + void removeTagHoldingLock(String tag) { + if (tagEquals(tag, Tags.SPAN_KIND)) { + // Clear the cached ordinal; the tag store still needs to be updated below. + spanKindOrdinal = SPAN_KIND_UNSET; + } + routeRemoval(tag); + } + + void removeTagHoldingLock(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + return; + } + if (tagId == KnownTags.SPAN_KIND_ID) { + spanKindOrdinal = SPAN_KIND_UNSET; + } + knownTags.remove(tagId); + } + + private void setBoxHoldingLock(long tagId, Object box) { + if (!tagInterceptor.interceptTag(this, tagId, box)) { + knownTags.store(tagId, box); + } + } + + Object getTagHoldingLock(final String key) { + switch (key) { + case DDTags.THREAD_ID: + return threadId; + case DDTags.THREAD_NAME: + // maintain previously observable type of the thread name :| + return threadName.toString(); + case Tags.HTTP_STATUS: + case KnownTags.HTTP_STATUS_CODE_OTEL_NAME: + return 0 == httpStatusCode ? null : (int) httpStatusCode; + case Tags.SPAN_KIND: + return getSpanKindString(); + default: + Object value = unsafeGetTag(key); + // maintain previously observable type of http url :| + return value == null ? null : Tags.HTTP_URL.equals(key) ? value.toString() : value; + } + } + + @Override + public DDSpanContext setResourceName(final CharSequence resourceName) { + setResourceName(resourceName, ResourceNamePriorities.DEFAULT); + return this; + } + + @Override + public DDSpanContext setError(final boolean error) { + setErrorFlag(error, ErrorPriorities.DEFAULT); + return this; + } + + /** + * Applies {@code extractor} to {@code source} holding the tag lock once for the whole call: the + * extractor's writes go through a {@link HeldSpanData}, which keeps the span's tag semantics but + * doesn't lock again. Kept small so it inlines and each call site sees its exact extractor type. + */ + void setTagsFrom(final T source, final TagExtractor extractor) { + synchronized (knownTags) { + extractor.extract(source, new HeldSpanData(this)); + } + } + + /** {@link #setTagsFrom(Object, TagExtractor)} for a {@link TagContributor}. */ + void setTagsFrom(final TagContributor contributor) { + synchronized (knownTags) { + contributor.addTo(new HeldSpanData(this)); + } + } + + /** + * Applies frozen tags as if their recorded writes were made now, under one lock. When the plan + * allows bulk -- no write needs the interceptor beyond span.kind, and none removes a tag or sets + * an empty value -- their net result is copied in: the known tags the masks prove absent at once, + * the ones that may be present (typically set earlier from another source, which these overwrite) + * one at a time through the interceptor, as {@code setTag} does (the partial {@code + * IdStore.seedPartial}), then span.kind's ordinal directly and the custom tags one at a time. + * Otherwise the writes are replayed in order, exactly as the extraction made them. Returns + * whether the bulk path was taken, for tests. + */ + boolean setTagsFrom(final FrozenTags frozen) { + final SeedPlan plan = seedPlanFor(frozen); + final TagMap tags = frozen.tags(); + synchronized (knownTags) { + if (plan.bulk + && knownTags.seedPartial( + tags.knownStore(), this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry))) { + if (plan.kindOrdinal != SPAN_KIND_UNSET) { + spanKindOrdinal = plan.kindOrdinal; + } + HashStore custom = tags.customStore(); + if (custom != null) { + custom.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); + } + return true; + } + frozen.replay(new HeldSpanData(this)); + return false; + } + } + + /** Whether the current thread holds this span's tag lock; for assertions. */ + boolean holdsTagLock() { + return Thread.holdsLock(knownTags); + } + + /** + * The span as a {@link SpanData} for the length of one {@code setTagsFrom} call, which holds the + * tag lock throughout: tag writes and reads skip relocking. It must not outlive the call; with + * the contributor's call site monomorphic, it is scalar-replaced. + */ + @NoEscape + static final class HeldSpanData implements SpanData { + private final DDSpanContext span; + + HeldSpanData(DDSpanContext span) { + this.span = span; + } + + /** The span, checking (with assertions on) that this is still inside its setTagsFrom call. */ + private DDSpanContext span() { + assert span.holdsTagLock() : "HeldSpanData used outside its setTagsFrom call"; + return span; + } + + @Override + public SpanData setTag(String key, Object value) { + span().setTagHoldingLock(key, value); + return this; + } + + @Override + public SpanData setTag(String key, String value) { + span().setTagHoldingLock(key, value); + return this; + } + + @Override + public SpanData setTag(String key, boolean value) { + span().setTagHoldingLock(key, value); + return this; + } + + @Override + public SpanData setTag(String key, int value) { + span().setTagHoldingLock(key, value); + return this; + } + + @Override + public SpanData setTag(String key, long value) { + span().setTagHoldingLock(key, value); + return this; + } + + @Override + public SpanData setTag(String key, float value) { + span().setTagHoldingLock(key, value); + return this; + } + + @Override + public SpanData setTag(String key, double value) { + span().setTagHoldingLock(key, value); + return this; + } + + @Override + public SpanData setTag(long tagId, Object value) { + span().setTagHoldingLock(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, CharSequence value) { + span().setTagHoldingLock(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, boolean value) { + span().setTagHoldingLock(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, int value) { + span().setTagHoldingLock(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, long value) { + span().setTagHoldingLock(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, float value) { + span().setTagHoldingLock(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, double value) { + span().setTagHoldingLock(tagId, value); + return this; + } + + @Override + public SpanData setTag(TagMap.EntryReader entry) { + span().setTagHoldingLock(entry); + return this; + } + + @Override + public Object getTag(String key) { + return span().getTagHoldingLock(key); + } + + @Override + public SpanData setResourceName(CharSequence resourceName) { + span().setResourceName(resourceName); + return this; + } + + @Override + public SpanData setError(boolean error) { + span().setError(error); + return this; + } + + @Override + public SpanData setMeasured(boolean measured) { + span().setMeasured(measured); + return this; + } + } + + void setAllTags(final TagMap map) { + setAllTags(map, true); + } + + void setAllTags(final TagMap map, boolean needsIntercept) { if (map == null) { return; } - synchronized (unsafeTags) { + synchronized (knownTags) { if (needsIntercept) { // forEach out-performs the iterator of TagMap // Taking advantage of ability to pass through other context arguments // to avoid using a capturing lambda - map.forEach( - this, - (ctx, tagEntry) -> { - if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.unsafeTags.set(tagEntry); - } - }); + map.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); } else { - unsafeTags.putAll(map); + routePutAll(map); } } } @@ -1321,20 +2210,20 @@ void setAllTags(final TagMap.Ledger ledger) { return; } - synchronized (unsafeTags) { + synchronized (knownTags) { 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 + // mirror removeTag(String): keep the cached ordinal in sync with the tags spanKindOrdinal = SPAN_KIND_UNSET; } - unsafeTags.remove(tag); + routeRemoval(tag); } else { TagMap.Entry entry = (TagMap.Entry) entryChange; if (!tagInterceptor.interceptTag(this, entry)) { - unsafeTags.set(entry); + routePut(entry); } } } @@ -1347,9 +2236,14 @@ void setAllTags(final Map map) { } else if (map instanceof TagMap) { setAllTags((TagMap) map); } else if (!map.isEmpty()) { - synchronized (unsafeTags) { + synchronized (knownTags) { for (final Map.Entry tag : map.entrySet()) { - if (!tagInterceptor.interceptTag(this, tag.getKey(), tag.getValue())) { + long tagId = KnownTagCodec.keyOf(tag.getKey()); + boolean intercepted = + tagId != 0 + ? tagInterceptor.interceptTag(this, tagId, tag.getValue()) + : tagInterceptor.interceptCustomTag(this, tag.getKey(), tag.getValue()); + if (!intercepted) { unsafeSetTag(tag.getKey(), tag.getValue()); } } @@ -1358,22 +2252,23 @@ void setAllTags(final Map map) { } void unsafeSetTag(final String tag, final Object value) { - unsafeTags.set(tag, value); + routeSet(tag, value); } void unsafeSetTag(final String tag, final CharSequence value) { - unsafeTags.set(tag, value); + routeSet(tag, value); } void unsafeSetTag(final String tag, final byte value) { - unsafeTags.set(tag, value); + routeSet(tag, value); } void unsafeSetTag(final String tag, final double value) { - unsafeTags.set(tag, value); + routeSet(tag, value); } - Object getTag(final String key) { + @Override + public Object getTag(final String key) { switch (key) { case DDTags.THREAD_ID: return threadId; @@ -1387,7 +2282,7 @@ Object getTag(final String key) { return getSpanKindString(); default: Object value; - synchronized (unsafeTags) { + synchronized (knownTags) { value = unsafeGetTag(key); } // maintain previously observable type of http url :| @@ -1395,6 +2290,72 @@ Object getTag(final String key) { } } + /* + * TagStore: the span's tags as stored, loaded and stored with no locking, interception or + * interpretation -- the storage beneath the span, as a Map was beneath a TagMap. A span isn't + * conceptually a TagStore. It implements one so post-processing is handed the span's storage + * without allocating: a wrapper would be free only if escape analysis removed it, and the + * post-processor chain's dispatch loop defeats that. Callers hold the knownTags lock, as + * post-processing does. + */ + + @Override + public Object load(String tag) { + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return knownTags.load(key); + } + HashStore custom = customTags; + TagMap.Entry entry = custom == null ? null : custom.getEntry(tag); + return entry == null ? null : entry.objectValue(); + } + + @Override + public String loadString(String tag) { + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return knownTags.loadString(key); + } + HashStore custom = customTags; + TagMap.Entry entry = custom == null ? null : custom.getEntry(tag); + return entry == null ? null : entry.stringValue(); + } + + @Override + public Object load(long tagId) { + return knownTags.load(tagId); + } + + @Override + public String loadString(long tagId) { + return knownTags.loadString(tagId); + } + + @Override + public void store(String tag, Object value) { + if (value == null) { + routeRemoval(tag); + } else { + routeSet(tag, value); + } + } + + @Override + public void store(long tagId, Object value) { + if (value == null) { + knownTags.remove(tagId); + } else { + knownTags.store(tagId, value); + } + } + + @Override + public void store(TagMap.EntryReader entry) { + if (entry != null) { + routePut(entry); + } + } + /** * This is not thread-safe and must only be used when it can be guaranteed that the context will * not be mutated. This is internal API and must not be exposed to users. @@ -1403,7 +2364,7 @@ Object getTag(final String key) { * @return the value associated with the tag */ public Object unsafeGetTag(final String tag) { - Object value = unsafeTags.getObject(tag); + Object value = load(tag); return value != null ? value : unsafeGetDirectionScopedTag(tag); } @@ -1422,8 +2383,7 @@ private Object unsafeGetDirectionScopedTag(final String tag) { if (tagId == 0) { return null; } - TagMap.Entry entry = unsafeTags.getEntry(tagId); - return entry == null ? null : entry.objectValue(); + return knownTags.load(tagId); } /** @@ -1446,8 +2406,8 @@ static int directionOfKind(byte spanKindOrdinal) { @Deprecated public TagMap getTags() { - synchronized (unsafeTags) { - TagMap tags = unsafeTags.copy(); + synchronized (knownTags) { + TagMap tags = tagView().copy(); tags.put(DDTags.THREAD_ID, threadId); // maintain previously observable type of the thread name :| @@ -1498,9 +2458,18 @@ public void setMetaStruct(final String field, final T value) { } } + /** + * Feeds this span's known-tag count back into its operation's {@link SizingHint}, held by its + * dense store, so later spans of the operation are presized. A best-effort racy max; with the + * dense store off this does nothing. + */ + void recordDenseSize() { + knownTags.recordSize(); + } + void earlyProcessTags(AppendableSpanLinks links) { - synchronized (unsafeTags) { - TagsPostProcessorFactory.eagerProcessor().processTags(unsafeTags, this, links); + synchronized (knownTags) { + TagsPostProcessorFactory.eagerProcessor().processTags(this, this, links); } } @@ -1529,15 +2498,18 @@ void processTagsAndBaggage( // This is a compromise to avoid... // - creating an extra wrapper object that would create significant allocation // - implementing an interface to read the spans that require making the read method public - synchronized (unsafeTags) { + synchronized (knownTags) { + // One live view of the tag stores, for the post-processors and the serializer + TagMap tags = tagView(); + // Tags - TagsPostProcessorFactory.lazyProcessor().processTags(unsafeTags, this, restrictedSpan); + TagsPostProcessorFactory.lazyProcessor().processTags(tags, this, restrictedSpan); // Links if (injectLinksAsTags) { String linksTag = DDSpanLink.toTag(restrictedSpan.getLinks()); if (linksTag != null) { - unsafeTags.set(SPAN_LINKS, linksTag); + tags.set(SPAN_LINKS, linksTag); } } @@ -1546,10 +2518,15 @@ void processTagsAndBaggage( if (injectEventsAsTags) { String eventsTag = spanEventsToTag(events); if (eventsTag != null) { - unsafeTags.set(SPAN_EVENTS, eventsTag); + tags.set(SPAN_EVENTS, eventsTag); } } + // Tune the operation's sizing hint now that every tag, serialization-time ones included, is + // present. This is where published spans are recorded; CoreTracer.write records only the + // spans it drops. + recordDenseSize(); + // Baggage Map baggageItemsWithPropagationTags; if (this.injectBaggageAsTags) { @@ -1572,7 +2549,7 @@ void processTagsAndBaggage( new Metadata( threadId, threadName, - unsafeTags, + tags, traceConfig.globalTags, traceConfig.spanDefaultTags, baggageItemsWithPropagationTags, @@ -1587,6 +2564,7 @@ void processTagsAndBaggage( Config.get().isOtlpTracesExportEnabled() ? OTLP_EXPORT_TRUE : OTLP_EXPORT_FALSE, restrictedSpan.getLinks(), events)); + adoptViewStores(tags); } } @@ -1737,7 +2715,7 @@ public String toString() { s.append(" *measured*"); } - synchronized (unsafeTags) { + synchronized (knownTags) { s.append(" tags=").append(new TreeMap<>(getTags())); } return s.toString(); 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..dbff87375d3 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 @@ -151,6 +151,25 @@ public boolean needsIntercept(TagMap.EntryReader entry) { return tagId != 0 ? needsIntercept(tagId) : isSplitServiceTag(entry.tag()); } + /** + * Whether a span prototype's tags can be seeded in bulk: none of its known tags needs the + * interceptor except {@code span.kind}, whose only effect is the span's cached kind ordinal, + * which the seed sets directly. Custom tags don't count: they are seeded one at a time. Depends + * only on this interceptor's configuration, so a prototype computes it once per interceptor. + */ + public boolean canSeedInBulk(TagMap tags) { + for (TagMap.EntryReader entry : tags) { + long tagId = entry.tagId(); + if (tagId == 0) { + continue; + } + if (tagId == KnownTags.SPAN_KIND_ID ? isSplitServiceTag(tagId) : needsIntercept(tagId)) { + return false; + } + } + return true; + } + /** Whether a custom tag (one with no id) may be routed; only split-by-tags routes them. */ public boolean needsInterceptCustomTag(String customTag) { return isSplitServiceTag(customTag); diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerPostProcessorChain.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerPostProcessorChain.java new file mode 100644 index 00000000000..df1ee6ea25d --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerPostProcessorChain.java @@ -0,0 +1,24 @@ +package datadog.trace.core.tagprocessor; + +import datadog.trace.api.TagStore; +import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; +import datadog.trace.core.DDSpanContext; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Runs eager post-processors in order. */ +public final class EagerPostProcessorChain extends EagerTagsPostProcessor { + private final EagerTagsPostProcessor[] chain; + + public EagerPostProcessorChain(@Nonnull final EagerTagsPostProcessor... processors) { + chain = Objects.requireNonNull(processors); + } + + @Override + public void processTags( + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + for (final EagerTagsPostProcessor processor : chain) { + processor.processTags(unsafeTags, spanContext, spanLinks); + } + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerTagsPostProcessor.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerTagsPostProcessor.java new file mode 100644 index 00000000000..15b1dcf4e1a --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerTagsPostProcessor.java @@ -0,0 +1,23 @@ +package datadog.trace.core.tagprocessor; + +import datadog.trace.api.TagStore; +import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; +import datadog.trace.core.DDSpanContext; + +/** + * A tag post-processor run on the application thread as a span finishes. It works on the span's + * tags as stored, through a {@link TagStore}, rather than a {@code TagMap} the span would have to + * build for it. The span passes itself as that store, which allocates nothing. + */ +public abstract class EagerTagsPostProcessor { + /** + * Processes the span's tags. + * + * @param unsafeTags the span's tags as stored, to read and write; the caller holds the span's tag + * lock + * @param spanContext the span, for its other properties + * @param spanLinks the span's links + */ + public abstract void processTags( + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks); +} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java index 2bb29fe31f4..cf278c16831 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java @@ -1,10 +1,10 @@ package datadog.trace.core.tagprocessor; +import static datadog.trace.api.KnownTags.HTTP_METHOD_ID; +import static datadog.trace.api.KnownTags.HTTP_ROUTE_ID; import static datadog.trace.bootstrap.instrumentation.api.OtelHttpSemantics.urlOrPath; -import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_METHOD; -import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ROUTE; -import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import datadog.trace.api.endpoint.EndpointResolver; import datadog.trace.api.internal.VisibleForTesting; import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; @@ -30,7 +30,7 @@ *
  • If {@code alwaysSimplifiedEndpoint=true}, always compute from URL regardless of route * */ -public class HttpEndpointPostProcessor extends TagsPostProcessor { +public class HttpEndpointPostProcessor extends EagerTagsPostProcessor { private static final Logger log = LoggerFactory.getLogger(HttpEndpointPostProcessor.class); private final EndpointResolver endpointResolver; @@ -55,18 +55,18 @@ public HttpEndpointPostProcessor() { @Override public void processTags( - TagMap unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { if (!endpointResolver.isEnabled()) { log.debug("EndpointResolver is not enabled, skipping HTTP endpoint post processing"); return; } - if (unsafeTags.getObject(HTTP_METHOD) == null) { + if (unsafeTags.load(HTTP_METHOD_ID) == null) { return; } try { - String httpRoute = unsafeTags.getString(HTTP_ROUTE); + String httpRoute = unsafeTags.loadString(HTTP_ROUTE_ID); Object httpUrl = urlOrPath(unsafeTags); endpointResolver.resolveEndpoint( unsafeTags, httpRoute, httpUrl == null ? null : httpUrl.toString()); diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/InternalTagsAdder.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/InternalTagsAdder.java index 3e6b8b13192..b0f8184e5d9 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/InternalTagsAdder.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/InternalTagsAdder.java @@ -1,16 +1,17 @@ package datadog.trace.core.tagprocessor; -import static datadog.trace.bootstrap.instrumentation.api.Tags.VERSION; +import static datadog.trace.api.KnownTags.DD_BASE_SERVICE_ID; +import static datadog.trace.api.KnownTags.VERSION_ID; -import datadog.trace.api.DDTags; import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.core.DDSpanContext; import javax.annotation.Nonnull; import javax.annotation.Nullable; -public final class InternalTagsAdder extends TagsPostProcessor { +public final class InternalTagsAdder extends EagerTagsPostProcessor { private final UTF8BytesString ddService; // Prebuilt once to avoid per-span Entry allocation. @@ -19,16 +20,16 @@ public final class InternalTagsAdder extends TagsPostProcessor { public InternalTagsAdder(@Nonnull final String ddService, @Nullable final String version) { this.ddService = UTF8BytesString.create(ddService); - this.baseServiceEntry = TagMap.Entry.create(DDTags.BASE_SERVICE, this.ddService); + this.baseServiceEntry = TagMap.Entry.create(DD_BASE_SERVICE_ID, this.ddService); this.versionEntry = version != null && !version.isEmpty() - ? TagMap.Entry.create(VERSION, UTF8BytesString.create(version)) + ? TagMap.Entry.create(VERSION_ID, UTF8BytesString.create(version)) : null; } @Override public void processTags( - TagMap unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { if (spanContext == null) { return; } @@ -36,13 +37,13 @@ public void processTags( if (!ddService.toString().equalsIgnoreCase(spanContext.getServiceName())) { if (baseServiceEntry != null) { // service name != DD_SERVICE - unsafeTags.set(baseServiceEntry); + unsafeTags.store(baseServiceEntry); } } else { // as per config consistency, the version tag is added across tracers only if // the service name is DD_SERVICE and version tag is not manually set - if (versionEntry != null && !unsafeTags.containsKey(VERSION)) { - unsafeTags.set(versionEntry); + if (versionEntry != null && unsafeTags.load(VERSION_ID) == null) { + unsafeTags.store(versionEntry); } } } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PayloadTagsProcessor.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PayloadTagsProcessor.java index 09d382843a3..e254a756a87 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PayloadTagsProcessor.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PayloadTagsProcessor.java @@ -245,6 +245,7 @@ public void doubleValue(PathCursor path, double value) { @Override public void nullValue(PathCursor path) { + // a JSON null sets no tag, and removes an earlier value set at the same path collectedTags.set(path.toString(tagPrefix), null); } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PeerServiceCalculator.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PeerServiceCalculator.java index 198e2c78f1c..98fd6888d45 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PeerServiceCalculator.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PeerServiceCalculator.java @@ -1,18 +1,19 @@ package datadog.trace.core.tagprocessor; +import static datadog.trace.api.KnownTags.DD_PEER_SERVICE_REMAPPED_FROM_ID; +import static datadog.trace.api.KnownTags.PEER_SERVICE_ID; + import datadog.trace.api.Config; -import datadog.trace.api.DDTags; -import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import datadog.trace.api.internal.VisibleForTesting; import datadog.trace.api.naming.NamingSchema; import datadog.trace.api.naming.SpanNaming; import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; -import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.core.DDSpanContext; import java.util.Map; import javax.annotation.Nonnull; -public final class PeerServiceCalculator extends TagsPostProcessor { +public final class PeerServiceCalculator extends EagerTagsPostProcessor { private final NamingSchema.ForPeerService peerServiceNaming; private final Map peerServiceMapping; @@ -34,8 +35,8 @@ public PeerServiceCalculator() { @Override public void processTags( - TagMap unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { - Object peerService = unsafeTags.getObject(Tags.PEER_SERVICE); + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + Object peerService = unsafeTags.load(PEER_SERVICE_ID); // the user set it if (peerService != null) { if (canRemap) { @@ -46,18 +47,18 @@ public void processTags( // calculate the defaults (if any) peerServiceNaming.tags(unsafeTags); // only remap if the mapping is not empty (saves one get) - remapPeerService(unsafeTags, canRemap ? unsafeTags.getObject(Tags.PEER_SERVICE) : null); + remapPeerService(unsafeTags, canRemap ? unsafeTags.load(PEER_SERVICE_ID) : null); return; } // we have no peer.service and we do not compute defaults. Leave the map untouched } - private void remapPeerService(TagMap unsafeTags, Object value) { + private void remapPeerService(TagStore unsafeTags, Object value) { if (value != null) { String mapped = peerServiceMapping.get(value); if (mapped != null) { - unsafeTags.put(Tags.PEER_SERVICE, mapped); - unsafeTags.put(DDTags.PEER_SERVICE_REMAPPED_FROM, value); + unsafeTags.store(PEER_SERVICE_ID, mapped); + unsafeTags.store(DD_PEER_SERVICE_REMAPPED_FROM_ID, value); } } } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java index 08c52a2658a..2e3cd4ca345 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java @@ -9,11 +9,11 @@ public final class TagsPostProcessorFactory { private static boolean addRemoteHostname = true; private static class Lazy { - private static TagsPostProcessor eagerProcessor = createEagerChain(); + private static EagerTagsPostProcessor eagerProcessor = createEagerChain(); private static TagsPostProcessor lazyProcessor = createLazyChain(); - private static TagsPostProcessor createEagerChain() { - final List processors = new ArrayList<>(3); + private static EagerTagsPostProcessor createEagerChain() { + final List processors = new ArrayList<>(3); processors.add(new PeerServiceCalculator()); if (addInternalTags) { processors.add( @@ -24,7 +24,7 @@ private static TagsPostProcessor createEagerChain() { if (Config.get().isTraceResourceRenamingEnabled()) { processors.add(new HttpEndpointPostProcessor()); } - return new PostProcessorChain(processors.toArray(new TagsPostProcessor[0])); + return new EagerPostProcessorChain(processors.toArray(new EagerTagsPostProcessor[0])); } private static TagsPostProcessor createLazyChain() { @@ -54,7 +54,7 @@ private static TagsPostProcessor createLazyChain() { } } - public static TagsPostProcessor eagerProcessor() { + public static EagerTagsPostProcessor eagerProcessor() { return Lazy.eagerProcessor; } diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java index 44467412346..121c55bf3ef 100644 --- a/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java @@ -226,7 +226,6 @@ DDSpan realSpan() throws Exception { Collections.emptyMap(), false, "", - 0, trace, null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDCoreJavaSpecification.java b/dd-trace-core/src/test/java/datadog/trace/core/DDCoreJavaSpecification.java index d5fead39ea7..b83d9c16bd4 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/DDCoreJavaSpecification.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDCoreJavaSpecification.java @@ -115,7 +115,6 @@ protected DDSpan buildSpan( null, false, spanType, - 0, tracer.createTraceCollector(DDTraceId.ONE), null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextStoresTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextStoresTest.java new file mode 100644 index 00000000000..d098d638517 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextStoresTest.java @@ -0,0 +1,297 @@ +package datadog.trace.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.common.writer.ListWriter; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * How a span routes tags to its own known and custom stores: custom tags by name with every value + * type, primitive sets of intercepted tags by id, map writes, and the custom store borrowed from + * the tracer-level tags until the span's first custom write. + */ +class DDSpanContextStoresTest extends DDCoreJavaSpecification { + private CoreTracer tracer; + + @BeforeEach + void setup() { + tracer = + tracerBuilder() + .writer(new ListWriter()) + .defaultSpanTags(Collections.singletonMap("global.custom", "from-tracer")) + .build(); + } + + @AfterEach + void tearDown() { + tracer.close(); + } + + private DDSpan start() { + return (DDSpan) tracer.startSpan("test-instr", "stores.op"); + } + + @Test + void aReaderWithNoEntryIsANoOp() { + DDSpan span = start(); + try { + span.setTag("http.route", "/users"); + span.setTag("custom.kept", "kept"); + span.spanContext().setTag(new NoEntryReader("http.route", KnownTags.HTTP_ROUTE_ID)); + span.spanContext().setTag(new NoEntryReader("custom.kept", 0L)); + span.spanContext().setMetric(new NoEntryReader("custom.metric", 0L)); + + assertEquals("/users", span.getTag("http.route")); + assertEquals("kept", span.getTag("custom.kept")); + assertNull(span.getTag("custom.metric")); + } finally { + span.finish(); + } + } + + @Test + void customTagsKeepEveryValueType() { + DDSpan span = start(); + try { + span.setTag("custom.chars", (CharSequence) "chars"); + span.setTag("custom.bool", true); + span.setTag("custom.int", 7); + span.setTag("custom.long", 8L); + span.setTag("custom.float", 1.5f); + span.setTag("custom.double", 2.5d); + + assertEquals("chars", span.getTag("custom.chars")); + assertEquals(true, span.getTag("custom.bool")); + assertEquals(7, span.getTag("custom.int")); + assertEquals(8L, span.getTag("custom.long")); + assertEquals(1.5f, span.getTag("custom.float")); + assertEquals(2.5d, span.getTag("custom.double")); + } finally { + span.finish(); + } + } + + @Test + void interceptedTagsSetByIdAsPrimitivesAreRouted() { + DDSpan span = start(); + try { + span.spanContext().setTag(KnownTags.HTTP_STATUS_CODE_ID, 404); + assertEquals(404, span.getHttpStatusCode()); + span.spanContext().setTag(KnownTags.HTTP_STATUS_CODE_ID, 503L); + assertEquals(503, span.getHttpStatusCode()); + + span.spanContext().setTag(KnownTags.DD_MEASURED_ID, true); + assertTrue(span.isMeasured()); + + span.spanContext().setTag(KnownTags.DD1_SR_EAUSR_ID, 0.25d); + span.spanContext().setTag(KnownTags.DD1_SR_EAUSR_ID, 0.5f); + assertEquals(0.5f, ((Number) span.getTag("_dd1.sr.eausr")).floatValue()); + } finally { + span.finish(); + } + } + + @Test + void knownTagsSetByIdAsPrimitivesWithoutInterception() { + DDSpan span = start(); + try { + span.spanContext().setTag(KnownTags.PEER_PORT_ID, 5432L); + span.spanContext().setTag(KnownTags.DB_INSTANCE_ID, 1.5f); + span.spanContext().setTag(KnownTags.DB_POOL_NAME_ID, 2.5d); + assertEquals(5432L, span.getTag("peer.port")); + assertEquals(1.5f, span.getTag("db.instance")); + assertEquals(2.5d, span.getTag("db.pool.name")); + + // an intercepted tag the interceptor declines is still stored: span.kind records its + // ordinal and keeps the value + span.spanContext().setTag(KnownTags.SPAN_KIND_ID, 1); + assertEquals("1", String.valueOf(span.getTag("span.kind"))); + } finally { + span.finish(); + } + } + + @Test + void metricsWithKnownNamesGoToTheKnownStore() { + DDSpan span = start(); + try { + span.setMetric("db.user", 200); + span.setMetric("peer.port", 8080L); + span.setMetric("db.instance", 0.5f); + span.setMetric("db.pool.name", 0.75d); + span.setMetric("custom.metric", 3L); + assertEquals(200, span.getTag("db.user")); + assertEquals(8080L, span.getTag("peer.port")); + assertEquals(0.5f, span.getTag("db.instance")); + assertEquals(0.75d, span.getTag("db.pool.name")); + assertEquals(3L, span.getTag("custom.metric")); + } finally { + span.finish(); + } + } + + @Test + void aMapWriteRoutesKnownAndCustomTags() { + DDSpan span = start(); + try { + Map tags = new LinkedHashMap<>(); + tags.put("http.route", "/users"); + tags.put("custom.from.map", "value"); + tags.put("custom.chars.from.map", new StringBuilder("chars")); + span.setAllTags(tags); + span.spanContext().unsafeSetTag("custom.unsafe", (CharSequence) "unsafe"); + + assertEquals("/users", span.getTag("http.route")); + assertEquals("value", span.getTag("custom.from.map")); + assertEquals("chars", String.valueOf(span.getTag("custom.chars.from.map"))); + assertEquals("unsafe", span.getTag("custom.unsafe")); + } finally { + span.finish(); + } + } + + @Test + void removingACustomTagTheSpanDoesNotHaveKeepsBorrowing() { + DDSpan span = start(); + try { + assertEquals("from-tracer", span.getTag("global.custom")); + span.removeTag("never.set"); // nothing to hide: the borrowed store stays as it is + assertEquals("from-tracer", span.getTag("global.custom")); + } finally { + span.finish(); + } + } + + @Test + void removingABorrowedCustomTagHidesItFromThisSpanOnly() { + DDSpan span = start(); + DDSpan other = start(); + try { + span.removeTag("global.custom"); // copies the borrowed store, then hides the tag + assertNull(span.getTag("global.custom")); + assertEquals("from-tracer", other.getTag("global.custom"), "the tracer-level tag stays"); + } finally { + span.finish(); + other.finish(); + } + } + + @Test + void aPrototypesCustomTagIsSeededOnlyWhereAbsent() { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("stores.proto") + .initTag("global.custom", "from-prototype") + .initTag("proto.custom", "seeded") + .build(); + DDSpan span = (DDSpan) tracer.startSpan(prototype, null); + try { + // the tracer-level tag is already visible, so the prototype's default doesn't replace it + assertEquals("from-tracer", span.getTag("global.custom")); + assertEquals("seeded", span.getTag("proto.custom")); + } finally { + span.finish(); + } + } + + /** An EntryReader with no entry and no value, as a caller outside the tracer may pass. */ + static final class NoEntryReader implements datadog.trace.api.TagMap.EntryReader { + private final String tag; + private final long tagId; + + NoEntryReader(String tag, long tagId) { + this.tag = tag; + this.tagId = tagId; + } + + @Override + public String tag() { + return tag; + } + + @Override + public long tagId() { + return tagId; + } + + @Override + public byte type() { + return datadog.trace.api.TagMap.EntryReader.OBJECT; + } + + @Override + public boolean is(byte type) { + return false; + } + + @Override + public boolean isNumericPrimitive() { + return false; + } + + @Override + public boolean isNumber() { + return false; + } + + @Override + public boolean isObject() { + return true; + } + + @Override + public Object objectValue() { + return null; + } + + @Override + public String stringValue() { + return null; + } + + @Override + public boolean booleanValue() { + return false; + } + + @Override + public int intValue() { + return 0; + } + + @Override + public long longValue() { + return 0; + } + + @Override + public float floatValue() { + return 0; + } + + @Override + public double doubleValue() { + return 0; + } + + @Override + public java.util.Map.Entry mapEntry() { + return null; + } + + @Override + public datadog.trace.api.TagMap.Entry entry() { + return null; + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTagViewTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTagViewTest.java new file mode 100644 index 00000000000..561dfc33bcc --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTagViewTest.java @@ -0,0 +1,60 @@ +package datadog.trace.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.trace.api.TagMap; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import java.util.Collections; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * The span's cached view of its tag stores keeps the stores it was created over, so a custom store + * the span creates afterwards must replace the view rather than be missed by it. + */ +class DDSpanContextTagViewTest extends DDCoreJavaSpecification { + private CoreTracer tracer; + + @AfterEach + void cleanup() { + if (tracer != null) { + tracer.close(); + } + } + + @Test + void aCustomTagWrittenAfterTheViewIsCreatedIsSeenThroughANewView() { + tracer = tracerBuilder().build(); + AgentSpan span = tracer.buildSpan("test", "op").start(); + try { + DDSpanContext context = (DDSpanContext) span.spanContext(); + assertEquals(null, context.getTags().get("custom.first")); // creates the view + + span.setTag("custom.first", "a"); // the span's first custom store: the view is dropped + span.setTag("custom.second", "b"); + + TagMap tags = context.getTags(); + assertEquals("a", tags.get("custom.first")); + assertEquals("b", tags.get("custom.second")); + } finally { + span.finish(); + } + } + + @Test + void aCustomTagCopiedInFromAMapIsStoredWithoutAView() { + tracer = tracerBuilder().build(); + AgentSpan span = tracer.buildSpan("test", "op").start(); + try { + DDSpanContext context = (DDSpanContext) span.spanContext(); + context.setAllTags(TagMap.fromMap(Collections.singletonMap("custom.copied", "c"))); + span.setTag("custom.later", "d"); + + TagMap tags = context.getTags(); + assertEquals("c", tags.get("custom.copied")); + assertEquals("d", tags.get("custom.later")); + } finally { + span.finish(); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsDenseForkedTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsDenseForkedTest.java new file mode 100644 index 00000000000..432a47e7e37 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsDenseForkedTest.java @@ -0,0 +1,138 @@ +package datadog.trace.core; + +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 datadog.trace.api.DDTags; +import datadog.trace.api.IdStore; +import datadog.trace.api.KnownTags; +import datadog.trace.api.config.TracerConfig; +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; +import datadog.trace.bootstrap.instrumentation.api.TagContributor; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.test.junit.utils.config.WithConfig; +import java.util.TreeMap; +import org.junit.jupiter.api.Test; + +/** + * {@link DDSpanFrozenTagsTest} with the dense store on, where frozen known tags that need no + * interception and aren't already on the span are copied in at once ({@code + * DDSpanContext.setTagsFrom(FrozenTags)}); runs in its own JVM because the flag is read once. Adds + * which path each shape takes: the inherited tests pass on either, so these pin that the bulk copy + * is actually used. + */ +@WithConfig(key = TracerConfig.TRACE_DENSE_TAGS_ENABLED, value = "true") +class DDSpanFrozenTagsDenseForkedTest extends DDSpanFrozenTagsTest { + private static final FrozenTags PEER_TAGS = + FrozenTags.of( + (TagContributor) + target -> { + target.setTag(KnownTags.PEER_HOSTNAME_ID, "db.example"); + target.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + target.setTag(KnownTags.PEER_PORT_ID, 5432); + }); + + @Test + void theDenseStoreIsOn() { + // guards the rest of this class: with the flag missed, every test would quietly run on the + // hash store, where the frozen tag tests pass too + assertEquals("DenseIdStore", IdStore.create(null).getClass().getSimpleName()); + } + + @Test + void peerTagsOnAFreshSpanGoInBulk() { + DDSpan span = start(); + try { + assertTrue(span.spanContext().setTagsFrom(PEER_TAGS)); + + assertEquals("db.example", span.getTag(Tags.PEER_HOSTNAME)); + assertEquals("10.0.0.1", span.getTag(Tags.PEER_HOST_IPV4)); + assertEquals(5432, span.getTag(Tags.PEER_PORT)); + } finally { + span.finish(); + } + } + + @Test + void spanKindStillGoesInBulk() { + FrozenTags frozen = + FrozenTags.of( + (TagContributor) + target -> { + target.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_CLIENT); + target.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + }); + DDSpan span = (DDSpan) tracer.buildSpan("test-instr", "frozen-tags.op").start(); + try { + assertTrue(span.spanContext().setTagsFrom(frozen)); + assertEquals(Tags.SPAN_KIND_CLIENT, span.getSpanKindString()); // the bulk path sets it + } finally { + span.finish(); + } + } + + @Test + void anInterceptedTagSendsTheSetOneAtATime() { + FrozenTags frozen = + FrozenTags.of( + (TagContributor) + target -> { + target.setTag(DDTags.RESOURCE_NAME, "frozen-resource"); + target.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + }); + DDSpan span = start(); + try { + assertFalse(span.spanContext().setTagsFrom(frozen)); + assertEquals("frozen-resource", span.getResourceName().toString()); + } finally { + span.finish(); + } + } + + @Test + void tagsTheSpanAlreadyHasAreOverwrittenAndTheRestGoInBulk() { + // the shape of an HTTP client: peer.hostname and peer.port first from the URL, then all the + // peer tags from the connection, which win + DDSpan live = start(); + DDSpan replayed = start(); + try { + for (DDSpan span : new DDSpan[] {live, replayed}) { + span.setTag(Tags.PEER_HOSTNAME, "url.example"); + span.setTag(Tags.PEER_PORT, 80); + } + live.setTag(KnownTags.PEER_HOSTNAME_ID, "db.example"); + live.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + live.setTag(KnownTags.PEER_PORT_ID, 5432); + assertTrue(replayed.spanContext().setTagsFrom(PEER_TAGS)); + + assertEquals("db.example", replayed.getTag(Tags.PEER_HOSTNAME)); + assertEquals("10.0.0.1", replayed.getTag(Tags.PEER_HOST_IPV4)); + assertEquals(5432, replayed.getTag(Tags.PEER_PORT)); + assertEquals(new TreeMap<>(live.getTags()), new TreeMap<>(replayed.getTags())); + } finally { + live.finish(); + replayed.finish(); + } + } + + @Test + void aSpanKindTheSpanAlreadyHasIsOverwrittenWithItsOrdinal() { + FrozenTags frozen = + FrozenTags.of( + (TagContributor) + target -> { + target.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_CLIENT); + target.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + }); + DDSpan span = start(); + try { + span.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER); + assertTrue(span.spanContext().setTagsFrom(frozen)); + assertEquals(Tags.SPAN_KIND_CLIENT, span.getTag(Tags.SPAN_KIND)); + assertEquals(Tags.SPAN_KIND_CLIENT, span.getSpanKindString()); + } finally { + span.finish(); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsTest.java new file mode 100644 index 00000000000..36a9e5362f9 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsTest.java @@ -0,0 +1,274 @@ +package datadog.trace.core; + +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.assertSame; + +import datadog.trace.api.DDTags; +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.FrozenTags; +import datadog.trace.bootstrap.instrumentation.api.SpanData; +import datadog.trace.bootstrap.instrumentation.api.TagContributor; +import datadog.trace.bootstrap.instrumentation.api.TagExtractor; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.common.writer.ListWriter; +import datadog.trace.core.taginterceptor.RuleFlags; +import datadog.trace.core.taginterceptor.TagInterceptor; +import java.util.HashSet; +import java.util.Set; +import java.util.TreeMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * {@code DDSpan.setTagsFrom(FrozenTags)}: applying recorded tags is equivalent to making the + * recorded writes on the span. {@link DDSpanFrozenTagsDenseForkedTest} reruns these with the dense + * store, where the known tags can be copied in at once. + */ +class DDSpanFrozenTagsTest extends DDCoreJavaSpecification { + CoreTracer tracer; + + @BeforeEach + void setup() { + tracer = tracerBuilder().writer(new ListWriter()).build(); + } + + @AfterEach + void tearDown() { + tracer.close(); + } + + DDSpan start() { + return (DDSpan) tracer.startSpan("test-instr", "frozen-tags.op"); + } + + /** + * A non-String value, shared so both spans hold the same object (StringBuilder has no equals). + */ + private static final Object OBJECT_VALUE = new StringBuilder("built"); + + /** Known tags by id and by name, custom tags, every value type, and a set-then-removed tag. */ + private static void writeTags(SpanData span) { + span.setTag(KnownTags.HTTP_ROUTE_ID, "/users/{id}"); + span.setTag(KnownTags.HTTP_STATUS_CODE_ID, 503); + span.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + span.setTag(Tags.PEER_PORT, 5432); + span.setTag("custom.string", "value"); + span.setTag("custom.object", OBJECT_VALUE); + span.setTag("custom.bool", true); + span.setTag("custom.long", 8L); + span.setTag("custom.double", 2.5d); + span.setTag("custom.removed", "gone"); + span.setTag("custom.removed", (Object) null); + } + + @Test + void frozenTagsSetWhatTheRecordedWritesWouldSet() { + FrozenTags frozen = FrozenTags.of((TagContributor) DDSpanFrozenTagsTest::writeTags); + DDSpan live = start(); + DDSpan replayed = start(); + try { + writeTags(live); + assertSame(replayed, replayed.setTagsFrom(frozen)); + + // compared as plain maps: TagMap.equals isn't Map content equality + assertEquals(new TreeMap<>(live.getTags()), new TreeMap<>(replayed.getTags())); + } finally { + live.finish(); + replayed.finish(); + } + } + + @Test + void everySpanGetsTheSameValueObjects() { + // the point of freezing: a value built once is shared, not rebuilt per span + TagExtractor extractor = + (n, target) -> target.setTag(KnownTags.PEER_IPV4_ID, "10.0.0." + n); + FrozenTags frozen = FrozenTags.of(7, extractor); + DDSpan first = start(); + DDSpan second = start(); + try { + first.setTagsFrom(frozen); + second.setTagsFrom(frozen); + + assertEquals("10.0.0.7", first.getTag(Tags.PEER_HOST_IPV4)); + assertSame(first.getTag(Tags.PEER_HOST_IPV4), second.getTag(Tags.PEER_HOST_IPV4)); + } finally { + first.finish(); + second.finish(); + } + } + + @Test + void interceptedTagsKeepTheirEffects() { + FrozenTags frozen = + FrozenTags.of( + (TagContributor) + target -> { + target.setTag(DDTags.RESOURCE_NAME, "frozen-resource"); // becomes the resource + target.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_CLIENT); // sets the cached kind + target.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + }); + DDSpan span = start(); + try { + span.setTagsFrom(frozen); + + assertEquals("frozen-resource", span.getResourceName().toString()); + assertEquals(Tags.SPAN_KIND_CLIENT, span.getSpanKindString()); + assertEquals("10.0.0.1", span.getTag(Tags.PEER_HOST_IPV4)); + } finally { + span.finish(); + } + } + + @Test + void spanKindAloneIsAppliedWithItsOrdinal() { + FrozenTags frozen = + FrozenTags.of( + (TagContributor) + target -> { + target.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_CONSUMER); + target.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + }); + DDSpan span = start(); + try { + span.setTagsFrom(frozen); + + assertEquals(Tags.SPAN_KIND_CONSUMER, span.getTag(Tags.SPAN_KIND)); + assertEquals(Tags.SPAN_KIND_CONSUMER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + + @Test + void frozenTagsOverwriteWhatTheSpanAlreadyHas() { + FrozenTags frozen = + FrozenTags.of( + (TagContributor) + target -> { + target.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + target.setTag(Tags.PEER_PORT, 5432); + target.setTag("custom.string", "frozen"); + }); + DDSpan span = start(); + try { + span.setTag(Tags.PEER_HOST_IPV4, "192.168.0.1"); + span.setTag("custom.string", "explicit"); + span.setTag(Tags.COMPONENT, "untouched"); + span.setTagsFrom(frozen); + + assertEquals("10.0.0.1", span.getTag(Tags.PEER_HOST_IPV4)); + assertEquals(5432, span.getTag(Tags.PEER_PORT)); + assertEquals("frozen", span.getTag("custom.string")); + assertEquals("untouched", span.getTag(Tags.COMPONENT)); + } finally { + span.finish(); + } + } + + @Test + void frozenTagsApplyAcrossTracers() { + // the core's apply plan is cached per interceptor; a second tracer must not reuse the first's + FrozenTags frozen = FrozenTags.of((TagContributor) DDSpanFrozenTagsTest::writeTags); + CoreTracer other = tracerBuilder().writer(new ListWriter()).build(); + try { + DDSpan first = start(); + DDSpan second = (DDSpan) other.startSpan("test-instr", "frozen-tags.op"); + try { + first.setTagsFrom(frozen); + second.setTagsFrom(frozen); + + assertEquals(new TreeMap<>(first.getTags()), new TreeMap<>(second.getTags())); + } finally { + first.finish(); + second.finish(); + } + } finally { + other.close(); + } + } + + @Test + void emptyOrNullFrozenTagsChangeNothing() { + DDSpan span = start(); + try { + int before = span.getTags().size(); + assertSame(span, span.setTagsFrom(FrozenTags.EMPTY)); + assertSame(span, span.setTagsFrom((FrozenTags) null)); + assertEquals(before, span.getTags().size()); + } finally { + span.finish(); + } + } + + @Test + void aRecordedRemovalRemovesTheSpansTag() { + FrozenTags frozen = + FrozenTags.of( + (TagContributor) + target -> { + target.setTag("custom.stale", (Object) null); + target.setTag(KnownTags.PEER_HOSTNAME_ID, (CharSequence) null); + target.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + }); + DDSpan span = start(); + try { + span.setTag("custom.stale", "stale"); + span.setTag(Tags.PEER_HOSTNAME, "url.example"); + span.setTagsFrom(frozen); + + assertNull(span.getTag("custom.stale")); + assertNull(span.getTag(Tags.PEER_HOSTNAME)); + assertEquals("10.0.0.1", span.getTag(Tags.PEER_HOST_IPV4)); + } finally { + span.finish(); + } + } + + @Test + void splitByTagsSeesTheWritesInTheirOrder() { + // split-by-tags makes each routed write set the service, so the last one decides it: the + // replay must make the writes in the extraction's order, not the store's + Set splitTags = new HashSet<>(asList("custom.first", "custom.second", "custom.third")); + CoreTracer splitting = + tracerBuilder() + .writer(new ListWriter()) + .tagInterceptor( + new TagInterceptor(true, "my-service", splitTags, new RuleFlags(), false)) + .build(); + try { + for (String[] order : + new String[][] { + {"custom.first", "custom.second", "custom.third"}, + {"custom.third", "custom.second", "custom.first"}, + {"custom.second", "custom.third", "custom.first"} + }) { + TagContributor writes = + target -> { + for (String tag : order) { + target.setTag(tag, tag + "-service"); + } + }; + FrozenTags frozen = FrozenTags.of(writes); + DDSpan live = (DDSpan) splitting.startSpan("test-instr", "frozen-tags.op"); + DDSpan replayed = (DDSpan) splitting.startSpan("test-instr", "frozen-tags.op"); + try { + writes.addTo(live); + replayed.setTagsFrom(frozen); + + assertEquals(order[2] + "-service", live.getServiceName()); + assertEquals(live.getServiceName(), replayed.getServiceName()); + assertEquals(new TreeMap<>(live.getTags()), new TreeMap<>(replayed.getTags())); + } finally { + live.finish(); + replayed.finish(); + } + } + } finally { + splitting.close(); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSerializationTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSerializationTest.java index b8ca25a6cc5..1ab43205181 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSerializationTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSerializationTest.java @@ -217,7 +217,6 @@ void serializeTraceWithBaggageAndTagsCorrectlyV04( baggage, false, null, - tags.size(), tracer.createTraceCollector(DDTraceId.ONE), null, null, @@ -294,7 +293,6 @@ void serializeTraceWithBaggageAndTagsCorrectlyV05( baggage, false, null, - tags.size(), tracer.createTraceCollector(DDTraceId.ONE), null, null, @@ -353,7 +351,7 @@ void serializeTraceWithConfiguredBaggageAndTagsCorrectlyV1(String scenario, bool w3cBaggageItems.put("ignored", "ignored-value"); DDSpanContext context = - createSpanContext(tracer, baggage, Baggage.create(w3cBaggageItems), injectBaggage, true, 1); + createSpanContext(tracer, baggage, Baggage.create(w3cBaggageItems), injectBaggage, true); context.setTag("span-tag", "span-value"); DDSpan span = DDSpan.create("test", 0, context, null); @@ -377,7 +375,7 @@ void serializeTraceWithConfiguredBaggageAndTagsCorrectlyV1(String scenario, bool @Test void serializeTraceWithSpanLinksAsStructuredLinksOnlyV1() throws Exception { CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); - DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true, 0); + DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true); DDSpan span = DDSpan.create("test", 0, context, null); Map linkAttributes = new HashMap<>(); @@ -407,7 +405,7 @@ void serializeTraceWithSpanLinksAsStructuredLinksOnlyV1() throws Exception { @Test void serializeTraceWithSpanEventsAsStructuredEventsOnlyV1() throws Exception { CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); - DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true, 1); + DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true); DDSpan span = DDSpan.create("test", 0, context, null); span.addEvent("event.one", typedAttributes(), 1234567890L, NANOSECONDS); span.addEvent("event.two", Collections.emptyMap(), 1234567891L, NANOSECONDS); @@ -439,7 +437,7 @@ void serializeTraceWithSpanEventsAsStructuredEventsOnlyV1() throws Exception { }) void serializeTraceWithSpanEventsAsEventsTag(String protocol) throws Exception { CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); - DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true, 1); + DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true); DDSpan span = DDSpan.create("test", 0, context, null); span.addEvent("event.one", typedAttributes(), 1234567890L, NANOSECONDS); span.addEvent("event \"two\"", Collections.emptyMap(), 1234567891L, NANOSECONDS); @@ -536,7 +534,6 @@ void serializeTraceWithFlatMapTagV04() throws Exception { null, false, null, - 0, tracer.createTraceCollector(DDTraceId.ONE), null, null, @@ -603,7 +600,6 @@ void serializeTraceWithFlatMapTagV05() throws Exception { null, false, null, - 0, tracer.createTraceCollector(DDTraceId.ONE), null, null, @@ -747,7 +743,6 @@ private DDSpanContext createSpanContext( baggage, false, spanType, - 1, tracer.createTraceCollector(DDTraceId.ONE), null, null, @@ -765,8 +760,7 @@ private DDSpanContext createSpanContext( Map baggage, Baggage w3cBaggage, boolean injectBaggage, - boolean injectLinks, - int tagsSize) { + boolean injectLinks) { return new DDSpanContext( DDTraceId.ONE, 1, @@ -782,7 +776,6 @@ private DDSpanContext createSpanContext( w3cBaggage, false, "fakeType", - tagsSize, tracer.createTraceCollector(DDTraceId.ONE), null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSetTagsFromTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSetTagsFromTest.java new file mode 100644 index 00000000000..a6c1a573bd4 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSetTagsFromTest.java @@ -0,0 +1,147 @@ +package datadog.trace.core; + +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 datadog.trace.api.DDTags; +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.SpanData; +import datadog.trace.bootstrap.instrumentation.api.TagContributor; +import datadog.trace.bootstrap.instrumentation.api.TagExtractor; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.common.writer.ListWriter; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * {@code DDSpan.setTagsFrom}: the contributor or extractor runs holding the span's tag lock once, + * through a short-lived {@link SpanData} that keeps the span's tag semantics. + */ +class DDSpanSetTagsFromTest extends DDCoreJavaSpecification { + private CoreTracer tracer; + + @BeforeEach + void setup() { + tracer = tracerBuilder().writer(new ListWriter()).build(); + } + + @AfterEach + void tearDown() { + tracer.close(); + } + + private DDSpan start() { + return (DDSpan) tracer.startSpan("test-instr", "tags-from.op"); + } + + /** + * A non-String value, shared so both spans hold the same object (StringBuilder has no equals). + */ + private static final Object OBJECT_VALUE = new StringBuilder("built"); + + /** The same writes either way: known tags by id and by name, custom tags, every value type. */ + private static void writeTags(SpanData span) { + span.setTag(KnownTags.HTTP_ROUTE_ID, "/users/{id}"); + span.setTag(KnownTags.HTTP_STATUS_CODE_ID, 503); + span.setTag(Tags.COMPONENT, "jdbc"); + span.setTag("custom.string", "value"); + span.setTag("custom.object", OBJECT_VALUE); + span.setTag("custom.bool", true); + span.setTag("custom.int", 7); + span.setTag("custom.long", 8L); + span.setTag("custom.float", 1.5f); + span.setTag("custom.double", 2.5d); + span.setTag("custom.removed", "gone"); + span.setTag("custom.removed", (Object) null); + } + + @Test + void aContributorSetsWhatTheSameSetTagCallsWouldSet() { + DDSpan oneByOne = start(); + DDSpan batched = start(); + try { + writeTags(oneByOne); + TagContributor contributor = DDSpanSetTagsFromTest::writeTags; + assertSame(batched, batched.setTagsFrom(contributor)); + + // compared as plain maps: TagMap.equals isn't Map content equality + assertEquals(new TreeMap<>(oneByOne.getTags()), new TreeMap<>(batched.getTags())); + assertNull(batched.getTag("custom.removed")); + } finally { + oneByOne.finish(); + batched.finish(); + } + } + + @Test + void anExtractorSeesTheSpanTagSemanticsIncludingInterception() { + DDSpan span = start(); + try { + TagExtractor extractor = + (resource, target) -> { + target.setTag(DDTags.RESOURCE_NAME, resource); // intercepted: becomes the resource + target.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_CLIENT); // intercepted: the cached kind + target.setTag(KnownTags.HTTP_ROUTE_ID, "/orders"); + // reads inside the batch see the batch's own writes + target.setTag("custom.route.seen", String.valueOf(target.getTag(Tags.HTTP_ROUTE))); + }; + span.setTagsFrom("GET /orders", extractor); + + assertEquals("GET /orders", span.getResourceName().toString()); + assertEquals(Tags.SPAN_KIND_CLIENT, span.getSpanKindString()); + assertEquals("/orders", span.getTag("custom.route.seen")); + } finally { + span.finish(); + } + } + + @Test + void theSpanLevelSettersWorkInsideTheBatch() { + DDSpan span = start(); + try { + span.setTagsFrom( + (TagContributor) + target -> target.setResourceName("batched").setError(true).setMeasured(true)); + + assertEquals("batched", span.getResourceName().toString()); + assertTrue(span.isError()); + assertTrue(span.isMeasured()); + } finally { + span.finish(); + } + } + + @Test + void aNullSourceOrContributorIsSkipped() { + DDSpan span = start(); + try { + TagExtractor extractor = (source, target) -> target.setTag("custom.ran", true); + assertSame(span, span.setTagsFrom(null, extractor)); + assertSame(span, span.setTagsFrom((TagContributor) null)); + assertFalse(span.getTags().containsKey("custom.ran")); + } finally { + span.finish(); + } + } + + @Test + void theBatchsSpanDataMustNotOutliveTheCall() { + DDSpan span = start(); + try { + AtomicReference kept = new AtomicReference<>(); + span.setTagsFrom((TagContributor) kept::set); + + // assertions are on in tests, so a write outside the lock is caught + assertThrows(AssertionError.class, () -> kept.get().setTag("custom.late", "late")); + } finally { + span.finish(); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanTest.java index 1d252859739..46659a5e9ad 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanTest.java @@ -423,7 +423,6 @@ void inferTopLevelFromParentServiceName( Collections.emptyMap(), false, "fakeType", - 0, tracer.createTraceCollector(DDTraceId.ONE), null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/LongRunningTracesTrackerTest.java b/dd-trace-core/src/test/java/datadog/trace/core/LongRunningTracesTrackerTest.java index 27c8156e27f..aab36487f1e 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/LongRunningTracesTrackerTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/LongRunningTracesTrackerTest.java @@ -228,7 +228,6 @@ private static DDSpan newSpanOf(PendingTrace trace, int samplingPriority, long t Collections.emptyMap(), false, "fakeType", - 0, trace, null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceBufferTest.java b/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceBufferTest.java index e72e5fa3a7c..99bc9481c50 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceBufferTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceBufferTest.java @@ -530,7 +530,6 @@ private static DDSpan newSpanOf(PendingTrace trace, int samplingPriority, long t Collections.emptyMap(), false, "fakeType", - 0, trace, null, null, @@ -556,7 +555,6 @@ private static DDSpan newSpanOf(DDSpan parent) { Collections.emptyMap(), false, "fakeType", - 0, traceCollector, null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceTest.java b/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceTest.java index 7d47a0b17c5..e4b003cb688 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceTest.java @@ -65,7 +65,6 @@ private DDSpan createSimpleSpanWithID(PendingTrace trace, long id) { Collections.emptyMap(), false, "", - 0, trace, null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/RootSpanTagsReadThroughTest.java b/dd-trace-core/src/test/java/datadog/trace/core/RootSpanTagsReadThroughTest.java new file mode 100644 index 00000000000..efda52ef49b --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/RootSpanTagsReadThroughTest.java @@ -0,0 +1,111 @@ +package datadog.trace.core; + +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import datadog.trace.api.DynamicConfig; +import datadog.trace.api.KnownTags; +import datadog.trace.api.config.TracerConfig; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * With builder tags taking precedence, a root span reads its root-span tags through the shared + * trace-level parent instead of holding copies: the same values, none of them stored locally, so + * the span's own store keeps only span-level tags (the slot coloring lets a span-level and a + * trace-level tag share a slot bit). + */ +class RootSpanTagsReadThroughTest extends DDCoreJavaSpecification { + private static final String CUSTOM_ROOT_TAG = "only_root"; + + private CoreTracer tracer; + + @AfterEach + void cleanup() { + if (tracer != null) { + tracer.close(); + } + } + + private CoreTracer tracer(boolean builderTagsPrecedence) { + Map rootTags = new LinkedHashMap<>(); + rootTags.put(KnownTags.LANGUAGE_NAME, "jvm"); + rootTags.put(KnownTags.PROCESS_ID_NAME, 42L); + rootTags.put(CUSTOM_ROOT_TAG, "value"); + Properties properties = new Properties(); + properties.setProperty( + TracerConfig.TRACE_BUILDER_TAGS_PRECEDENCE_ENABLED, String.valueOf(builderTagsPrecedence)); + return tracerBuilder().withProperties(properties).localRootSpanTags(rootTags).build(); + } + + @Test + void aRootSpanReadsItsRootTagsThroughTheTraceLevelParent() throws Exception { + tracer = tracer(false); + int copiedLocally = localKnownTagCount(rootSpanOf(tracer)); + tracer.close(); + + tracer = tracer(true); + DDSpan root = rootSpanOf(tracer); + assertEquals("jvm", root.getTag(KnownTags.LANGUAGE_NAME)); + assertEquals(42L, root.getTag(KnownTags.PROCESS_ID_NAME)); + assertEquals("value", root.getTag(CUSTOM_ROOT_TAG)); + assertEquals(copiedLocally - 2, localKnownTagCount(root)); + } + + @Test + void aChildSpanStillGetsNoRootTags() { + tracer = tracer(true); + AgentSpan root = tracer.buildSpan("test", "root").start(); + AgentSpan child = tracer.buildSpan("test", "child").asChildOf(root.spanContext()).start(); + try { + assertNull(child.getTag(KnownTags.LANGUAGE_NAME)); + assertNull(child.getTag(CUSTOM_ROOT_TAG)); + } finally { + child.finish(); + root.finish(); + } + } + + @Test + void rootSpansShareTheTraceLevelParentUntilTheTracerTagsChange() throws Exception { + tracer = tracer(true); + DDSpan first = rootSpanOf(tracer); + DDSpan second = rootSpanOf(tracer); // same config snapshot: the cached parent + assertEquals("value", first.getTag(CUSTOM_ROOT_TAG)); + assertEquals("value", second.getTag(CUSTOM_ROOT_TAG)); + + // a remote-config tracing_tags change gives new merged tracer tags, so the parent is rebuilt + Field dynamicConfigField = CoreTracer.class.getDeclaredField("dynamicConfig"); + dynamicConfigField.setAccessible(true); + DynamicConfig dynamicConfig = (DynamicConfig) dynamicConfigField.get(tracer); + dynamicConfig.current().setTracingTags(singletonMap("rc.tag", "remote")).apply(); + + DDSpan afterChange = rootSpanOf(tracer); + assertEquals("remote", afterChange.getTag("rc.tag")); + assertEquals("value", afterChange.getTag(CUSTOM_ROOT_TAG)); + } + + private static DDSpan rootSpanOf(CoreTracer tracer) { + AgentSpan span = tracer.buildSpan("test", "root").start(); + span.finish(); + return (DDSpan) span; + } + + /** The known tags the span's own id store holds, not counting what it reads through. */ + private static int localKnownTagCount(DDSpan span) throws Exception { + Field knownTags = DDSpanContext.class.getDeclaredField("knownTags"); + knownTags.setAccessible(true); + Object ids = knownTags.get(span.spanContext()); + Method computeSize = + Class.forName("datadog.trace.api.IdStore").getDeclaredMethod("computeSize"); + computeSize.setAccessible(true); + return (Integer) computeSize.invoke(ids); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java new file mode 100644 index 00000000000..7d880dda6ea --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java @@ -0,0 +1,109 @@ +package datadog.trace.core; + +import static datadog.trace.bootstrap.instrumentation.api.Tags.COMPONENT; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_CLIENT; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_SERVER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +import datadog.trace.api.IdStore; +import datadog.trace.api.SizingHint; +import datadog.trace.api.config.TracerConfig; +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import datadog.trace.common.writer.ListWriter; +import datadog.trace.test.junit.utils.config.WithConfig; +import org.junit.jupiter.api.Test; + +/** + * {@link SpanPrototypeConstructionTest} with the dense store on, where a prototype's known tags are + * seeded in bulk ({@code DDSpanContext.seedPrototypeTags}); runs in its own JVM because the flag is + * read once. Adds the bulk path's fallback: a span that already holds one of the prototype's tags + * is seeded tag by tag, so its own value wins and the rest still land. + */ +@WithConfig(key = TracerConfig.TRACE_DENSE_TAGS_ENABLED, value = "true") +class SpanPrototypeConstructionDenseForkedTest extends SpanPrototypeConstructionTest { + + @Test + void theDenseStoreIsOn() { + // guards the rest of this class: with the flag missed, every test would quietly run on the + // hash store, where the prototype tests pass too + assertEquals("DenseIdStore", IdStore.create(null).getClass().getSimpleName()); + } + + @Test + void aTagTheSpanAlreadyHasKeepsItsValueAndTheRestAreSeeded() { + CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); + try { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initKind(SPAN_KIND_CLIENT) + .initComponentOnly("prototype-component") + .build(); + DDSpan span = (DDSpan) tracer.buildSpan("test-instr", "op").start(); + try { + span.setTag(COMPONENT, "explicit"); + span.spanContext().apply(prototype); + + assertEquals("explicit", span.getTags().get(COMPONENT)); // present: kept + assertEquals(SPAN_KIND_CLIENT, span.getTags().get(SPAN_KIND)); // absent: seeded + assertEquals(SPAN_KIND_CLIENT, span.getSpanKindString()); // and its ordinal + } finally { + span.finish(); + } + } finally { + tracer.close(); + } + } + + @Test + void aPrototypeCachesTheSizingHintForItsOwnOperationNameOnly() { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("sizing.proto.op") + .build(); + SizingHint own = prototype.sizingHint(true, "sizing.proto.op"); + assertNotNull(own); + assertSame(SizingHint.forOperation("sizing.proto.op", true), own, "keyed like the table"); + assertSame(own, prototype.sizingHint(true, UTF8BytesString.create("sizing.proto.op"))); + + // another operation name gets that name's hint, never the prototype's + SizingHint other = prototype.sizingHint(true, "sizing.proto.other"); + assertSame(SizingHint.forOperation("sizing.proto.other", true), other); + assertNotSame(own, other); + + // the entry and child lanes stay apart + assertNotSame(own, prototype.sizingHint(false, "sizing.proto.op")); + } + + @Test + void aPrototypeSeedsEverySpanBuiltFromIt() { + CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); + try { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initKind(SPAN_KIND_SERVER) + .initComponentOnly("prototype-component") + .build(); + for (int i = 0; i < 3; i++) { // the first span builds the cached plan, the rest reuse it + DDSpan span = (DDSpan) tracer.buildSpan(prototype, null).start(); + try { + assertEquals("prototype-component", span.getTags().get(COMPONENT)); + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + } finally { + tracer.close(); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java index 7a3722d77cf..a2303e3b44a 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java @@ -8,6 +8,7 @@ import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; import datadog.trace.common.writer.ListWriter; +import java.lang.reflect.Field; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -88,6 +89,98 @@ void startSpanSeedsPrototype() { } } + @Test + void singleSpanBuilderSeedsPrototype() { + DDSpan span = (DDSpan) tracer.singleSpanBuilder(prototype, null).start(); + try { + assertEquals("proto.op", span.getOperationName().toString()); + assertEquals("web", span.getSpanType()); + assertEquals("test-component", span.getTags().get(COMPONENT)); + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + + @Test + void aReusedSingleSpanBuilderDropsThePrototype() { + // the thread's reusable builder carried a prototype for the first span; reset must clear it + tracer.singleSpanBuilder(prototype, null).start().finish(); + DDSpan plain = (DDSpan) tracer.singleSpanBuilder("other-instr", "plain.op").start(); + try { + assertNull(plain.getTags().get(COMPONENT)); + assertNull(plain.getSpanType()); + } finally { + plain.finish(); + } + } + + @Test + void applyOverwritingAfterStartStampsThePrototype() { + // the decorator path: start by name, then afterStart stamps the prototype over the span + DDSpan span = (DDSpan) tracer.startSpan("test-instr", "decorated.op"); + try { + span.applyOverwriting(prototype); + assertEquals("web", span.getSpanType()); + assertEquals("test-component", span.getTags().get(COMPONENT)); + assertEquals(SPAN_KIND_SERVER, span.getTags().get(SPAN_KIND)); + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + + @Test + void applyOverwritingReplacesATagTheSpanAlreadyHas() { + DDSpan span = (DDSpan) tracer.startSpan("test-instr", "decorated.op"); + try { + span.setTag(COMPONENT, "earlier"); + span.applyOverwriting(prototype); + assertEquals("test-component", span.getTags().get(COMPONENT)); // the prototype wins + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + + @Test + void theSeedPlanTriesTheBulkPathOnlyWhenTheStoreSupportsIt() throws Exception { + // With the hash store (the default) the plan must never be bulk: applyOverwriting would take + // the tag lock for a bulk attempt that always fails, then again for its fallback. + tracer.buildSpan(prototype, null).start().finish(); + Object plan = prototype.seedPlan(); + Field bulk = plan.getClass().getDeclaredField("bulk"); + bulk.setAccessible(true); + assertEquals(prototype.tags().knownStore().supportsBulkSeed(), bulk.getBoolean(plan)); + } + + @Test + void aPrototypesCustomTagIsSeededAndOverwritten() { + SpanPrototype withCustom = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initKind(SPAN_KIND_SERVER) + .initTag("my.custom.constant", "custom-value") + .build(); + DDSpan built = (DDSpan) tracer.buildSpan(withCustom, null).start(); + try { + assertEquals("custom-value", built.getTags().get("my.custom.constant")); + assertEquals(SPAN_KIND_SERVER, built.getSpanKindString()); + } finally { + built.finish(); + } + + DDSpan decorated = (DDSpan) tracer.startSpan("test-instr", "decorated.op"); + try { + decorated.setTag("my.custom.constant", "earlier"); + decorated.applyOverwriting(withCustom); + assertEquals("custom-value", decorated.getTags().get("my.custom.constant")); + } finally { + decorated.finish(); + } + } + @Test void explicitBuilderTagOverridesPrototypeConstant() { // prototype seeds `component` just before the builder's own tags, so the explicit withTag wins diff --git a/dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java b/dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java new file mode 100644 index 00000000000..fd6e413f4e8 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java @@ -0,0 +1,87 @@ +package datadog.trace.core; + +import static datadog.trace.api.KnownTags.HTTP_ROUTE_ID; +import static datadog.trace.api.KnownTags.HTTP_STATUS_CODE_ID; +import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ROUTE; +import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_STATUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; +import datadog.trace.common.writer.ListWriter; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** A known tag's id and name address the same tag, in every {@link TagStore}. */ +class TagStoreTest extends DDCoreJavaSpecification { + private CoreTracer tracer; + + @BeforeEach + void setup() { + tracer = tracerBuilder().writer(new ListWriter()).build(); + } + + @AfterEach + void cleanup() { + tracer.close(); + } + + @Test + void storedByIdLoadsByName() { + forEachTagStore( + tags -> { + tags.store(HTTP_ROUTE_ID, "/users/{id}"); + + assertEquals("/users/{id}", tags.load(HTTP_ROUTE)); + assertEquals("/users/{id}", tags.load(HTTP_ROUTE_ID)); + }); + } + + @Test + void storedByNameLoadsById() { + forEachTagStore( + tags -> { + tags.store(HTTP_ROUTE, "/users/{id}"); + + assertEquals("/users/{id}", tags.load(HTTP_ROUTE_ID)); + }); + } + + @Test + void loadStringByIdConverts() { + forEachTagStore( + tags -> { + tags.store(HTTP_STATUS_CODE_ID, 404); + + assertEquals("404", tags.loadString(HTTP_STATUS_CODE_ID)); + assertEquals("404", tags.loadString(HTTP_STATUS)); + }); + } + + @Test + void nullStoredByIdRemoves() { + forEachTagStore( + tags -> { + tags.store(HTTP_ROUTE, "/users/{id}"); + + tags.store(HTTP_ROUTE_ID, null); + + assertNull(tags.load(HTTP_ROUTE)); + assertNull(tags.loadString(HTTP_ROUTE_ID)); + }); + } + + private void forEachTagStore(Consumer check) { + check.accept(TagMap.create()); + + DDSpan span = (DDSpan) tracer.buildSpan("test", "span").start(); + try { + check.accept(span.spanContext()); + } finally { + span.finish(); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/propagation/AbstractHttpInjectorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/propagation/AbstractHttpInjectorTest.java index 3098ab217ec..306984b790f 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/propagation/AbstractHttpInjectorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/propagation/AbstractHttpInjectorTest.java @@ -51,7 +51,6 @@ protected DDSpanContext mockSpanContext( baggage, false, "fakeType", - 0, this.tracer.createTraceCollector(DDTraceId.ONE), null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java index 6cb01e76f08..235b21d4799 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java @@ -1069,7 +1069,8 @@ void exactlyTheTagsWithTheInterceptedBitHaveACase() throws IllegalAccessExceptio boolean hasCase = consumed || !mockingDetails(span).getInvocations().isEmpty(); assertEquals(KnownTagCodec.isIntercepted(tagId), hasCase, field.getName()); - assertEquals(KnownTagCodec.isIntercepted(tagId), interceptor.needsIntercept(tagId)); + assertEquals( + KnownTagCodec.isIntercepted(tagId), interceptor.needsIntercept(tagId), field.getName()); } } diff --git a/dd-trace-core/src/test/java/datadog/trace/core/tagprocessor/PayloadTagsProcessorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/tagprocessor/PayloadTagsProcessorTest.java index 1405c6329ae..cb91a78789e 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/tagprocessor/PayloadTagsProcessorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/tagprocessor/PayloadTagsProcessorTest.java @@ -203,8 +203,6 @@ void expandPreservingTagTypes() { 3.14d, "payload.tag4.Value.0", "string", - "payload.tag5", - null, "payload.tag6", false); assertEquals(expected, unsafeTags); @@ -246,8 +244,6 @@ void expandStringifiedJsonTags() { 2, "p.j3.2", 3.14d, - "p.j3.3", - null, "p.j3.4", true, "p.j4.foo", @@ -257,6 +253,23 @@ void expandStringifiedJsonTags() { assertEquals(expected, unsafeTags); } + @Test + void nullRemovesEarlierValueAtSamePath() { + PayloadTagsProcessor ptp = tagsProcessor("p", Collections.emptyList(), 10, 758); + + Map st = + spanTags( + "p", + pv(pc().push("j1"), "{'x': 'old', 'x': null, 'y': 1}"), + pv(pc().push("v"), "old"), + pv(pc().push("v"), null)); + + TagMap unsafeTags = TagMap.fromMap(st); + ptp.processTags(unsafeTags, null, link -> {}); + + assertEquals(mapOf("p.j1.y", 1), unsafeTags); + } + @Test void expandSerializedEscapedInnerJsonWithinInnerJson() throws IOException { Buffer b0 = new Buffer(); @@ -410,8 +423,6 @@ void applyRedactionRules() { 2, "p.j3.2", 3.14d, - "p.j3.3", - null, "p.j3.4", true, "p.j4.foo", @@ -444,8 +455,8 @@ void respectMaxTagsLimit() { 2, "p.j3.2", 3.14d, - "p.j3.3", - null, + "p.j3.4", + true, "_dd.payload_tags_incomplete", true), unsafeTags); @@ -474,8 +485,6 @@ void respectMaxDepthLimit() { 2, "p.j3.2", 3.14d, - "p.j3.3", - null, "p.j3.4", true, "p.j3.5.0", diff --git a/gradle/forbiddenApiFilters/instrumentation.txt b/gradle/forbiddenApiFilters/instrumentation.txt index 5ffb1f7868d..102a57de753 100644 --- a/gradle/forbiddenApiFilters/instrumentation.txt +++ b/gradle/forbiddenApiFilters/instrumentation.txt @@ -3,7 +3,13 @@ @defaultMessage Use AgentSpan#setServiceName(String, CharSequence) instead to track the source of the service name. The source should typically be the instrumentation name. datadog.trace.bootstrap.instrumentation.api.AgentSpan#setServiceName(java.lang.String) -# TagMap.internals() skips checks the tracer core has already made; it is @Restricted to the core -# (see datadog.apicontract.Restricted). Instrumentation uses TagMap's checked methods. -@defaultMessage TagMap.internals() is restricted to the tracer core; use TagMap's checked methods instead. -datadog.trace.api.TagMap#internals() +# A span's tag stores, how it gets them from a parent, and the span's view of them skip checks the tracer +# core has already made; they are @Restricted to the core (see datadog.apicontract.Restricted). +# Instrumentation uses TagMap's checked methods. +@defaultMessage Tag stores are restricted to the tracer core; use TagMap's checked methods instead. +datadog.trace.api.HashStore +datadog.trace.api.IdStore +datadog.trace.api.TagMap#readThroughParent(datadog.trace.api.TagMap) +datadog.trace.api.TagMap#viewOf(datadog.trace.api.IdStore,datadog.trace.api.HashStore,boolean) +datadog.trace.api.TagMap#customStore() +datadog.trace.api.TagMap#isCustomStoreBorrowed() diff --git a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java index c72a26430af..96d81b91095 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java @@ -293,7 +293,7 @@ public long hashOf(CIEntry entry) { static CIEntry[] _create_flat(float loadFactor) { // 16 distinct case-insensitive keys (foo-0..quux-3). CIEntry[] table = - FlatHashtable.create(CIEntry.class, PREFIXES.length * NUM_SUFFIXES, loadFactor); + FlatHashtable.create(PREFIXES.length * NUM_SUFFIXES, loadFactor, CIEntry[]::new); for (int suffix = 0; suffix < NUM_SUFFIXES; ++suffix) { for (String prefix : PREFIXES) { String key = prefix + "-" + suffix; diff --git a/internal-api/src/jmh/java/datadog/trace/util/FlatHashtableIteratorBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/FlatHashtableIteratorBenchmark.java index 43d928176b1..ef5b060f207 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/FlatHashtableIteratorBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/FlatHashtableIteratorBenchmark.java @@ -120,7 +120,7 @@ public long hashOf(ItEntry entry) { @Setup(Level.Trial) public void setUp() { // Sparse so the bucket is one contiguous run with a clean terminating empty slot. - table = FlatHashtable.create(ItEntry.class, BUCKET_SIZE, FlatHashtable.LOW_LOAD_FACTOR); + table = FlatHashtable.create(BUCKET_SIZE, FlatHashtable.LOW_LOAD_FACTOR, ItEntry[]::new); for (int i = 0; i < BUCKET_SIZE; ++i) { FlatHashtable.insert(table, new ItEntry(BUCKET_HASH, i)); // all share the hash => one bucket } diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java index bd3c0e83cc6..3ab316d25db 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java @@ -209,7 +209,7 @@ public long hashOf(IntEntry entry) { static IntEntry[] newFilledFlat() { // Sized to the key count (FlatHashtable is fixed-capacity, no resize): load factor <= 0.5. - IntEntry[] table = FlatHashtable.create(IntEntry.class, INSERTION_KEYS.length); + IntEntry[] table = FlatHashtable.create(INSERTION_KEYS.length, IntEntry[]::new); for (int i = 0; i < INSERTION_KEYS.length; ++i) { FlatHashtable.insert(table, new IntEntry(INSERTION_KEYS[i], i), IntEntryKeyStrategy.INSTANCE); } diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 83fc85c38dd..25c0fdaed74 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -703,6 +703,7 @@ import static datadog.trace.api.config.TracerConfig.TRACE_CLOUD_PAYLOAD_TAGGING_SERVICES; import static datadog.trace.api.config.TracerConfig.TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING; import static datadog.trace.api.config.TracerConfig.TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING; +import static datadog.trace.api.config.TracerConfig.TRACE_DENSE_TAGS_ENABLED; import static datadog.trace.api.config.TracerConfig.TRACE_EXPERIMENTAL_FEATURES_ENABLED; import static datadog.trace.api.config.TracerConfig.TRACE_GIT_METADATA_ENABLED; import static datadog.trace.api.config.TracerConfig.TRACE_HTTP_CLIENT_ERROR_STATUSES; @@ -1455,6 +1456,7 @@ public static String getHostName() { private final boolean jdkSocketEnabled; private final boolean spanBuilderReuseEnabled; + private final boolean traceDenseTagsEnabled; private final int tagNameUtf8CacheSize; private final int tagValueUtf8CacheSize; private final int stackTraceLengthLimit; @@ -3460,6 +3462,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) this.spanBuilderReuseEnabled = configProvider.getBoolean(GeneralConfig.SPAN_BUILDER_REUSE_ENABLED, true); + this.traceDenseTagsEnabled = configProvider.getBoolean(TRACE_DENSE_TAGS_ENABLED, true); this.tagNameUtf8CacheSize = Math.max(configProvider.getInteger(GeneralConfig.TAG_NAME_UTF8_CACHE_SIZE, 128), 0); this.tagValueUtf8CacheSize = @@ -5425,6 +5428,10 @@ public boolean isJdkSocketEnabled() { return jdkSocketEnabled; } + public boolean isTraceDenseTagsEnabled() { + return traceDenseTagsEnabled; + } + public boolean isSpanBuilderReuseEnabled() { return spanBuilderReuseEnabled; } diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java new file mode 100644 index 00000000000..0a331a8d051 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -0,0 +1,947 @@ +package datadog.trace.api; + +import datadog.trace.api.TagMap.EntryReader; +import datadog.trace.api.function.TriConsumer; +import java.util.Arrays; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import javax.annotation.Nullable; + +/** + * The dense {@link IdStore}: known tags in two parallel arrays -- each tag's 16-bit serial and its + * value -- with no Entry per tag, reading through to the frozen parent's store. + * + *

    A lookup is a linear scan of the serials, skipped when the slot and level masks rule the tag + * out (see {@link #spanOccupancy}). The full id is rebuilt from the serial ({@link + * KnownTagCodec#idOf}) only where a reader asks for it. Values are boxed: a primitive set by id is + * stored as its wrapper. + * + *

    Point reads that need only the value ({@link #load}) build nothing. Those that return a reader + * or an Entry build their own ({@link #getEntryReader}, {@link #getEntry}), so concurrent reads of + * a frozen store shared between threads never share state. Only writes answer their prior value + * with this store's one reused flyweight, valid until the store's next operation (see {@link + * IdStore}). + * + *

    Removing a tag that hides nothing inherited swaps the last slot into its place. Removing a tag + * the parent chain also holds stores the shared {@link #TOMBSTONE} as its value instead, and sets + * its slot in the tombstone mask. A tombstone is never counted, iterated or returned. + */ +final class DenseIdStore extends IdStore { + /** A value hiding an inherited tag. Never escapes the store. */ + private static final Object TOMBSTONE = new Object(); + + /** First capacity, on a store's first tag. */ + static final int INIT_CAP = 12; + + private static final char[] NO_SERIALS = new char[0]; + private static final Object[] NO_VALUES = new Object[0]; + + /** + * The reused flyweight a dense store answers with: a known tag's id and value, with no Entry. Its + * name is resolved from the id only when asked for. + */ + static class DenseReader implements EntryReader { + private long tagId; + private Object value; + private String tag; // resolved lazily via nameOf on first tag() access + + void set(long tagId, Object value) { + this.tagId = tagId; + this.value = value; + this.tag = null; + } + + @Override + public final String tag() { + String tag = this.tag; + if (tag == null) { + tag = KnownTagCodec.nameOf(this.tagId); + this.tag = tag; + } + return tag; + } + + @Override + public final long tagId() { + return this.tagId; + } + + @Override + public final byte type() { + return TagValueConversions.typeOf(this.value); + } + + @Override + public final boolean is(byte type) { + return TagValueConversions.isA(this.value, type); + } + + @Override + public final boolean isNumber() { + return TagValueConversions.isNumber(this.value); + } + + @Override + public final boolean isNumericPrimitive() { + return TagValueConversions.isNumericPrimitive(this.value); + } + + @Override + public final boolean isObject() { + return TagValueConversions.isObject(this.value); + } + + @Override + public final boolean booleanValue() { + return TagValueConversions.toBoolean(this.value); + } + + @Override + public final int intValue() { + return TagValueConversions.toInt(this.value); + } + + @Override + public final long longValue() { + return TagValueConversions.toLong(this.value); + } + + @Override + public final float floatValue() { + return TagValueConversions.toFloat(this.value); + } + + @Override + public final double doubleValue() { + return TagValueConversions.toDouble(this.value); + } + + @Override + public final String stringValue() { + return TagValueConversions.toString(this.value); + } + + @Override + public final Object objectValue() { + return this.value; + } + + @Override + public final TagMap.Entry entry() { + return TagMap.Entry.newAnyEntry(this.tagId, this.value); + } + + @Override + public final Map.Entry mapEntry() { + return this.entry(); + } + } + + /** + * Walks the leaf's slots, then each ancestor's, skipping tombstones and shadowed tags. The cursor + * is its own flyweight: {@link #next} points it at the next visible tag and returns it. + */ + private static final class DenseCursor extends DenseReader implements Cursor { + private final DenseIdStore leaf; + private DenseIdStore level; + private int index = -1; + + DenseCursor(DenseIdStore leaf) { + this.leaf = leaf; + this.level = leaf; + } + + @Override + public EntryReader next() { + DenseIdStore level = this.level; + while (level != null) { + for (++this.index; this.index < level.used; ++this.index) { + if (this.leaf.readIfVisible(level, this.index, this)) { + return this; + } + } + level = level.parent; + this.level = level; + this.index = -1; + } + return null; + } + } + + /** The serial of each local tag or tombstone, in the first {@link #used} slots. */ + private char[] serials = NO_SERIALS; + + private Object[] values = NO_VALUES; + + /** Slots in use, tombstones included. */ + private int used; + + /** Live local tags, not counting tombstones. */ + private int count; + + /* + * The slot masks: a tag's slot (KnownTagCodec.slotOf) maps it to one bit of a long. A bit is a + * may-be-present hint -- tags that don't usually co-occur share a slot -- so a clear bit is a + * definite miss and a set bit means "scan". The masks are never cleared by a removal, only + * widened. The hash store needs none: its buckets are the exact state. + * + * Span-level and trace-level tags are colored separately and reuse the same slots, since they + * are meant to live in separate stores (a span's trace-level tags in the frozen parent it reads + * through). So each store also records which levels it holds, local and inherited, and a lookup + * consults the slot bits only for a level that is present: in a store holding one level the word + * is exact, and a store holding both still works, with a cross-level collision costing a scan. + */ + + /** + * Slots that may hold a live local span-level entry. Span-level and trace-level tags are colored + * independently, so their slot numbers overlap; a local root span holds both, so each level keeps + * its own word and a span-level miss isn't defeated by a trace-level tag in the same slot. + */ + long spanOccupancy; + + /** Slots that may hold a live local trace-level entry; see {@link #spanOccupancy}. */ + long traceOccupancy; + + /** Slots that may hold a local tombstone, hiding an inherited entry. */ + long tombstoneMask; + + /** + * Slots an ancestor may hold, fixed at construction: ancestors are frozen. A clear bit lets a + * local miss skip read-through. + */ + final long inheritedMask; + + /** The levels ({@link #SPAN_LEVEL}, {@link #TRACE_LEVEL}) of the local tombstones. */ + byte tombstoneLevels; + + /** The levels an ancestor may hold, fixed at construction like {@link #inheritedMask}. */ + final byte inheritedLevels; + + static final int SPAN_LEVEL = 1; + static final int TRACE_LEVEL = 2; + + /** The frozen parent's id store, or null. */ + final DenseIdStore parent; + + /** + * The per-operation hint this store was presized from, and that {@link #recordSize} tunes, or + * null. Held here rather than on the span so a span with the dense store off carries no field for + * it. + */ + @Nullable private final SizingHint hint; + + /** + * The reused flyweight that writes ({@code put}, {@code getAndPut}, {@code remove}) answer their + * prior value with; built on first use. Only writes use it: a store being written has a single + * writer, while a frozen store that threads share is only read, and reads build their own. + */ + private DenseReader reader; + + DenseIdStore(IdStore parent) { + this(parent, null); + } + + /** + * Like {@link #DenseIdStore(IdStore)}, with the arrays allocated up front at {@code hint}'s size. + * A span's store presized this way never grows on its first tag, so {@link #grow} stays off the + * write path. A null hint, or one not yet seeded (a racy reader can see 0), keeps the shared + * empty arrays. + */ + DenseIdStore(IdStore parent, @Nullable SizingHint hint) { + DenseIdStore dense = (DenseIdStore) parent; + this.parent = dense; + if (dense == null) { + this.inheritedMask = 0L; + this.inheritedLevels = 0; + } else { + this.inheritedMask = dense.spanOccupancy | dense.traceOccupancy | dense.inheritedMask; + this.inheritedLevels = (byte) (dense.localLevels() | dense.inheritedLevels); + } + this.hint = hint; + if (hint != null) { + int cap = hint.size; // read the racy field once + if (cap > 0) { + this.serials = new char[cap]; + this.values = new Object[cap]; + } + } + } + + /** {@code tagId}'s slot bit. */ + static long slotBit(long tagId) { + return 1L << KnownTagCodec.slotOf(tagId); + } + + /** {@code tagId}'s level: {@link #SPAN_LEVEL} or {@link #TRACE_LEVEL}, without a branch. */ + static int levelOf(long tagId) { + return 1 << (int) ((tagId & KnownTagCodec.LEVEL_TRACE) >>> 2); + } + + /** The occupancy word of {@code level}: a select that folds when the level is constant. */ + long occupancy(int level) { + return level == TRACE_LEVEL ? this.traceOccupancy : this.spanOccupancy; + } + + /** The levels of the local entries and tombstones. */ + int localLevels() { + return (this.spanOccupancy != 0L ? SPAN_LEVEL : 0) + | (this.traceOccupancy != 0L ? TRACE_LEVEL : 0) + | this.tombstoneLevels; + } + + /** Whether the slot {@code bit} may hold a local entry or tombstone of {@code level}. */ + boolean mayHaveLocal(long bit, int level) { + return (occupancy(level) & bit) != 0L + || ((tombstoneLevels & level) != 0 && (tombstoneMask & bit) != 0L); + } + + /** Whether an ancestor may hold a tag of {@code level} in the slot {@code bit}. */ + boolean mayInherit(long bit, int level) { + return (inheritedLevels & level) != 0 && (inheritedMask & bit) != 0L; + } + + /** Records a live local entry in the slot {@code bit} of {@code level}. */ + void markLocal(long bit, int level) { + if (level == TRACE_LEVEL) { + this.traceOccupancy |= bit; + } else { + this.spanOccupancy |= bit; + } + } + + /** Records a local tombstone in the slot {@code bit} of {@code level}. */ + void markTombstone(long bit, int level) { + this.tombstoneMask |= bit; + this.tombstoneLevels |= (byte) level; + } + + /** + * The bulk seed: when {@code source} is a parentless dense store with no tombstones and its + * occupancy words share no bit with this store's (live, tombstoned or inherited, per level), none + * of its tags can be present here, so its serials and values are appended with two array copies + * and its occupancy ORed in. The occupancy words over-approximate presence, so a shared bit only + * means "maybe present" and falls back to the caller's tag-by-tag seed. + */ + @Override + public boolean supportsBulkSeed() { + return true; + } + + @Override + public boolean seedAbsent(IdStore source) { + if (!(source instanceof DenseIdStore)) { + return false; + } + DenseIdStore src = (DenseIdStore) source; + int n = src.used; + if (src.parent != null || n != src.count) { + return false; // read-through or tombstones: not a plain list of tags + } + long maybeHere = this.tombstoneMask | this.inheritedMask; + if ((src.spanOccupancy & (this.spanOccupancy | maybeHere)) != 0L + || (src.traceOccupancy & (this.traceOccupancy | maybeHere)) != 0L) { + return false; + } + if (n == 0) { + return true; + } + int used = this.used; + if (used + n > this.serials.length) { + this.growTo(used + n); + } + System.arraycopy(src.serials, 0, this.serials, used, n); + System.arraycopy(src.values, 0, this.values, used, n); + this.used = used + n; + this.count += n; + this.spanOccupancy |= src.spanOccupancy; + this.traceOccupancy |= src.traceOccupancy; + return true; + } + + /** + * The partial bulk seed: when the occupancy words share no bit, the two array copies of {@link + * #seedAbsent(IdStore)}; otherwise a walk over {@code source} that appends each tag whose slot + * bit is clear here and hands the others, which may be present, to {@code maybePresent}. The bits + * are checked against this store as it was before the walk: {@code source}'s tags are distinct, + * so one appended here can't make a later one present. + */ + @Override + public boolean seedPartial( + IdStore source, T thisObj, BiConsumer maybePresent) { + if (this.seedAbsent(source)) { + return true; + } + if (!(source instanceof DenseIdStore)) { + return false; + } + DenseIdStore src = (DenseIdStore) source; + int n = src.used; + if (src.parent != null || n != src.count) { + return false; // read-through or tombstones: not a plain list of tags + } + long maybeHere = this.tombstoneMask | this.inheritedMask; + long spanHere = this.spanOccupancy | maybeHere; + long traceHere = this.traceOccupancy | maybeHere; + if (this.used + n > this.serials.length) { + this.growTo(this.used + n); + } + DenseReader collided = null; + for (int i = 0; i < n; i++) { + char serial = src.serials[i]; + long tagId = KnownTagCodec.idOf(serial); + long bit = slotBit(tagId); + int level = levelOf(tagId); + if (((level == TRACE_LEVEL ? traceHere : spanHere) & bit) != 0L) { + if (collided == null) { + collided = new DenseReader(); // not this.reader: maybePresent's writes reuse that + } + collided.set(tagId, src.values[i]); + maybePresent.accept(thisObj, collided); + } else { + // re-read used and the arrays: maybePresent may have appended or grown them + int at = this.used; + if (at == this.serials.length) { + this.growTo(at + 1); + } + this.serials[at] = serial; + this.values[at] = src.values[i]; + this.used = at + 1; + this.count++; + this.markLocal(bit, level); + } + } + return true; + } + + /** Grows the arrays to hold at least {@code minCapacity} slots. Rare, so kept out of line. */ + private void growTo(int minCapacity) { + int newCap = + Math.max(minCapacity, Math.min(this.serials.length << 1, KnownTagCodec.serialLimit())); + this.serials = Arrays.copyOf(this.serials, newCap); + this.values = Arrays.copyOf(this.values, newCap); + } + + @Override + public void recordSize() { + // Capped hints (the shared overflow) are fixed: unlike sharers mustn't grow them. Best-effort + // racy max; a benign plain-int write. + SizingHint hint = this.hint; + if (hint != null && !hint.capped) { + int used = this.used; + if (used > hint.size) { + hint.size = used; + } + } + } + + /** + * A fresh reader per call, never the store's reused one: point reads can come from several + * threads at once on a frozen store they share. A short-lived reader is usually scalar-replaced. + */ + @Override + public EntryReader getEntryReader(long tagId) { + Object value = this.load(tagId); + if (value == null) { + return null; + } + DenseReader reader = new DenseReader(); + reader.set(tagId, value); + return reader; + } + + @Override + public TagMap.Entry getEntry(long tagId) { + Object value = this.load(tagId); + return value == null ? null : TagMap.Entry.newAnyEntry(tagId, value); + } + + @Override + public Object load(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + return this.getVisible((char) KnownTagCodec.serialNum(tagId), slotBit(tagId), levelOf(tagId)); + } + + @Override + public String loadString(long tagId) { + Object value = this.load(tagId); + return value == null ? null : value.toString(); + } + + /** + * The visible value of the tag {@code serial} in the slot {@code bit} of {@code level}, or null. + */ + private Object getVisible(char serial, long bit, int level) { + if (this.mayHaveLocal(bit, level)) { + int i = this.indexOf(serial); + if (i >= 0) { + Object value = this.values[i]; + return value == TOMBSTONE ? null : value; + } + } + return this.mayInherit(bit, level) ? this.parent.getVisible(serial, bit, level) : null; + } + + @Override + public void store(long tagId, Object value) { + if (value == null) { + this.remove(tagId); // a null value removes the tag + return; + } + this.storeLocal(tagId, value); + } + + @Override + public void store(long tagId, CharSequence value) { + if (value == null) { + this.remove(tagId); // a null value removes the tag + return; + } + this.storeLocal(tagId, value); + } + + @Override + public void store(long tagId, boolean value) { + this.storeLocal(tagId, Boolean.valueOf(value)); + } + + @Override + public void store(long tagId, int value) { + this.storeLocal(tagId, Integer.valueOf(value)); + } + + @Override + public void store(long tagId, long value) { + this.storeLocal(tagId, Long.valueOf(value)); + } + + @Override + public void store(long tagId, float value) { + this.storeLocal(tagId, Float.valueOf(value)); + } + + @Override + public void store(long tagId, double value) { + this.storeLocal(tagId, Double.valueOf(value)); + } + + @Override + public EntryReader put(EntryReader entryReader) { + Object value = entryReader.objectValue(); + if (value == null) { + return null; // a reader with nothing to store is a no-op, as for TagMap.set + } + long tagId = entryReader.tagId(); + return this.readerOf(tagId, this.storeLocal(tagId, value)); + } + + @Override + public EntryReader getAndPut(EntryReader entryReader) { + if (entryReader.objectValue() == null) { + return null; // a reader with nothing to store is a no-op + } + long tagId = entryReader.tagId(); + char serial = (char) KnownTagCodec.serialNum(tagId); + long bit = slotBit(tagId); + int level = levelOf(tagId); + int i = this.mayHaveLocal(bit, level) ? this.indexOf(serial) : -1; + Object prior; + if (i >= 0) { + prior = this.values[i]; + if (prior == TOMBSTONE) { + prior = null; // tombstoned, so not visible + this.markLocal(bit, level); + this.count++; + } + } else { + prior = this.mayInherit(bit, level) ? this.parent.getVisible(serial, bit, level) : null; + i = this.appendSlot(serial, bit, level); + } + this.values[i] = entryReader.objectValue(); // the one reference store; see store + return this.readerOf(tagId, prior); + } + + @Override + public EntryReader remove(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + char serial = (char) KnownTagCodec.serialNum(tagId); + long bit = slotBit(tagId); + int level = levelOf(tagId); + + int i = this.mayHaveLocal(bit, level) ? this.indexOf(serial) : -1; + Object local = null; + if (i >= 0) { + local = this.values[i]; + if (local == TOMBSTONE) { + return null; // already removed + } + } + if (this.mayInherit(bit, level)) { + // Read-through: hide an inherited value with a tombstone. The prior *visible* value is the + // local one if there was one, otherwise the inherited one. + Object inherited = this.parent.getVisible(serial, bit, level); + if (inherited != null) { + if (i >= 0) { + this.values[i] = TOMBSTONE; + this.count--; + } else { + int slot = this.reserveSlot(serial); // first: it may replace this.values + this.values[slot] = TOMBSTONE; + } + this.markTombstone(bit, level); + return this.readerOf(tagId, local != null ? local : inherited); + } + } + if (i >= 0) { + this.removeAt(i); + this.count--; + } + return this.readerOf(tagId, local); + } + + /** Stores a known tag's value, returning the prior local value, or null. Builds no Entry. */ + private Object storeLocal(long tagId, Object value) { + char serial = (char) KnownTagCodec.serialNum(tagId); + long bit = slotBit(tagId); + int level = levelOf(tagId); + int i = this.mayHaveLocal(bit, level) ? this.indexOf(serial) : -1; + Object prior; + if (i >= 0) { + prior = this.values[i]; + if (prior == TOMBSTONE) { + prior = null; // a tombstone replaced is a removal undone: no prior local value + this.markLocal(bit, level); + this.count++; + } + } else { + prior = null; + i = this.appendSlot(serial, bit, level); + } + // The one reference store on this path. Under G1 (before JDK 24's late barrier expansion) C2 + // expands each reference-store site's write barriers inline, roughly 250 bytes apiece, so the + // overwrite and append paths share this store rather than each having their own: that keeps + // the setters that inline this small enough to inline into their callers. + this.values[i] = value; + return prior; + } + + /** Reserves a slot for a new live local tag, returning its index; the caller stores the value. */ + private int appendSlot(char serial, long bit, int level) { + int i = this.reserveSlot(serial); + this.markLocal(bit, level); + this.count++; + return i; + } + + /** Reserves the next slot for {@code serial}, returning its index; its value is left unset. */ + private int reserveSlot(char serial) { + int i = this.used; + if (i == this.serials.length) { + this.grow(); + } + this.serials[i] = serial; + this.used = i + 1; + return i; + } + + /** Removes slot {@code i}, swapping the last slot into its place. The masks stay as they were. */ + private void removeAt(int i) { + int last = --this.used; + this.serials[i] = this.serials[last]; + this.values[i] = this.values[last]; + this.serials[last] = 0; + this.values[last] = null; + } + + /** Index of {@code serial} among the used slots, or -1. */ + private int indexOf(char serial) { + char[] serials = this.serials; + for (int i = 0; i < this.used; ++i) { + if (serials[i] == serial) return i; + } + return -1; + } + + /** + * Makes room for one more slot: the first from the shared empty arrays at {@link #INIT_CAP}, then + * doubling, bounded by {@link KnownTagCodec#serialLimit()} -- a store holds each known tag at + * most once. Out of line, as it is rare after the first. + */ + private void grow() { + int length = this.serials.length; + if (length == 0) { + this.serials = new char[INIT_CAP]; + this.values = new Object[INIT_CAP]; + } else { + this.growTo(length + 1); // doubles, bounded by the serial limit + } + } + + /** The reused flyweight over a write's prior {@code value}, or null when there is none. */ + private EntryReader readerOf(long tagId, Object value) { + if (value == null) { + return null; + } + DenseReader reader = this.reader; + if (reader == null) { + reader = new DenseReader(); + this.reader = reader; + } + reader.set(tagId, value); + return reader; + } + + @Override + int size() { + return this.parent == null ? this.count : this.count + this.visibleParentCount(); + } + + /** Ancestor tags not shadowed or tombstoned by a nearer level, nearest-level-wins. */ + private int visibleParentCount() { + int count = 0; + for (DenseIdStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + for (int i = 0; i < ancestor.used; ++i) { + if (ancestor.values[i] != TOMBSTONE && this.visibleFrom(ancestor, i)) { + count++; + } + } + } + return count; + } + + @Override + boolean isEmpty() { + if (this.count != 0) { + return false; + } + DenseIdStore parent = this.parent; + if (parent == null) { + return true; + } + if (this.tombstoneMask == 0L) { + return parent.isEmpty(); // nothing hidden: the parent's tags are all visible + } + return this.visibleParentCount() == 0; + } + + @Override + boolean isDefinitelyEmpty() { + for (DenseIdStore level = this; level != null; level = level.parent) { + if (level.count != 0) { + return false; + } + } + return true; + } + + @Override + int estimateSize() { + int total = 0; + for (DenseIdStore level = this; level != null; level = level.parent) { + total += level.count; + } + return total; + } + + @Override + boolean hasParent() { + return this.parent != null; + } + + /** + * Whether slot {@code i} of the ancestor level {@code fromAncestor} is visible through this leaf: + * no nearer level holds a value or a tombstone for its tag. A nearer level whose masks rule the + * slot out is skipped without a scan. + */ + private boolean visibleFrom(DenseIdStore fromAncestor, int i) { + char serial = fromAncestor.serials[i]; + long tagId = KnownTagCodec.idOf(serial); + long bit = slotBit(tagId); + int level = levelOf(tagId); + for (DenseIdStore nearer = this; nearer != fromAncestor; nearer = nearer.parent) { + if (nearer.mayHaveLocal(bit, level) && nearer.indexOf(serial) >= 0) { + return false; + } + } + return true; + } + + /** Points {@code reader} at slot {@code i} of {@code level}, if it holds a visible tag. */ + private boolean readIfVisible(DenseIdStore level, int i, DenseReader reader) { + Object value = level.values[i]; + if (value == TOMBSTONE || (level != this && !this.visibleFrom(level, i))) { + return false; + } + reader.set(KnownTagCodec.idOf(level.serials[i]), value); + return true; + } + + @Override + public void forEach(Consumer consumer) { + DenseReader reader = new DenseReader(); + for (DenseIdStore level = this; level != null; level = level.parent) { + for (int i = 0; i < level.used; ++i) { + if (this.readIfVisible(level, i, reader)) { + consumer.accept(reader); + } + } + } + } + + @Override + public void forEach(T thisObj, BiConsumer consumer) { + DenseReader reader = new DenseReader(); + for (DenseIdStore level = this; level != null; level = level.parent) { + for (int i = 0; i < level.used; ++i) { + if (this.readIfVisible(level, i, reader)) { + consumer.accept(thisObj, reader); + } + } + } + } + + @Override + public void forEach( + T thisObj, U otherObj, TriConsumer consumer) { + DenseReader reader = new DenseReader(); + for (DenseIdStore level = this; level != null; level = level.parent) { + for (int i = 0; i < level.used; ++i) { + if (this.readIfVisible(level, i, reader)) { + consumer.accept(thisObj, otherObj, reader); + } + } + } + } + + @Override + Cursor cursor() { + return new DenseCursor(this); + } + + @Override + IdStore copy() { + DenseIdStore copy = new DenseIdStore(this.parent); + if (this.used != 0) { + copy.serials = Arrays.copyOf(this.serials, this.serials.length); + copy.values = Arrays.copyOf(this.values, this.values.length); + } + copy.used = this.used; + copy.count = this.count; + copy.spanOccupancy = this.spanOccupancy; + copy.traceOccupancy = this.traceOccupancy; + copy.tombstoneMask = this.tombstoneMask; + copy.tombstoneLevels = this.tombstoneLevels; + return copy; + } + + @Override + void putAllLocal(IdStore other) { + DenseIdStore that = (DenseIdStore) other; + if (that.count == 0) { + return; + } + if (this.used == 0 && that.used == that.count) { + // nothing here and no tombstones there: take copies of its arrays + this.serials = Arrays.copyOf(that.serials, that.serials.length); + this.values = Arrays.copyOf(that.values, that.values.length); + this.used = that.used; + this.count = that.count; + this.spanOccupancy |= that.spanOccupancy; + this.traceOccupancy |= that.traceOccupancy; + return; + } + for (int i = 0; i < that.used; ++i) { + Object value = that.values[i]; + if (value != TOMBSTONE) { + this.storeLocal(KnownTagCodec.idOf(that.serials[i]), value); + } + } + } + + @Override + void fillMapLocal(Map map) { + for (int i = 0; i < this.used; ++i) { + Object value = this.values[i]; + if (value != TOMBSTONE) { + map.put(nameAt(i), value); + } + } + } + + @Override + void fillStringMapLocal(Map map) { + for (int i = 0; i < this.used; ++i) { + Object value = this.values[i]; + if (value != TOMBSTONE) { + map.put(nameAt(i), TagValueConversions.toString(value)); + } + } + } + + private String nameAt(int i) { + return KnownTagCodec.nameOf(KnownTagCodec.idOf(this.serials[i])); + } + + @Override + void checkIntegrity() { + if (this.used > this.serials.length || this.serials.length != this.values.length) { + throw new IllegalStateException("incorrect capacity"); + } + for (int i = 0; i < this.used; ++i) { + char serial = this.serials[i]; + if (serial == 0 || serial >= KnownTagCodec.serialLimit()) { + throw new IllegalStateException("not a known serial: " + (int) serial); + } + long tagId = KnownTagCodec.idOf(serial); + boolean tombstone = this.values[i] == TOMBSTONE; + long mask = tombstone ? this.tombstoneMask : this.occupancy(levelOf(tagId)); + if ((mask & slotBit(tagId)) == 0L) { + throw new IllegalStateException("slot bit not set: " + nameAt(i)); + } + if (tombstone && (this.tombstoneLevels & levelOf(tagId)) == 0) { + throw new IllegalStateException("level not set: " + nameAt(i)); + } + if (this.values[i] == null) { + throw new IllegalStateException("null value: " + nameAt(i)); + } + for (int j = i + 1; j < this.used; ++j) { + if (this.serials[j] == serial) { + throw new IllegalStateException("duplicate tag: " + nameAt(i)); + } + } + } + if (this.count != this.computeSize()) { + throw new IllegalStateException("incorrect size"); + } + } + + @Override + int computeSize() { + int live = 0; + for (int i = 0; i < this.used; ++i) { + if (this.values[i] != TOMBSTONE) live++; + } + return live; + } + + @Override + String toInternalString() { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < this.used; ++i) { + Object value = this.values[i]; + builder + .append('[') + .append(i) + .append("]: ") + .append(nameAt(i)) + .append('=') + .append(value == TOMBSTONE ? "" : value) + .append('\n'); + } + return builder.toString(); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java new file mode 100644 index 00000000000..4d6f4cbfaf2 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -0,0 +1,430 @@ +package datadog.trace.api; + +import datadog.trace.api.TagMap.BucketGroup; +import datadog.trace.api.TagMap.Entry; +import datadog.trace.api.TagMap.EntryReader; +import datadog.trace.api.function.TriConsumer; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * The hash {@link IdStore}: known tags in their own bucket array ({@link HashSupport}), keyed by + * id, reading through to the frozen parent's id store. + * + *

    The buckets are the exact state: a read probes them, and a local miss reads through to the + * parent if there is one. Removing an inherited tag puts that tag's shared tombstone ({@link + * Entry#tombstoneOf}) in the local buckets, so it allocates nothing. A tombstone is never counted, + * iterated or returned. + */ +final class HashIdStore extends IdStore { + /** An empty parentless store over one bucket, for {@link TagMap#EMPTY}, which is frozen. */ + static HashIdStore createEmpty() { + return new HashIdStore(new Object[1]); + } + + /** Walks the leaf's buckets, then each ancestor's, skipping tombstones and shadowed entries. */ + /** Walks the store's levels; it is its own bucket walker, so a cursor is one allocation. */ + private static final class HashCursor extends HashSupport.BucketWalker implements Cursor { + private final HashIdStore leaf; + private HashIdStore level; + + HashCursor(HashIdStore leaf) { + this.leaf = leaf; + this.level = leaf; + this.reset(leaf.buckets); + } + + @Override + public Entry next() { + HashIdStore level = this.level; + while (level != null) { + Entry entry = this.nextEntry(); + if (entry != null) { + if (!entry.tombstone + && (level == this.leaf || this.leaf.parentEntryVisible(entry, level))) { + return entry; + } + continue; + } + level = level.parent; + this.level = level; + if (level != null) { + this.reset(level.buckets); + } + } + return null; + } + } + + final Object[] buckets; + + /** Live local entries, not counting tombstones. */ + int count; + + /** The frozen parent's id store, or null. */ + final HashIdStore parent; + + /** Whether the buckets may hold a tombstone; a hint that keeps the common case on fast paths. */ + boolean hasTombstones; + + HashIdStore(IdStore parent) { + this.buckets = HashSupport.newBuckets(); + this.parent = (HashIdStore) parent; + } + + private HashIdStore(Object[] buckets) { + this.buckets = buckets; + this.parent = null; + } + + @Override + public Entry getEntry(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + return this.getVisible(tagId); + } + + /** The stored Entry is itself a stable reader, so this is {@link #getEntry}. */ + @Override + public Entry getEntryReader(long tagId) { + return this.getEntry(tagId); + } + + /** {@link #getEntry} for a validated id. */ + private Entry getVisible(long tagId) { + Entry local = HashSupport.findById(this.buckets, Entry.bucketHash(tagId), tagId); + if (local != null) { + return local.tombstone ? null : local; + } + HashIdStore parent = this.parent; + return parent == null ? null : parent.getVisible(tagId); + } + + @Override + public Object load(long tagId) { + Entry entry = this.getEntry(tagId); + return entry == null ? null : entry.objectValue(); + } + + @Override + public String loadString(long tagId) { + Entry entry = this.getEntry(tagId); + return entry == null ? null : entry.stringValue(); + } + + @Override + public void store(long tagId, Object value) { + if (value == null) { + this.remove(tagId); // a null value removes the tag + return; + } + this.put(Entry.newAnyEntry(tagId, value)); + } + + @Override + public void store(long tagId, CharSequence value) { + if (value == null) { + this.remove(tagId); // a null value removes the tag + return; + } + this.put(Entry.newObjectEntry(tagId, value)); + } + + @Override + public void store(long tagId, boolean value) { + this.put(Entry.newBooleanEntry(tagId, value)); + } + + @Override + public void store(long tagId, int value) { + this.put(Entry.newIntEntry(tagId, value)); + } + + @Override + public void store(long tagId, long value) { + this.put(Entry.newLongEntry(tagId, value)); + } + + @Override + public void store(long tagId, float value) { + this.put(Entry.newFloatEntry(tagId, value)); + } + + @Override + public void store(long tagId, double value) { + this.put(Entry.newDoubleEntry(tagId, value)); + } + + @Override + public Entry put(EntryReader entryReader) { + Entry entry = entryReader.entry(); + if (entry == null) { + return null; // a reader with no entry is a no-op, as for TagMap.set + } + Entry replaced = HashSupport.put(this.buckets, entry); + if (replaced == null || replaced.tombstone) { + this.count++; // a tombstone replaced is a removal undone: no prior local entry + return null; + } + return replaced; + } + + @Override + public Entry getAndPut(EntryReader entryReader) { + Entry entry = entryReader.entry(); + if (entry == null) { + return null; // a reader with no entry is a no-op + } + Entry replaced = HashSupport.put(this.buckets, entry); + if (replaced != null && !replaced.tombstone) { + return replaced; // replaced a local entry -> that is the prior value + } + this.count++; + HashIdStore parent = this.parent; + if (replaced != null || parent == null) { + return null; // it was tombstoned, so not visible, or nothing to inherit + } + return parent.getVisible(entry.tagHash); + } + + @Override + public Entry remove(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + int hash = Entry.bucketHash(tagId); + + Entry local = HashSupport.findById(this.buckets, hash, tagId); + if (local != null) { + if (local.tombstone) { + return null; // already removed + } + HashSupport.remove(this.buckets, hash, local.tag); + this.count--; + } + HashIdStore parent = this.parent; + if (parent != null) { + // Read-through: hide an inherited value with a tombstone. The prior *visible* value is the + // local entry if there was one, otherwise the inherited one. + Entry inherited = parent.getVisible(tagId); + if (inherited != null) { + HashSupport.put(this.buckets, Entry.tombstoneOf(tagId)); + this.hasTombstones = true; + return local != null ? local : inherited; + } + } + return local; + } + + @Override + int size() { + return this.parent == null ? this.count : this.count + this.visibleParentCount(); + } + + /** Ancestor entries not shadowed or tombstoned by a nearer level, nearest-level-wins. */ + private int visibleParentCount() { + // a plain loop, not forEachParent: counting through a lambda would allocate a counter + int count = 0; + for (HashIdStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object bucket = parentBuckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (!entry.tombstone && this.parentEntryVisible(entry, ancestor)) { + count++; + } + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && !entry.tombstone && this.parentEntryVisible(entry, ancestor)) { + count++; + } + } + } + } + } + } + return count; + } + + @Override + boolean isEmpty() { + if (this.count != 0) { + return false; + } + HashIdStore parent = this.parent; + if (parent == null) { + return true; + } + if (!this.hasTombstones) { + return parent.isEmpty(); // nothing hidden: the parent's tags are all visible + } + return this.visibleParentCount() == 0; + } + + @Override + boolean isDefinitelyEmpty() { + for (HashIdStore level = this; level != null; level = level.parent) { + if (level.count != 0) { + return false; + } + } + return true; + } + + @Override + int estimateSize() { + int total = 0; + for (HashIdStore level = this; level != null; level = level.parent) { + total += level.count; + } + return total; + } + + @Override + boolean hasParent() { + return this.parent != null; + } + + /** + * Whether {@code parentEntry}, from the ancestor level {@code fromAncestor}, is visible through + * this leaf: no nearer level holds an entry or a tombstone for it. + */ + boolean parentEntryVisible(Entry parentEntry, HashIdStore fromAncestor) { + long tagId = parentEntry.tagHash; + int hash = parentEntry.hash(); + for (HashIdStore nearer = this; nearer != fromAncestor; nearer = nearer.parent) { + if (HashSupport.findById(nearer.buckets, hash, tagId) != null) { + return false; + } + } + return true; + } + + @Override + public void forEach(Consumer consumer) { + if (this.hasTombstones) { + HashSupport.forEachLive(this.buckets, consumer); + } else { + HashSupport.forEach(this.buckets, consumer); + } + if (this.parent != null) { + this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); + } + } + + @Override + public void forEach(T thisObj, BiConsumer consumer) { + if (this.hasTombstones) { + HashSupport.forEachLive(this.buckets, thisObj, consumer); + } else { + HashSupport.forEach(this.buckets, thisObj, consumer); + } + if (this.parent != null) { + this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry)); + } + } + + @Override + public void forEach( + T thisObj, U otherObj, TriConsumer consumer) { + if (this.hasTombstones) { + HashSupport.forEachLive(this.buckets, thisObj, otherObj, consumer); + } else { + HashSupport.forEach(this.buckets, thisObj, otherObj, consumer); + } + if (this.parent != null) { + this.forEachParent(thisObj, otherObj, consumer); + } + } + + private void forEachParent( + T thisObj, U otherObj, TriConsumer consumer) { + for (HashIdStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object bucket = parentBuckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (!entry.tombstone && this.parentEntryVisible(entry, ancestor)) { + consumer.accept(thisObj, otherObj, entry); + } + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && !entry.tombstone && this.parentEntryVisible(entry, ancestor)) { + consumer.accept(thisObj, otherObj, entry); + } + } + } + } + } + } + } + + @Override + Cursor cursor() { + return new HashCursor(this); + } + + @Override + IdStore copy() { + HashIdStore copy = new HashIdStore(this.parent); + HashSupport.copyInto(copy.buckets, this.buckets); + copy.count = this.count; + copy.hasTombstones = this.hasTombstones; + return copy; + } + + @Override + void putAllLocal(IdStore other) { + HashIdStore that = (HashIdStore) other; + if (that.count == 0) { + return; + } + if (this.hasTombstones) { + // A bucket merge would count a replaced tombstone as a replacement, so put one at a time. + HashSupport.forEachLive(that.buckets, this, (self, entryReader) -> self.put(entryReader)); + return; + } + if (this.count == 0) { + HashSupport.copyInto(this.buckets, that.buckets); + this.count = that.count; + } else { + this.count += HashSupport.merge(this.buckets, that.buckets); + } + } + + @Override + void fillMapLocal(Map map) { + HashSupport.fillMap(this.buckets, map); + } + + @Override + void fillStringMapLocal(Map map) { + HashSupport.fillStringMap(this.buckets, map); + } + + @Override + void checkIntegrity() { + HashSupport.checkBuckets(this.buckets); + if (this.count != this.computeSize()) { + throw new IllegalStateException("incorrect size"); + } + } + + @Override + int computeSize() { + return HashSupport.count(this.buckets); + } + + @Override + String toInternalString() { + return HashSupport.toInternalString(this.buckets); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/HashStore.java b/internal-api/src/main/java/datadog/trace/api/HashStore.java new file mode 100644 index 00000000000..ec73330fa37 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java @@ -0,0 +1,376 @@ +package datadog.trace.api; + +import datadog.apicontract.Restricted; +import datadog.trace.api.TagMap.BucketGroup; +import datadog.trace.api.TagMap.Entry; +import datadog.trace.api.TagMap.EntryReader; +import datadog.trace.api.function.TriConsumer; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * The custom-tag half of a {@link TagMap}: custom tags by name, in their own bucket array ({@link + * HashSupport}), reading through to the frozen parent's custom store. Name-keyed methods take only + * custom tag names (asserted): a known tag is resolved to its id first, and goes to the {@link + * IdStore}. + * + *

    Values move by {@code store}, as through {@link TagStore}; entries by {@code getEntry}, {@code + * put} and {@code remove}. + * + *

    Created lazily by its owner -- a {@link TagMap}, or a span in the tracer core -- on the first + * custom write ({@link #create}); until then the owner borrows its parent's custom store, if any, + * read-only. No locking: guarded by the owner's lock. + */ +@Restricted(allowedIn = {"datadog.trace.core"}) +public final class HashStore { + final Object[] buckets; + + /** Local entries. */ + int count; + + /** The frozen parent's custom store, or null. */ + final HashStore parent; + + /** + * Parent keys removed locally (read-through tombstones): a tombstone stops read-through for its + * key. Allocated on the first such removal, so while it is {@code null} the hot paths skip the + * tombstone checks. + */ + private Set removedFromParent; + + /** + * {@code parent}'s custom store, for an owner to borrow read-only until its first custom write, + * or null. + */ + public static HashStore inheritedFrom(TagMap parent) { + return parent == null ? null : parent.nameStore; + } + + /** + * A fresh custom store for an owner's first custom write, reading through to {@code borrowed}, + * the store it borrowed until now (may be null). + */ + public static HashStore create(HashStore borrowed) { + return new HashStore(borrowed); + } + + HashStore(HashStore parent) { + this.buckets = HashSupport.newBuckets(); + this.parent = parent; + } + + /** Exact count, including the visible part of the read-through chain. */ + int size() { + return this.parent == null ? this.count : this.count + this.visibleParentCount(); + } + + /** Ancestor entries not shadowed or tombstoned by a nearer level, nearest-level-wins. */ + private int visibleParentCount() { + // a plain loop, not forEachParent: counting through a lambda would allocate a counter + int count = 0; + for (HashStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object bucket = parentBuckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (this.parentEntryVisible(entry, ancestor)) { + count++; + } + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && this.parentEntryVisible(entry, ancestor)) { + count++; + } + } + } + } + } + } + return count; + } + + boolean isEmpty() { + if (this.count != 0) { + return false; + } + HashStore parent = this.parent; + if (parent == null) { + return true; + } + if (this.removedFromParent == null) { + return parent.isEmpty(); // nothing hidden: the parent's tags are all visible + } + return this.visibleParentCount() == 0; + } + + /** Cheap: no level of the chain holds a local entry (ignores shadowing and tombstones). */ + boolean isDefinitelyEmpty() { + for (HashStore level = this; level != null; level = level.parent) { + if (level.count != 0) { + return false; + } + } + return true; + } + + /** Upper bound: the local counts of every level. */ + int estimateSize() { + int total = 0; + for (HashStore level = this; level != null; level = level.parent) { + total += level.count; + } + return total; + } + + /** The custom tag {@code tag}'s entry, reading through to the parent chain. */ + public Entry getEntry(String tag) { + assert KnownTagCodec.keyOf(tag) == 0 : "'" + tag + "' is a known tag; look it up by its id"; + return this.get(tag, Entry.bucketHash(Entry.customHash(tag))); + } + + private Entry get(String tag, int hash) { + Entry local = HashSupport.find(this.buckets, hash, tag); + if (local != null) { + return local; + } + HashStore parent = this.parent; + if (parent == null) { + return null; + } + if (this.removedFromParent != null && this.removedFromParent.contains(tag)) { + return null; // tombstoned: removed locally, do not read through + } + return parent.get(tag, hash); + } + + /* + * Stores the custom tag {@code tag}, which must not be a known tag's name. + */ + + public void store(String tag, Object value) { + if (value == null) { + this.remove(tag); // a null value removes the tag + return; + } + this.put(Entry.newAnyEntry(tag, value)); + } + + public void store(String tag, CharSequence value) { + if (value == null) { + this.remove(tag); // a null value removes the tag + return; + } + this.put(Entry.newObjectEntry(tag, value)); + } + + public void store(String tag, boolean value) { + this.put(Entry.newBooleanEntry(tag, value)); + } + + public void store(String tag, int value) { + this.put(Entry.newIntEntry(tag, value)); + } + + public void store(String tag, long value) { + this.put(Entry.newLongEntry(tag, value)); + } + + public void store(String tag, float value) { + this.put(Entry.newFloatEntry(tag, value)); + } + + public void store(String tag, double value) { + this.put(Entry.newDoubleEntry(tag, value)); + } + + /** + * Inserts or replaces a custom tag's entry, returning the local entry it replaced (or null). Does + * not consult the read-through parent. + */ + public Entry put(Entry newEntry) { + // Re-setting a key clears any read-through tombstone for it (the new value overrides the + // removal). Gated on the lazy field, so this is a no-op for the common no-tombstone case. + if (this.removedFromParent != null) { + this.removedFromParent.remove(newEntry.tag); + } + Entry replaced = HashSupport.put(this.buckets, newEntry); + if (replaced == null) { + this.count++; + } + return replaced; + } + + /** {@link #put}, returning the prior visible entry -- local, else read through. */ + public Entry getAndPut(Entry newEntry) { + // Capture whether the key was tombstoned BEFORE put clears it: a tombstoned key had no visible + // prior value (it was removed), so getAndPut must report null rather than the parent's. + boolean wasTombstoned = + this.removedFromParent != null && this.removedFromParent.contains(newEntry.tag); + + Entry priorLocal = this.put(newEntry); + if (priorLocal != null) { + return priorLocal; + } + if (wasTombstoned || this.parent == null) { + return null; + } + return this.parent.get(newEntry.tag, newEntry.hash()); + } + + /** + * Removes the custom tag {@code tag}, returning the prior visible entry. Removing a key the + * parent chain still exposes also installs a tombstone, so it stops reading through. + */ + public Entry remove(String tag) { + assert KnownTagCodec.keyOf(tag) == 0 : "'" + tag + "' is a known tag; remove it by its id"; + int hash = Entry.bucketHash(Entry.customHash(tag)); + Entry localRemoved = HashSupport.remove(this.buckets, hash, tag); + if (localRemoved != null) { + this.count--; + } + + HashStore parent = this.parent; + if (parent != null) { + boolean alreadyTombstoned = + this.removedFromParent != null && this.removedFromParent.contains(tag); + if (!alreadyTombstoned) { + Entry parentEntry = parent.get(tag, hash); + if (parentEntry != null) { + if (this.removedFromParent == null) { + // Small initial capacity: this set is rare and almost always holds only a handful of + // tombstoned keys, so the default 16-bucket HashSet table would be wasteful. + this.removedFromParent = new HashSet<>(4); + } + this.removedFromParent.add(tag); + return localRemoved != null ? localRemoved : parentEntry; + } + } + } + return localRemoved; + } + + /** + * Whether {@code parentEntry}, from the ancestor level {@code fromAncestor}, is visible through + * this leaf: not shadowed and not tombstoned by any nearer level. + */ + boolean parentEntryVisible(Entry parentEntry, HashStore fromAncestor) { + int hash = parentEntry.hash(); + String tag = parentEntry.tag; + for (HashStore nearer = this; nearer != fromAncestor; nearer = nearer.parent) { + if (nearer.removedFromParent != null && nearer.removedFromParent.contains(tag)) { + return false; + } + if (HashSupport.find(nearer.buckets, hash, tag) != null) { + return false; + } + } + return true; + } + + /** Visits the local entries, then the visible inherited ones. */ + public void forEach(Consumer consumer) { + HashSupport.forEach(this.buckets, consumer); + if (this.parent != null) { + this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); + } + } + + public void forEach(T thisObj, BiConsumer consumer) { + HashSupport.forEach(this.buckets, thisObj, consumer); + if (this.parent != null) { + this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry)); + } + } + + public void forEach( + T thisObj, U otherObj, TriConsumer consumer) { + HashSupport.forEach(this.buckets, thisObj, otherObj, consumer); + if (this.parent != null) { + this.forEachParent(thisObj, otherObj, consumer); + } + } + + private void forEachParent( + T thisObj, U otherObj, TriConsumer consumer) { + for (HashStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object bucket = parentBuckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (this.parentEntryVisible(entry, ancestor)) { + consumer.accept(thisObj, otherObj, entry); + } + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && this.parentEntryVisible(entry, ancestor)) { + consumer.accept(thisObj, otherObj, entry); + } + } + } + } + } + } + } + + /** + * An independently mutable copy: the same (frozen, shared) parent, this store's own entries + * (shared Entry objects) and its tombstones. + */ + HashStore copy() { + HashStore copy = new HashStore(this.parent); + HashSupport.copyInto(copy.buckets, this.buckets); + copy.count = this.count; + if (this.removedFromParent != null) { + copy.removedFromParent = new HashSet<>(this.removedFromParent); + } + return copy; + } + + /** Copies the entries of the parentless store {@code that}, sharing Entry objects. */ + void putAllLocal(HashStore that) { + if (that.count == 0) { + return; + } + if (this.count == 0) { + HashSupport.copyInto(this.buckets, that.buckets); + this.count = that.count; + } else { + this.count += HashSupport.merge(this.buckets, that.buckets); + } + } + + void fillMapLocal(Map map) { + HashSupport.fillMap(this.buckets, map); + } + + void fillStringMapLocal(Map map) { + HashSupport.fillStringMap(this.buckets, map); + } + + /** Tests only: throws if the store's structure is inconsistent. */ + void checkIntegrity() { + HashSupport.checkBuckets(this.buckets); + if (this.count != this.computeSize()) { + throw new IllegalStateException("incorrect size"); + } + } + + /** Tests only: the local count, recomputed from the structure. */ + int computeSize() { + return HashSupport.count(this.buckets); + } + + /** For deep debugging. */ + String toInternalString() { + return HashSupport.toInternalString(this.buckets); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/HashSupport.java b/internal-api/src/main/java/datadog/trace/api/HashSupport.java new file mode 100644 index 00000000000..853470b427f --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java @@ -0,0 +1,515 @@ +package datadog.trace.api; + +import datadog.trace.api.TagMap.BucketGroup; +import datadog.trace.api.TagMap.Entry; +import datadog.trace.api.TagMap.EntryReader; +import datadog.trace.api.function.TriConsumer; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * The raw bucket mechanics both tag stores share, over one fixed-size {@code Object[]} of buckets + * of shareable, immutable {@link Entry} objects. A bucket holds a lone Entry directly; because + * Entries are shared between maps, they cannot form a linked list, so colliding entries share a + * fixed-size {@link BucketGroup}, chained when one fills. Removals are assumed rare, so groups are + * never consolidated, but an emptied group is unlinked from its chain. + * + *

    Stateless: each operation reports what it did through its return value -- put returns the + * entry it replaced, remove the entry it removed -- and the calling store keeps its own count. + * Copies share Entries and clone only BucketGroup chains. Iteration skips tombstones ({@link + * Entry#tombstone}), which only an id store holds. + */ +final class HashSupport { + private HashSupport() {} + + /** A fresh bucket array; its length must be a power of 2 for bucket masking. */ + static Object[] newBuckets() { + return new Object[1 << 4]; + } + + /** The entry for {@code (hash, tag)}, tombstone or not, or null. */ + static Entry find(Object[] buckets, int hash, String tag) { + return findInBucket(buckets[hash & (buckets.length - 1)], hash, tag); + } + + /** + * {@link #find} for a known tag, matching on its id rather than its name, so an id store never + * needs a name. {@code hash} is {@code Entry.bucketHash(tagId)}. + */ + static Entry findById(Object[] buckets, int hash, long tagId) { + Object bucket = buckets[hash & (buckets.length - 1)]; + if (bucket instanceof Entry) { + Entry tagEntry = (Entry) bucket; + return tagEntry.tagHash == tagId ? tagEntry : null; + } else if (bucket instanceof BucketGroup) { + return ((BucketGroup) bucket).findInChainById(hash, tagId); + } + return null; + } + + /** Finds an entry by hash and tag in one bucket: an Entry, a BucketGroup chain, or null. */ + private static Entry findInBucket(Object bucket, int hash, String tag) { + if (bucket instanceof Entry) { + Entry tagEntry = (Entry) bucket; + return tagEntry.hash() == hash && tagEntry.matches(tag) ? tagEntry : null; + } else if (bucket instanceof BucketGroup) { + return ((BucketGroup) bucket).findInChain(hash, tag); + } + return null; + } + + /** Inserts or replaces {@code newEntry}, returning the entry it replaced, or null if it added. */ + static Entry put(Object[] buckets, Entry newEntry) { + Object[] thisBuckets = buckets; + + int newHash = newEntry.hash(); + int bucketIndex = newHash & (thisBuckets.length - 1); + + Object bucket = thisBuckets[bucketIndex]; + if (bucket == null) { + thisBuckets[bucketIndex] = newEntry; + + return null; + } else if (bucket instanceof Entry) { + Entry existingEntry = (Entry) bucket; + if (existingEntry.hash() == newHash && existingEntry.matches(newEntry.tag)) { + thisBuckets[bucketIndex] = newEntry; + + // replaced existing entry - no size change + return existingEntry; + } else { + thisBuckets[bucketIndex] = + new BucketGroup(existingEntry.hash(), existingEntry, newHash, newEntry); + + return null; + } + } else if (bucket instanceof BucketGroup) { + BucketGroup lastGroup = (BucketGroup) bucket; + + BucketGroup containingGroup = lastGroup.findContainingGroupInChain(newHash, newEntry.tag); + if (containingGroup != null) { + // replaced existing entry - no size change + return containingGroup._replace(newHash, newEntry); + } + + if (!lastGroup.insertInChain(newHash, newEntry)) { + thisBuckets[bucketIndex] = new BucketGroup(newHash, newEntry, lastGroup); + } + return null; + } + + // unreachable + return null; + } + + /** Removes the entry for {@code (hash, tag)}, returning it, or null if there was none. */ + static Entry remove(Object[] buckets, int hash, String tag) { + Object[] thisBuckets = buckets; + int bucketIndex = hash & (thisBuckets.length - 1); + + Object bucket = thisBuckets[bucketIndex]; + // null bucket case - do nothing + if (bucket instanceof Entry) { + Entry existingEntry = (Entry) bucket; + if (existingEntry.hash() == hash && existingEntry.matches(tag)) { + thisBuckets[bucketIndex] = null; + + return existingEntry; + } else { + return null; + } + } else if (bucket instanceof BucketGroup) { + BucketGroup lastGroup = (BucketGroup) bucket; + + BucketGroup containingGroup = lastGroup.findContainingGroupInChain(hash, tag); + if (containingGroup == null) { + return null; + } + + Entry existingEntry = containingGroup._remove(hash, tag); + if (containingGroup._isEmpty()) { + thisBuckets[bucketIndex] = lastGroup.removeGroupInChain(containingGroup); + } + + return existingEntry; + } + return null; + } + + /** + * Copies {@code thatBuckets} into the empty {@code thisBuckets}, sharing Entry objects and + * cloning only BucketGroup chains. + */ + static void copyInto(Object[] thisBuckets, Object[] thatBuckets) { + + // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check + // elimination + for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) { + Object thatBucket = thatBuckets[i]; + + // faster to explicitly null check first, then do instanceof + if (thatBucket == null) { + // do nothing + } else if (thatBucket instanceof BucketGroup) { + // if it is a BucketGroup, then need to clone + BucketGroup thatGroup = (BucketGroup) thatBucket; + + thisBuckets[i] = thatGroup.cloneChain(); + } else { // if ( thatBucket instanceof Entry ) + thisBuckets[i] = thatBucket; + } + } + } + + /** + * Merges {@code thatBuckets} into {@code thisBuckets}, sharing Entry objects and cloning only + * BucketGroup chains; incoming entries replace existing ones. Returns how many it added. Both + * arrays must be the same length (stores don't expand, so buckets are perfectly aligned). + */ + static int merge(Object[] thisBuckets, Object[] thatBuckets) { + int added = 0; + + // Stores don't expand, so buckets are perfectly aligned + // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check + // elimination + for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) { + Object thatBucket = thatBuckets[i]; + + // if nothing incoming, nothing to do + if (thatBucket == null) continue; + + Object thisBucket = thisBuckets[i]; + if (thisBucket == null) { + // This bucket is null, easy case + // Either copy over the sole entry or clone the BucketGroup chain + + if (thatBucket instanceof Entry) { + thisBuckets[i] = thatBucket; + added += 1; + } else if (thatBucket instanceof BucketGroup) { + BucketGroup thatGroup = (BucketGroup) thatBucket; + + BucketGroup thisNewGroup = thatGroup.cloneChain(); + thisBuckets[i] = thisNewGroup; + added += thisNewGroup.sizeInChain(); + } + } else if (thisBucket instanceof Entry) { + // This bucket is a single entry, medium complexity case + // If other side is an Entry - just merge the entries into a bucket + // If other side is a BucketGroup - then clone the group and insert the entry normally into + // the cloned group + + Entry thisEntry = (Entry) thisBucket; + int thisHash = thisEntry.hash(); + + if (thatBucket instanceof Entry) { + Entry thatEntry = (Entry) thatBucket; + int thatHash = thatEntry.hash(); + + if (thisHash == thatHash && thisEntry.matches(thatEntry.tag())) { + thisBuckets[i] = thatEntry; + // replacing entry, no size change + } else { + thisBuckets[i] = + new BucketGroup( + thisHash, thisEntry, + thatHash, thatEntry); + added += 1; + } + } else if (thatBucket instanceof BucketGroup) { + BucketGroup thatGroup = (BucketGroup) thatBucket; + + // Clone the other group, then place this entry into that group + BucketGroup thisNewGroup = thatGroup.cloneChain(); + int thisNewGroupSize = thisNewGroup.sizeInChain(); + + Entry incomingEntry = thisNewGroup.findInChain(thisHash, thisEntry.tag()); + if (incomingEntry != null) { + // there's already an entry w/ the same tag from the incoming TagMap + // incoming entry clobbers the existing entry, so we're done + thisBuckets[i] = thisNewGroup; + + // overlapping group - subtract one for clobbered existing entry + added += thisNewGroupSize - 1; + } else if (thisNewGroup.insertInChain(thisHash, thisEntry)) { + // able to add thisEntry into the existing groups + thisBuckets[i] = thisNewGroup; + + // non overlapping group - existing entry already counted + added += thisNewGroupSize; + } else { + // unable to add into the existing groups + thisBuckets[i] = new BucketGroup(thisHash, thisEntry, thisNewGroup); + + // non overlapping group - existing entry already counted + added += thisNewGroupSize; + } + } + } else if (thisBucket instanceof BucketGroup) { + // This bucket is a BucketGroup, medium to hard case + // If the other side is an entry, just normal insertion procedure - no cloning required + BucketGroup thisGroup = (BucketGroup) thisBucket; + + if (thatBucket instanceof Entry) { + Entry thatEntry = (Entry) thatBucket; + int thatHash = thatEntry.hash(); + + if (thisGroup.replaceInChain(thatHash, thatEntry) != null) { + // replaced existing entry no size change + } else if (thisGroup.insertInChain(thatHash, thatEntry)) { + added += 1; + } else { + thisBuckets[i] = new BucketGroup(thatHash, thatEntry, thisGroup); + added += 1; + } + } else if (thatBucket instanceof BucketGroup) { + // Most complicated case - need to walk that bucket group chain and update this chain + BucketGroup thatGroup = (BucketGroup) thatBucket; + + // Taking the easy / expensive way out for updating size + int thisPrevGroupSize = thisGroup.sizeInChain(); + + BucketGroup thisNewGroup = thisGroup.replaceOrInsertAllInChain(thatGroup); + int thisNewGroupSize = thisNewGroup.sizeInChain(); + + thisBuckets[i] = thisNewGroup; + added += (thisNewGroupSize - thisPrevGroupSize); + } + } + } + return added; + } + + /** + * Visits every entry in {@code buckets}, which must hold no tombstones, as a {@link TagMap}'s and + * a {@link HashStore}'s never do. {@link #forEachLive} skips them. This walk runs for every span + * the serializer writes, so it stays the unrolled shape with no tombstone check. + */ + static void forEach(Object[] buckets, Consumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + consumer.accept((Entry) bucket); + } else if (bucket instanceof BucketGroup) { + ((BucketGroup) bucket).forEachInChain(consumer); + } + } + } + + /** {@link #forEach(Object[], Consumer)} with a context object for a non-capturing consumer. */ + static void forEach( + Object[] buckets, T thisObj, BiConsumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + consumer.accept(thisObj, (Entry) bucket); + } else if (bucket instanceof BucketGroup) { + ((BucketGroup) bucket).forEachInChain(thisObj, consumer); + } + } + } + + /** {@link #forEach(Object[], Consumer)} with two context objects. */ + static void forEach( + Object[] buckets, T thisObj, U otherObj, TriConsumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + consumer.accept(thisObj, otherObj, (Entry) bucket); + } else if (bucket instanceof BucketGroup) { + ((BucketGroup) bucket).forEachInChain(thisObj, otherObj, consumer); + } + } + } + + /** + * Visits every live entry in {@code buckets}, skipping tombstones, which only an id store holds. + */ + static void forEachLive(Object[] buckets, Consumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (!entry.tombstone) consumer.accept(entry); + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && !entry.tombstone) consumer.accept(entry); + } + } + } + } + } + + /** {@link #forEachLive(Object[], Consumer)} with a context object. */ + static void forEachLive( + Object[] buckets, T thisObj, BiConsumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (!entry.tombstone) consumer.accept(thisObj, entry); + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && !entry.tombstone) consumer.accept(thisObj, entry); + } + } + } + } + } + + /** {@link #forEachLive(Object[], Consumer)} with two context objects. */ + static void forEachLive( + Object[] buckets, T thisObj, U otherObj, TriConsumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (!entry.tombstone) consumer.accept(thisObj, otherObj, entry); + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && !entry.tombstone) consumer.accept(thisObj, otherObj, entry); + } + } + } + } + } + + /** Puts every live entry in {@code buckets} into {@code map}, by tag, as its object value. */ + static void fillMap(Object[] buckets, Map map) { + forEachLive(buckets, map, (m, entry) -> m.put(entry.tag(), entry.objectValue())); + } + + /** Puts every live entry in {@code buckets} into {@code map}, by tag, as its string value. */ + static void fillStringMap(Object[] buckets, Map map) { + forEachLive(buckets, map, (m, entry) -> m.put(entry.tag(), entry.stringValue())); + } + + /** The number of live entries, not counting tombstones. Tests only. */ + static int count(Object[] buckets) { + int[] count = new int[1]; + forEachLive(buckets, count, (c, entry) -> c[0]++); + return count[0]; + } + + /** Throws if an entry sits in the wrong bucket. Tests only. */ + static void checkBuckets(Object[] buckets) { + // Decided to use if ( cond ) throw new IllegalStateException rather than assert + // That was done to avoid the extra static initialization needed for an assertion + // While that's probably an unnecessary optimization, this method is only called in tests + + Object[] thisBuckets = buckets; + + for (int i = 0; i < thisBuckets.length; ++i) { + Object thisBucket = thisBuckets[i]; + + if (thisBucket instanceof Entry) { + Entry thisEntry = (Entry) thisBucket; + int thisHash = thisEntry.hash(); + + int expectedBucket = thisHash & (thisBuckets.length - 1); + if (expectedBucket != i) { + throw new IllegalStateException("incorrect bucket"); + } + } else if (thisBucket instanceof BucketGroup) { + BucketGroup thisGroup = (BucketGroup) thisBucket; + + for (BucketGroup curGroup = thisGroup; curGroup != null; curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry thisEntry = curGroup._entryAt(j); + if (thisEntry == null) continue; + + int thisHash = thisEntry.hash(); + assert curGroup._hashAt(j) == thisHash; + + int expectedBucket = thisHash & (thisBuckets.length - 1); + if (expectedBucket != i) { + throw new IllegalStateException("incorrect bucket"); + } + } + } + } + } + } + + /** The buckets' internal structure, for deep debugging. */ + static String toInternalString(Object[] buckets) { + Object[] thisBuckets = buckets; + + StringBuilder ledger = new StringBuilder(128); + for (int i = 0; i < thisBuckets.length; ++i) { + ledger.append('[').append(i).append("] = "); + + Object thisBucket = thisBuckets[i]; + if (thisBucket == null) { + ledger.append("null"); + } else if (thisBucket instanceof Entry) { + ledger.append('{').append(thisBucket).append('}'); + } else if (thisBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) thisBucket; + curGroup != null; + curGroup = curGroup.prev) { + ledger.append(curGroup).append(" -> "); + } + } + ledger.append('\n'); + } + return ledger.toString(); + } + + /** Walks a bucket array's raw entries, tombstones included, for an iterator. */ + static class BucketWalker { + private Object[] buckets; + private int bucketIndex; + private BucketGroup group; + private int groupIndex; + + /** Starts over on {@code buckets}. */ + void reset(Object[] buckets) { + this.buckets = buckets; + this.bucketIndex = -1; + this.group = null; + this.groupIndex = 0; + } + + /** The next raw entry, or null when the array is done. */ + final Entry nextEntry() { + while (this.bucketIndex < this.buckets.length) { + if (this.group != null) { + for (++this.groupIndex; this.groupIndex < BucketGroup.LEN; ++this.groupIndex) { + Entry tagEntry = this.group._entryAt(this.groupIndex); + if (tagEntry != null) return tagEntry; + } + + // done processing - that group, go to next group + this.group = this.group.prev; + this.groupIndex = -1; + } + + // if the group is null, then we've finished the current bucket - so advance the bucket + if (this.group == null) { + for (++this.bucketIndex; this.bucketIndex < this.buckets.length; ++this.bucketIndex) { + Object bucket = this.buckets[this.bucketIndex]; + + if (bucket instanceof Entry) { + return (Entry) bucket; + } else if (bucket instanceof BucketGroup) { + this.group = (BucketGroup) bucket; + this.groupIndex = -1; + + break; + } + } + } + } + + return null; + } + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java new file mode 100644 index 00000000000..36727a308f1 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -0,0 +1,215 @@ +package datadog.trace.api; + +import datadog.apicontract.Restricted; +import datadog.trace.api.TagMap.EntryReader; +import datadog.trace.api.function.TriConsumer; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import javax.annotation.Nullable; + +/** + * Known tags keyed by their {@link KnownTags} id. Every method takes an id, or an {@link + * EntryReader} for a known tag -- never a name, so nothing here can be handed a name that a caller + * has not yet resolved. + * + *

    A span in the tracer core owns its id store directly ({@link IdStoreFactory#create}); a {@link + * TagMap} owns one too. + * + *

    Values move by {@code load} and {@code store}, as through {@link TagStore}; entries by {@code + * getEntry}, {@code put} and {@code remove}. + * + *

    {@link IdStoreFactory} builds every store, so the JVM loads one implementation, {@link + * HashIdStore} or {@link DenseIdStore}, and class hierarchy analysis devirtualizes every call on an + * {@code IdStore} without a type guard; nothing that is always loaded may extend this class. No + * locking: guarded by the owner's lock. + */ +@Restricted(allowedIn = {"datadog.trace.core"}) +public abstract class IdStore { + /** Visits a store's visible entries one at a time. */ + interface Cursor { + /** The next visible entry, or null when there are none left. */ + EntryReader next(); + } + + /** A fresh id store reading through to {@code parent}'s known tags (parent may be null). */ + public static IdStore create(TagMap parent) { + return create(parent, null); + } + + /** + * Like {@link #create(TagMap)}, presized from {@code hint} (see {@link SizingHintTable}); a null + * hint, or a store that isn't sized per span, ignores it. + */ + public static IdStore create(TagMap parent, @Nullable SizingHint hint) { + return IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore, hint); + } + + /** + * Feeds this store's size back into the hint it was presized from -- a best-effort, racy max, so + * later stores of the same operation are presized to it. A store created without a hint, or with + * a capped one, or that isn't sized per span, does nothing. + */ + public void recordSize() {} + + /** + * Copies every tag of {@code source} in at once if none of them can already be present here -- + * locally, as a tombstone, or inherited -- and returns true; otherwise copies nothing and returns + * false, and the caller seeds tag by tag. A store that can't prove absence in bulk always returns + * false. {@code source} must be frozen. + */ + public boolean seedAbsent(IdStore source) { + return false; + } + + /** + * The partial form of {@link #seedAbsent(IdStore)}: copies in the tags of {@code source} that + * can't already be present here, hands each of the rest to {@code maybePresent} for the caller to + * set tag by tag, and returns true. Returns false, having done nothing, when this store can't + * seed in bulk at all or {@code source} isn't a plain frozen list of tags; the caller then sets + * every tag itself. {@code source} must be frozen, and {@code maybePresent} must not keep the + * entry it is handed. + */ + public boolean seedPartial( + IdStore source, T thisObj, BiConsumer maybePresent) { + return false; + } + + /** + * Whether {@link #seedAbsent} can ever succeed for this kind of store, so a caller can skip it, + * and the lock it needs, when it can't. + */ + public boolean supportsBulkSeed() { + return false; + } + + /** Throws for an id that names no known tag. */ + static IllegalArgumentException notKnown(long tagId) { + return new IllegalArgumentException("not a known tag id: " + Long.toHexString(tagId)); + } + + /* + * Point operations. A returned EntryReader is valid only until this store's next operation: a + * store that keeps known tags without an Entry per tag answers with a reused flyweight. Call + * EntryReader.entry() to keep one. Stores are inside the tracer's trust boundary, so callers are + * expected to follow this rather than have it enforced. + */ + + /** + * The entry for the known tag {@code tagId}, reading through to the parent chain, or null. + * + * @throws IllegalArgumentException if {@code tagId} names no known tag + */ + public abstract EntryReader getEntryReader(long tagId); + + /** + * The entry for the known tag {@code tagId}, reading through to the parent chain, or null. Unlike + * {@link #getEntryReader}, the result is an {@link TagMap.Entry} the caller may keep, and it + * shares no state with the store, so it is safe to call concurrently on a frozen store that + * several threads read, such as a span prototype's or the tracer-level tags. + * + * @throws IllegalArgumentException if {@code tagId} names no known tag + */ + public abstract TagMap.Entry getEntry(long tagId); + + /** + * {@link #getEntryReader}'s value, boxed if necessary, or null -- for a caller that needs only + * the value, so a store that keeps known tags without an Entry per tag can answer without one. + */ + public abstract Object load(long tagId); + + /** + * {@link #load} as a String, or null. A store that keeps an Entry per tag answers with the + * Entry's cached string, so a numeric tag read repeatedly, as the serializer does, is formatted + * once. + */ + public abstract String loadString(long tagId); + + /* + * Stores a known tag without validating its id: the caller has checked KnownTagCodec.isKnown. + */ + + public abstract void store(long tagId, Object value); + + public abstract void store(long tagId, CharSequence value); + + public abstract void store(long tagId, boolean value); + + public abstract void store(long tagId, int value); + + public abstract void store(long tagId, long value); + + public abstract void store(long tagId, float value); + + public abstract void store(long tagId, double value); + + /** + * Inserts or replaces a known tag, returning the local entry it replaced (or null). Does not + * consult the read-through parent. A store that keeps Entry objects keeps {@code entryReader}'s + * Entry (free when it is one); a store that doesn't copies its value. + */ + public abstract EntryReader put(EntryReader entryReader); + + /** {@link #put}, returning the prior visible entry -- local, else read through. */ + public abstract EntryReader getAndPut(EntryReader entryReader); + + /** + * Removes the known tag {@code tagId}, returning the prior visible entry; an inherited one is + * hidden by a tombstone. + * + * @throws IllegalArgumentException if {@code tagId} names no known tag + */ + public abstract EntryReader remove(long tagId); + + /* + * Whole-store operations, for the owning TagMap. + */ + + /** Exact count, including the visible part of the read-through chain. */ + abstract int size(); + + abstract boolean isEmpty(); + + /** Cheap: no level of the chain holds a local entry (ignores shadowing and tombstones). */ + abstract boolean isDefinitelyEmpty(); + + /** Upper bound: the local counts of every level. */ + abstract int estimateSize(); + + /** Whether this store reads through to a parent. */ + abstract boolean hasParent(); + + /** Visits the local entries, then the visible inherited ones. */ + public abstract void forEach(Consumer consumer); + + public abstract void forEach(T thisObj, BiConsumer consumer); + + public abstract void forEach( + T thisObj, U otherObj, TriConsumer consumer); + + /** + * A cursor over the visible entries, local then inherited, for an iterator. Each entry it returns + * is valid until its next call, as with a point read. + */ + abstract Cursor cursor(); + + /** An independently mutable copy with the same parent, sharing any Entry objects it holds. */ + abstract IdStore copy(); + + /** Copies the entries of the parentless store {@code that}, sharing any Entry objects. */ + abstract void putAllLocal(IdStore that); + + /** Puts the local entries into {@code map}, for a store with no parent. */ + abstract void fillMapLocal(Map map); + + abstract void fillStringMapLocal(Map map); + + /** Tests only: throws if the store's structure is inconsistent. */ + abstract void checkIntegrity(); + + /** Tests only: the local count, recomputed from the structure. */ + abstract int computeSize(); + + /** For deep debugging. */ + abstract String toInternalString(); +} diff --git a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java new file mode 100644 index 00000000000..beb419ad5b7 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java @@ -0,0 +1,75 @@ +package datadog.trace.api; + +import javax.annotation.Nullable; + +/** + * Builds the {@link IdStore}s of the one implementation this JVM uses: {@link DenseIdStore} when + * the dense store is on, {@link HashIdStore} otherwise. Rollout scaffolding: the hash store stays + * one flag away until the dense store is proven. + * + *

    Every store is built through {@link Default#INSTANCE}, never with {@code new}. The factory not + * chosen is loaded (to check the field's type) but never linked, so its store class is never + * loaded; with one {@code IdStore} subclass loaded, class hierarchy analysis devirtualizes every + * call on an {@code IdStore} without a guard. A store builds its own copies. + * + *

    No static state here, and no {@link TagMap}, so {@link TagMap#EMPTY} can be built with {@link + * #createEmpty} whichever of the two classes initializes first. + */ +abstract class IdStoreFactory { + /** The JVM's store, chosen once by the dense-store flag. */ + static final class Default { + /** Whether the dense store is on; folds wherever it is tested. */ + static final boolean DENSE = Config.get().isTraceDenseTagsEnabled(); + + static final IdStoreFactory INSTANCE = + DENSE ? DenseIdStoreFactory.INSTANCE : HashIdStoreFactory.INSTANCE; + + private Default() {} + } + + /** A fresh store reading through to {@code parent} (may be null). */ + final IdStore create(IdStore parent) { + return create(parent, null); + } + + /** + * A fresh store reading through to {@code parent} (may be null), presized from {@code hint} where + * the store keeps sized arrays; a null hint presizes nothing. + */ + abstract IdStore create(IdStore parent, @Nullable SizingHint hint); + + /** A new empty parentless store, for a frozen map. */ + abstract IdStore createEmpty(); +} + +final class HashIdStoreFactory extends IdStoreFactory { + static final HashIdStoreFactory INSTANCE = new HashIdStoreFactory(); + + private HashIdStoreFactory() {} + + @Override + IdStore create(IdStore parent, @Nullable SizingHint hint) { + return new HashIdStore(parent); // buckets aren't sized per span; a hint is only for dense + } + + @Override + IdStore createEmpty() { + return HashIdStore.createEmpty(); + } +} + +final class DenseIdStoreFactory extends IdStoreFactory { + static final DenseIdStoreFactory INSTANCE = new DenseIdStoreFactory(); + + private DenseIdStoreFactory() {} + + @Override + IdStore create(IdStore parent, @Nullable SizingHint hint) { + return new DenseIdStore(parent, hint); + } + + @Override + IdStore createEmpty() { + return new DenseIdStore(null); + } +} 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 31d8bee4302..2b917a0e22d 100644 --- a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java +++ b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java @@ -13,13 +13,16 @@ */ public final class KnownTagCodec { /* - * 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] - * 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. + * tagId bit layout: [63-48 serialNum (16 bits, 15 used: bit 63 is kept clear)] [47-32 slot] + * [31-0 flags]. The generator keeps bit 63 clear, so every id is positive and a negative value + * can never be an id. 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] hold the tag's slot ({@link #slotOf}), or {@link #NO_SLOT} for a tag on + * no modeled span type. Graph coloring gives tags that appear together on a span type distinct + * slots (see {@link #slotOf} for the overflow exception); trace-level tags are colored separately + * and reuse the span-level slots. 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. * *

    There is one id per Datadog name. A tag the conventions declare once per direction, such as * {@code peer.port}, is still one tag: only its OpenTelemetry name depends on the span's @@ -102,13 +105,13 @@ public static long traceLevel(long tagId) { } /** - * 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. + * The id of the known tag with {@code serialNum}, slot and flag bits included, so it equals the + * tag's {@code KnownTags} constant and stores and looks up exactly like it. A serial outside the + * known range gets an id carrying only the serial, which is not a known tag. Inverse of {@link + * #serialNum}. */ public static long makeTagId(int serialNum) { - return (long) serialNum << 48; + return serialNum > 0 && serialNum < serialLimit() ? idOf(serialNum) : (long) serialNum << 48; } /* @@ -160,6 +163,9 @@ public interface Resolver { /** The tag's Datadog-namespace (canonical) name. */ String nameOf(long tagId); + /** The full id of the known tag with {@code serial}, which must be below the serial limit. */ + long idOf(int serial); + /** * The tag's OpenTelemetry-namespace name on spans of {@code direction}, or {@code null} when it * declares none there. {@link #DIRECTION_UNKNOWN} returns only a name that holds in every @@ -183,6 +189,15 @@ private static final class Installed { static final Resolver RESOLVER = KnownTags.RESOLVER; } + /** + * The full id of the known tag with {@code serial} (flag and slot bits included), for a store + * that keeps only the 16-bit serial. {@code serial} must be a known tag's: {@code 0 < serial < + * serialLimit()}. + */ + public static long idOf(int serial) { + return Installed.RESOLVER.idOf(serial); + } + /** The tag's canonical (Datadog-namespace) name, or {@code null} when the id is not known. */ public static String nameOf(long tagId) { return Installed.RESOLVER.nameOf(tagId); diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHint.java b/internal-api/src/main/java/datadog/trace/api/SizingHint.java new file mode 100644 index 00000000000..238c5620178 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SizingHint.java @@ -0,0 +1,56 @@ +package datadog.trace.api; + +import datadog.trace.util.FlatHashtable; +import javax.annotation.Nullable; + +/** + * Opaque per-operation dense-store sizing hint, and a self-contained {@link FlatHashtable.D1} + * entry: it carries everything the probe compares (the operation name as the {@link + * FlatHashtable.D1.Entry} key, plus the cached hash from {@link FlatHashtable.Entry#hash}) and the + * tuned payload ({@link #size}). Holding key, hash, and value in ONE object behind ONE array slot + * is deliberate — entry publication is a single reference store, so a reader sees {@code null} or a + * complete entry (never a torn one), and the {@code final} identity fields are visible even under + * racy publication (JMM final-field guarantee). That sidesteps the memory-ordering / visibility + * problems parallel key/hash/value arrays would create, no volatile or atomics. + * + *

    Opaque to everything outside {@code datadog.trace.api}: its only public member is the lookup, + * {@link #forOperation}. {@link DenseIdStore} reads {@link #size} to size a span's fresh store and + * writes it back (best-effort max, see {@link IdStore#recordSize}) at a terminal point; {@link + * SizingHintTable} mints them. Callers only ever hold the reference. + */ +public final class SizingHint extends FlatHashtable.D1.Entry { + // Payload: the tuned dense-store size. Plain racy int, updated best-effort max (see + // IdStore#recordSize -- not strictly monotonic under concurrent finishes). A stale/lost/lowered + // read only mis-sizes an array (over/under-provision), never corrupts tag data, so no + // synchronization. Not safely published by a racing create either (FlatHashtable class doc): a + // reader may see the default 0, which DenseIdStore treats as "no presize" -- benign. + int size; + + // When true, size is fixed and recordSize won't grow it. For the shared overflow hint (a + // HETEROGENEOUS catch-all for over-budget operations): self-tuning it toward the observed max + // would converge to the max across unlike sharers and over-provision the lean ones. + final boolean capped; + + /** + * The hint for spans of {@code operationName}, in the entry-span or child-span lane; null when + * the dense store is off (constant-folded) or there is no operation name. See {@link + * SizingHintTable#hintFor}. + */ + @Nullable + public static SizingHint forOperation(@Nullable CharSequence operationName, boolean entrySpan) { + return SizingHintTable.hintFor(operationName, entrySpan); + } + + /** + * A fresh, uncapped hint for {@code operationName}, seeded at {@link SizingHintTable#SEED_SIZE}. + */ + SizingHint(String operationName) { + this(operationName, SizingHintTable.SEED_SIZE, false); + } + + SizingHint(String operationName, int seedSize, boolean capped) { + super(operationName); + this.size = seedSize; + this.capped = capped; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java new file mode 100644 index 00000000000..7fcd0b4567c --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java @@ -0,0 +1,101 @@ +package datadog.trace.api; + +import datadog.trace.util.FlatHashtable; +import javax.annotation.Nullable; + +/** + * Process-wide, self-tuning registry of per-operation {@link SizingHint}s, keyed by operation name. + * Pure static and package-private: the tracer resolves a hint at span build through {@link + * SizingHint#forOperation} and hands it to the span, which sizes its dense {@link IdStore} from it + * and records the actual size back when it finishes — the hint converges to the operation's real + * known-tag high-water mark, so later spans of that operation size correctly. + * + *

    Only meaningful with the dense store on ({@link IdStoreFactory.Default#DENSE}); with it off, + * {@link #hintFor} returns {@code null} before touching anything, so the default span path does no + * lookup and the lane tables are never even allocated (they live in a holder class). + * + *

    Two lanes, because a span's dense size depends systematically on whether it is an entry + * (local-root) span or not: entry spans carry the trace-metadata / enriching tags and children + * don't, so the same operation name has two different steady-state sizes. {@link #hintFor} picks + * the lane. + * + *

    Bounded + racy by design. Each lane is a fixed-capacity {@link FlatHashtable.D1} (never + * resized) so memory is bounded even under unbounded/dynamic operation names; once a lane's + * cardinality budget is spent, further operations share a capped overflow hint. Insertion and the + * best-effort-max size update are lock-free and deliberately racy (the table is used from many + * threads without synchronization): a lost size-counter update lets a lane slightly overshoot its + * budget (still bounded by the backing array, whose full-table insert just fails), and two threads + * minting the same operation at once may leave a shadowed, unreachable duplicate. Either only + * mis-sizes an array (over/under-provision) for a span or two, never corrupts tag data (see {@link + * FlatHashtable} and {@link SizingHint} for the rationale). + * + *

    Keyed by the operation name's {@code String} form. In practice every operation name we see is + * a {@code String} or a {@link datadog.trace.bootstrap.instrumentation.api.UTF8BytesString}, whose + * {@code toString()} just returns its backing field — so the {@code toString()} here is O(1) and + * allocation-free on the hot path. A {@code null} operation name gets no hint (the span falls back + * to the generic default capacity); a missed hint is benign, never wrong. + */ +final class SizingHintTable { + private SizingHintTable() {} + + // Seed for a fresh per-operation hint: a small floor that self-tunes up via best-effort-max + // recordSize. Kept small because the hint only grows -- a large seed would lock small spans into + // oversized arrays forever; a span that outgrows it pays one doubling. + static final int SEED_SIZE = 4; + + // Fixed size for the shared over-budget hint: the untuned default, DenseIdStore's initial + // capacity (a compile-time constant, so reading it loads no class). Capped (never grown by + // recordSize) because it's a heterogeneous catch-all -- growing it would over-provision lean + // sharers to the max of an unlike cohort. + static final int OVERFLOW_SEED = DenseIdStore.INIT_CAP; + + // Max distinct operation names per lane that get their own hint before collapsing to the capped + // overflow hint. + static final int CARDINALITY_LIMIT = 512; + + /** + * The lanes, in a holder so they are only allocated once the dense store is on and a span asks + * for a hint. + */ + static final class Lanes { + private Lanes() {} + + // Entry (local-root, enriched) lane and non-entry (child) lane, keyed by operation name. + static final FlatHashtable.D1 ENTRY = + FlatHashtable.D1.createFixed(CARDINALITY_LIMIT, SizingHint[]::new); + static final FlatHashtable.D1 CHILD = + FlatHashtable.D1.createFixed(CARDINALITY_LIMIT, SizingHint[]::new); + + // Shared capped hint each lane returns once its budget is exhausted. + static final SizingHint ENTRY_OVERFLOW = new SizingHint(null, OVERFLOW_SEED, /* capped */ true); + static final SizingHint CHILD_OVERFLOW = new SizingHint(null, OVERFLOW_SEED, /* capped */ true); + } + + /** + * The sizing hint for {@code operationName} in the given lane: the existing one, a freshly-minted + * (seeded) one if the lane has budget, or the shared capped overflow hint if it's full. Returns + * {@code null} when the dense store is off (constant-folded, so the default span path does no + * lookup) and for a {@code null} operation name — the span then uses the generic default capacity + * (operation-less spans aren't reliably similar, so they get no hint). Hits are a single probe; + * the create path is warmup-rare. + */ + @Nullable + static SizingHint hintFor(@Nullable CharSequence operationName, boolean entrySpan) { + if (!IdStoreFactory.Default.DENSE) { + return null; + } + if (operationName == null) { + return null; + } + // O(1) for String / UTF8BytesString (see class doc) + final String key = operationName.toString(); + final FlatHashtable.D1 lane = entrySpan ? Lanes.ENTRY : Lanes.CHILD; + // One probe: returns a hit directly, and null only when the fixed lane is full and the key is + // absent. + final SizingHint hint = lane.tryGetOrCreateOrNull(key, SizingHint::new); + if (hint != null) { + return hint; + } + return entrySpan ? Lanes.ENTRY_OVERFLOW : Lanes.CHILD_OVERFLOW; + } +} 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..fb0e5c91745 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1,7 +1,6 @@ package datadog.trace.api; import datadog.apicontract.Restricted; -import datadog.trace.api.function.NoEscape; import datadog.trace.api.function.TriConsumer; import datadog.trace.api.internal.VisibleForTesting; import java.util.AbstractCollection; @@ -9,11 +8,12 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.HashSet; import java.util.Iterator; import java.util.Map; import java.util.NoSuchElementException; import java.util.Set; +import java.util.Spliterator; +import java.util.Spliterators; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; @@ -48,16 +48,11 @@ *

  • adaptive collision * */ -public final class TagMap implements Map, Iterable { +public final class TagMap implements Map, Iterable, TagStore { /** Immutable empty TagMap - similar to {@link Collections#emptyMap()} */ - // Frozen view over a length-1 array: bucket masking needs a power-of-two array length (size 0 - // would fail with ArrayIndexOutOfBoundsException, size 1 works), and the private constructor - // reads no statics, so this is safe to build directly during TagMap's . - public static final TagMap EMPTY = new TagMap(new Object[1], 0); - - // Sentinel for an ID not yet resolved by the reused EntryReadingHelper. Zero denotes an unknown - // tag, so it cannot represent "not computed". - static final long TAG_ID_NOT_COMPUTED = Long.MIN_VALUE; + // Built from the JVM's chosen store's createEmpty(); IdStoreFactory explains why that is safe + // during TagMap's , whichever class initializes first. + public static final TagMap EMPTY = new TagMap(IdStoreFactory.Default.INSTANCE.createEmpty()); /** Creates a new mutable TagMap that contains the contents of map */ public static final TagMap fromMap(@Nonnull Map map) { @@ -98,19 +93,7 @@ public static final TagMap create(int size) { * local miss/removal for no benefit. */ public static final TagMap createFromParent(TagMap parent) { - if (parent != null) { - if (!parent.frozen) { - throw new IllegalStateException("read-through parent must be frozen"); - } - // Exact, not isDefinitelyEmpty(): a multi-level parent can be observationally empty while a - // local level still holds entries (all of them tombstoned by a nearer level). Dropping such a - // parent keeps isEmpty()'s no-tombstone fast path valid -- an attached parent always - // contributes at least one visible entry. - if (parent.isEmpty()) { - parent = null; - } - } - return new TagMap(parent); + return new TagMap(readThroughParent(parent)); } /** Creates a new TagMap.Ledger */ @@ -158,6 +141,13 @@ public boolean isRemoval() { } } + /** + * A momentary view of one tag: read it where it was obtained and drop it. A reader MUST NOT be + * kept past the call that produced it -- not in a field, a collection or a later step. A store + * may answer with a flyweight it reuses for the next read or write, so a kept reader can later + * report another tag's value. To keep a tag, call {@link #entry()}: an {@link Entry} is the + * keepable form. (To be marked {@code @ScopeBounded}, APMLP-2086.) + */ public interface EntryReader { public static final byte OBJECT = 1; @@ -447,6 +437,41 @@ static Entry newDoubleEntry(String tag, Double box) { volatile String strCache = null; + /** + * True for a known tag's tombstone: the marker the hash id store puts in its own buckets to + * hide an inherited entry it has removed. Never visible outside the store; see {@link + * #tombstoneOf}. + */ + final boolean tombstone; + + /** The shared tombstone per known tag, by serial, created on first use. */ + private static final Entry[] TOMBSTONES = new Entry[KnownTagCodec.serialLimit()]; + + /** + * The tombstone for the known tag {@code tagId}: one shared, immutable entry per tag, so + * tombstoning a known tag allocates nothing after the first time. Racing first uses may each + * build one; either serves, since a tombstone is told apart by {@link #tombstone}, not + * identity. + */ + static Entry tombstoneOf(long tagId) { + int serial = KnownTagCodec.serialNum(tagId); + Entry tombstone = TOMBSTONES[serial]; + if (tombstone == null || tombstone.tagHash != tagId) { + tombstone = new Entry(tagId); + TOMBSTONES[serial] = tombstone; + } + return tombstone; + } + + private Entry(long tagId) { + super(requireKnownName(tagId)); + this.tagHash = tagId; + this.tombstone = true; + this.rawType = OBJECT; + this.rawPrim = 0L; + this.rawObj = null; + } + /** * A custom tag's entry: no registry lookup. A known tag is built from its id instead -- callers * that start from a name resolve it once and pick the id or name factory (see {@code @@ -461,6 +486,7 @@ private Entry(String tag, byte type, long prim, Object obj) { // Resolve the name once: it both validates the id and names the entry. super(requireKnownName(tagId)); this.tagHash = tagId; + this.tombstone = false; this.rawType = type; this.rawPrim = prim; @@ -471,6 +497,7 @@ private Entry(String tag, byte type, long prim, Object obj) { Entry(long tagId, String customTag, byte type, long prim, Object obj) { super(tagId != 0 ? KnownTagCodec.nameOf(tagId) : customTag); this.tagHash = tagId != 0 ? tagId : customHash(customTag); + this.tombstone = false; this.rawType = type; this.rawPrim = prim; @@ -497,14 +524,27 @@ static long customHash(String tag) { } /** - * Folds a tag hash to the 32-bit bucket hash. A tag id's serial is in bits 63-48, so it is - * folded down from there rather than from bit 32, where a bucket mask would discard it. + * Folds a tag hash to the 32-bit bucket hash, whose low bits pick the bucket. * - *

    Never zero: BucketGroup treats a zero hash as a vacant slot. The fold can produce zero - * when a serial equals the id's flag bits (serial 4 with the trace-level bit, 4), so zero maps - * to the same nonzero sentinel {@link #_hash} uses. + *

    A known tag with a slot is bucketed by its slot: slot coloring gives tags that appear + * together on a span type distinct slots, so they mostly land in distinct buckets and need no + * BucketGroup. Trace-level tags reuse the span-level slot numbers, so their level bit moves + * them to the other half of the buckets. The serial sits above the slot, so two known tags + * never share a bucket hash. + * + *

    A known tag without a slot folds its serial (bits 63-48) down instead, and a custom tag + * uses its name hash. + * + *

    Never zero: BucketGroup treats a zero hash as a vacant slot. A slotted known tag's hash + * carries its nonzero serial; the other folds map zero to the same nonzero sentinel {@link + * #_hash} uses. */ static int bucketHash(long tagHash) { + int slot = (int) (tagHash >>> 32) & 0xFFFF; + if ((tagHash >>> 32) != 0 && slot != KnownTagCodec.NO_SLOT) { + int level = (int) (tagHash & KnownTagCodec.LEVEL_TRACE) << 1; // trace-level: flip bit 3 + return ((int) (tagHash >>> 48) << 16) | (slot ^ level); + } int hash = (int) (tagHash >>> 48) ^ (int) tagHash; return hash == 0 ? 0xDD06 : hash; } @@ -1170,49 +1210,38 @@ public EntryChange next() { } /* - * For memory efficiency, TagMap uses a rather complicated bucket system. - *

    - * When there is only a single Entry in a particular bucket, the Entry is stored into the bucket directly. - *

    - * Because the Entry objects can be shared between multiple TagMaps, the Entry objects cannot - * directly form a linked list to handle collisions. - *

    - * Instead when multiple entries collide in the same bucket, a BucketGroup is formed to hold multiple entries. - * But a BucketGroup is only formed when a collision occurs to keep allocation low in the common case of no collisions. - *

    - * For efficiency, BucketGroups are a fixed size, so when a BucketGroup fills up another BucketGroup is formed - * to hold the additional Entry-s. And the BucketGroup-s are connected via a linked list instead of the Entry-s. - *

    - * This does introduce some inefficiencies when Entry-s are removed. - * The assumption is that removals are rare, so BucketGroups are never consolidated. - * However as a precaution if a BucketGroup becomes completely empty, then that BucketGroup will be - * removed from the collision chain. + * A TagMap holds its tags in two stores: an IdStore with the known tags by id, and a HashStore + * with the custom tags by name. Their key spaces are disjoint, + * so each store reads through to its own parent chain and keeps its own tombstones, and + * precedence never crosses stores. + * + * The custom store is created on the first custom write. Until then, a map with a parent borrows + * the parent's custom store, read-only (nameStoreBorrowed): the parent is frozen, so its store is + * stable, and with no local custom state the borrowed store reads exactly as an own one would. */ - final Object[] buckets; - private int size; - private boolean frozen; + /** The known tags, by id. Not final only so {@link #clear()} can replace it. */ + IdStore knownStore; /** - * Optional frozen parent for read-through. When non-null, reads that miss the local buckets fall - * through to the parent chain, nearest-level-wins (a local entry shadows the parent's, a nearer - * ancestor shadows a farther one). Parents may themselves have parents, so this is a chain (e.g. - * baggage layered over trace tags). Must be frozen when attached, so it is safely shareable. Not - * final only so {@link #clear()} can detach it (to null); it is otherwise fixed at construction - * and never re-pointed. Package-visible so same-package tests can assert attach/detach directly. + * The custom tags, by name: null until the first custom write, or the parent's custom store, + * borrowed read-only while {@link #nameStoreBorrowed}. */ - @VisibleForTesting TagMap parent; + HashStore nameStore; + + /** True while {@link #nameStore} is the parent's custom store, borrowed until the first write. */ + boolean nameStoreBorrowed; + + private boolean frozen; /** - * Parent keys removed locally (read-through tombstones). Lazily allocated on the first such - * removal; {@code null} both means "no tombstones" and serves as the gate that keeps the hot - * paths untouched. Only meaningful when {@link #parent} != null. A tombstone stops read-through - * fall-through for its key, so a key removed from a child no longer reads through to the parent. - * Kept off the bucket structure deliberately — it is shape-agnostic (bare-Entry vs BucketGroup) - * and rare, so it costs a lazy allocation on removal rather than complicating the hot bucket - * code. + * Optional frozen parent for read-through. Reads that miss locally fall through to the parent + * chain (each store reads through to the parent's matching store). Must be frozen when attached, + * so it is safely shareable. Not final only so {@link #clear()} can detach it (to null); it is + * otherwise fixed at construction and never re-pointed. Package-visible so same-package tests can + * assert attach/detach directly. */ - private Set removedFromParent; + @VisibleForTesting TagMap parent; public TagMap() { this((TagMap) null); @@ -1224,93 +1253,155 @@ public TagMap() { * optimizations can treat it as fixed. */ private TagMap(TagMap parent) { - // needs to be a power of 2 for bucket masking calculation to work as intended - this.buckets = new Object[1 << 4]; - this.size = 0; + this.knownStore = IdStore.create(parent); + this.nameStore = HashStore.inheritedFrom(parent); + this.nameStoreBorrowed = this.nameStore != null; + this.frozen = false; + this.parent = parent; + } + + /** A map over {@code knownStore} and {@code nameStore}: a copy ({@link #copy()}) or a view. */ + private TagMap( + TagMap parent, IdStore knownStore, HashStore nameStore, boolean nameStoreBorrowed) { + this.knownStore = knownStore; + this.nameStore = nameStore; + this.nameStoreBorrowed = nameStoreBorrowed; this.frozen = false; this.parent = parent; } - /** Used for inexpensive immutable */ - private TagMap(Object[] buckets, int size) { - this.buckets = buckets; - this.size = size; + /** A frozen map over the empty parentless store {@code knownStore}: {@link #EMPTY}. */ + private TagMap(IdStore knownStore) { + this.knownStore = knownStore; + this.nameStore = null; + this.nameStoreBorrowed = false; this.frozen = true; this.parent = null; } - @Override - public int size() { - // Exact (Map contract). Under read-through resolves the union; prefer estimateSize() for hints. - TagMap parent = this.parent; - return parent == null ? this.size : this.size + this.visibleParentCount(); + /** + * A live view over a span's stores, for the span's internal readers (tag post-processors, + * serializers) and its snapshot copy; never on the span's own set/get paths. Not thread-safe: use + * it under the span's tag lock. + * + *

    The span owns the stores, and the view keeps the ones it was created over. A span that later + * creates its own custom store drops its view and creates a new one. After using the view, the + * span adopts any custom store the view created ({@link #customStore}), since a reader may write + * a custom tag. + */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public static TagMap viewOf(IdStore knownStore, HashStore nameStore, boolean nameStoreBorrowed) { + return new TagMap(null, knownStore, nameStore, nameStoreBorrowed); } /** - * Exact count of ancestor entries not shadowed by a nearer level or tombstoned (the read-through - * addition). Walks the ancestor chain iteratively, nearest-level-wins. + * The parent an owner reads through to: {@code parent}, or null when it is null or empty, as + * {@link #createFromParent} decides. + * + * @throws IllegalStateException if {@code parent} is not frozen */ - private int visibleParentCount() { - int count = 0; - for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { - Object[] parentBuckets = ancestor.buckets; - for (int i = 0; i < parentBuckets.length; ++i) { - Object parentBucket = parentBuckets[i]; - if (parentBucket instanceof Entry) { - if (parentEntryVisible((Entry) parentBucket, ancestor)) count++; - } else if (parentBucket instanceof BucketGroup) { - for (BucketGroup curGroup = (BucketGroup) parentBucket; - curGroup != null; - curGroup = curGroup.prev) { - for (int j = 0; j < BucketGroup.LEN; ++j) { - Entry parentEntry = curGroup._entryAt(j); - if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) count++; - } - } - } + @Restricted(allowedIn = {"datadog.trace.core"}) + public static TagMap readThroughParent(TagMap parent) { + if (parent == null) { + return null; + } + if (!parent.isFrozen()) { + throw new IllegalStateException("read-through parent must be frozen"); + } + // Exact, not isDefinitelyEmpty(): a multi-level parent can be observationally empty while a + // local level still holds entries (all of them tombstoned by a nearer level). Dropping such a + // parent keeps isEmpty()'s no-tombstone fast path valid -- an attached parent always + // contributes at least one visible entry. + return parent.isEmpty() ? null : parent; + } + + /** + * The Entry an id store's {@code reader} denotes, or null, for a caller that keeps it or hands it + * out. Free for a hash store's reader, which is an Entry; for a dense store's reused flyweight, + * this builds one. + */ + private static Entry entryOf(EntryReader reader) { + return reader == null ? null : reader.entry(); + } + + /** This map's known-tag store. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public IdStore knownStore() { + return this.knownStore; + } + + /** This map's custom store: null, borrowed ({@link #isCustomStoreBorrowed}), or its own. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public HashStore customStore() { + return this.nameStore; + } + + /** Whether {@link #customStore} is the parent's, borrowed read-only. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public boolean isCustomStoreBorrowed() { + return this.nameStoreBorrowed; + } + + /** The custom store to write to: created on the first write, ending any borrowing. */ + HashStore nameStoreForWrite() { + HashStore nameStore = this.nameStore; + if (nameStore == null || this.nameStoreBorrowed) { + // A borrowed store is the nearest ancestor's custom store: read through to it. + nameStore = HashStore.create(nameStore); + this.nameStore = nameStore; + this.nameStoreBorrowed = false; + } + return nameStore; + } + + /** Removes the custom tag {@code tag}, returning the prior visible entry. */ + Entry removeCustom(String tag) { + HashStore nameStore = this.nameStore; + if (nameStore == null) { + return null; + } + if (this.nameStoreBorrowed) { + if (nameStore.getEntry(tag) == null) { + return null; // nothing to hide: keep borrowing } + nameStore = this.nameStoreForWrite(); // hiding an inherited tag needs a tombstone of our own } - return count; + return nameStore.remove(tag); + } + + /** Whether either store reads through to a parent. */ + private boolean readsThrough() { + HashStore nameStore = this.nameStore; + return this.knownStore.hasParent() + || this.nameStoreBorrowed + || (nameStore != null && nameStore.parent != null); + } + + @Override + public int size() { + // Exact (Map contract). Under read-through resolves the union; prefer estimateSize() for hints. + HashStore nameStore = this.nameStore; + return this.knownStore.size() + (nameStore == null ? 0 : nameStore.size()); } @Override public boolean isEmpty() { // Exact (Map contract). Under read-through resolves the parent; prefer isDefinitelyEmpty(). - if (this.size != 0) { - return false; - } - TagMap parent = this.parent; - if (parent == null) { - return true; - } - if (this.removedFromParent == null) { - // A live parent is never definitely-empty: createFromParent drops an empty parent (and copy() - // only forwards an already-vetted parent), and the parent is frozen so it can't become empty. - // So with no local entries and no tombstones, the parent contributes at least one visible - // entry -> this map is non-empty. - return false; - } - // size == 0 with tombstones (rare): empty iff every visible ancestor entry is tombstoned - return this.visibleParentCount() == 0; + HashStore nameStore = this.nameStore; + return this.knownStore.isEmpty() && (nameStore == null || nameStore.isEmpty()); } public boolean isDefinitelyEmpty() { // Cheap: empty iff no level in the chain holds a local entry (ignores shadowing/tombstones). - for (TagMap level = this; level != null; level = level.parent) { - if (level.size != 0) { - return false; - } - } - return true; + HashStore nameStore = this.nameStore; + return this.knownStore.isDefinitelyEmpty() + && (nameStore == null || nameStore.isDefinitelyEmpty()); } public int estimateSize() { // Upper bound: sum of every level's local size, ignoring read-through shadowing/removals. - int total = 0; - for (TagMap level = this; level != null; level = level.parent) { - total += level.size; - } - return total; + HashStore nameStore = this.nameStore; + return this.knownStore.estimateSize() + (nameStore == null ? 0 : nameStore.estimateSize()); } @Deprecated @@ -1321,25 +1412,78 @@ public Object get(Object tag) { return this.getObject((String) tag); } - /** Provides the corresponding entry value as an Object - boxing if necessary */ + /** + * Provides the corresponding entry value as an Object - boxing if necessary. A known tag is read + * straight from its store, so a store without an Entry per tag builds none. + */ public Object getObject(String tag) { - Entry entry = this.getEntry(tag); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return this.knownStore.load(key); + } + HashStore nameStore = this.nameStore; + Entry entry = nameStore == null ? null : nameStore.getEntry(tag); return entry == null ? null : entry.objectValue(); } - /** Provides the corresponding entry value as a String - calling toString if necessary */ + /** + * Provides the corresponding entry value as a String - calling toString if necessary. Like {@link + * #getObject}, a known tag is read straight from its store. + */ public String getString(String tag) { - Entry entry = this.getEntry(tag); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return this.knownStore.loadString(key); + } + HashStore nameStore = this.nameStore; + Entry entry = nameStore == null ? null : nameStore.getEntry(tag); return entry == null ? null : entry.stringValue(); } + /* TagStore, for standalone maps handed to the pipeline: TagMap's own get and set. */ + + @Override + public Object load(String tag) { + return this.getObject(tag); + } + + @Override + public String loadString(String tag) { + return this.getString(tag); + } + + @Override + public Object load(long tagId) { + return this.knownStore.load(tagId); + } + + @Override + public String loadString(long tagId) { + return this.knownStore.loadString(tagId); + } + + @Override + public void store(String tag, Object value) { + this.set(tag, value); + } + + @Override + public void store(long tagId, Object value) { + this.set(tagId, value); + } + + @Override + public void store(EntryReader entry) { + this.set(entry); + } + public boolean getBoolean(String tag) { return this.getBooleanOrDefault(tag, false); } public boolean getBooleanOrDefault(String tag, boolean defaultValue) { - Entry entry = this.getEntry(tag); - return entry == null ? defaultValue : entry.booleanValue(); + EntryReader reader = this.getEntryReader(tag); + return reader == null ? defaultValue : reader.booleanValue(); } public int getInt(String tag) { @@ -1347,8 +1491,8 @@ public int getInt(String tag) { } public int getIntOrDefault(String tag, int defaultValue) { - Entry entry = this.getEntry(tag); - return entry == null ? defaultValue : entry.intValue(); + EntryReader reader = this.getEntryReader(tag); + return reader == null ? defaultValue : reader.intValue(); } public long getLong(String tag) { @@ -1356,8 +1500,8 @@ public long getLong(String tag) { } public long getLongOrDefault(String tag, long defaultValue) { - Entry entry = this.getEntry(tag); - return entry == null ? defaultValue : entry.longValue(); + EntryReader reader = this.getEntryReader(tag); + return reader == null ? defaultValue : reader.longValue(); } public float getFloat(String tag) { @@ -1365,8 +1509,8 @@ public float getFloat(String tag) { } public float getFloatOrDefault(String tag, float defaultValue) { - Entry entry = this.getEntry(tag); - return entry == null ? defaultValue : entry.floatValue(); + EntryReader reader = this.getEntryReader(tag); + return reader == null ? defaultValue : reader.floatValue(); } public double getDouble(String tag) { @@ -1374,15 +1518,15 @@ public double getDouble(String tag) { } public double getDoubleOrDefault(String tag, double defaultValue) { - Entry entry = this.getEntry(tag); - return entry == null ? defaultValue : entry.doubleValue(); + EntryReader reader = this.getEntryReader(tag); + return reader == null ? defaultValue : reader.doubleValue(); } @Override public boolean containsKey(Object key) { if (!(key instanceof String)) return false; - return (this.getEntry((String) key) != null); + return (this.getEntryReader((String) key) != null); } @Override @@ -1419,90 +1563,45 @@ public Set> entrySet() { public Entry getEntry(String tag) { // Entries are stored under their canonical Datadog name (see Entry's constructor); a lookup by - // an OpenTelemetry rename must canonicalize the same way, or it would hash to the wrong bucket - // and silently miss the entry stored under the Datadog name. - long tagHash = Entry.tagHashOf(tag); - String canonicalTag = (tagHash >>> 32) != 0 ? KnownTagCodec.nameOf(tagHash) : tag; - return this.getEntry(canonicalTag, tagHash); + // an OpenTelemetry rename must resolve to the same id, or it would silently miss the entry. + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return this.knownStore.getEntry(key); + } + HashStore nameStore = this.nameStore; + return nameStore == null ? null : nameStore.getEntry(tag); } /** The entry for a known tag id, found by the id itself rather than by resolving a name. */ public Entry getEntry(long tagId) { - return this.getEntry(Entry.requireKnownName(tagId), tagId); - } - - private Entry getEntry(String canonicalTag, long tagHash) { - Entry local = this.getLocalEntry(canonicalTag, tagHash); - if (local != null) { - // Local entry shadows the parent (local-wins) — unchanged hot path. - return local; - } - // Read-through: miss locally, defer to the frozen parent. Single-parent in phase 1. - // The tombstone check lives only here, on the cold miss+parent path — the hot local hit above - // never touches it. - TagMap parent = this.parent; - if (parent == null) { - return null; - } - if (this.removedFromParent != null && this.removedFromParent.contains(canonicalTag)) { - return null; // tombstoned: removed locally, do not read through - } - return parent.getEntry(canonicalTag, tagHash); - } - - /** Looks up an entry in this map's own buckets only — no read-through to the parent. */ - private Entry getLocalEntry(String tag, long tagHash) { - Object[] thisBuckets = this.buckets; - int hash = Entry.bucketHash(tagHash); - return findInBucket(thisBuckets[hash & (thisBuckets.length - 1)], hash, tag); + return this.knownStore.getEntry(tagId); } /** - * Finds an entry by hash/tag within a single bucket object (Entry | BucketGroup chain | null). + * Like {@link #getEntry(String)}, but answers with a momentary {@link EntryReader}, so a store + * without an Entry per tag builds none. The reader MUST NOT be kept (see {@link EntryReader}); + * call {@link EntryReader#entry()} to keep one. Safe on a frozen map that several threads read. */ - private static Entry findInBucket(Object bucket, int hash, String tag) { - if (bucket instanceof Entry) { - Entry tagEntry = (Entry) bucket; - return tagEntry.matches(tag) ? tagEntry : null; - } else if (bucket instanceof BucketGroup) { - return ((BucketGroup) bucket).findInChain(hash, tag); + public EntryReader getEntryReader(String tag) { + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return this.knownStore.getEntryReader(key); } - return null; + HashStore nameStore = this.nameStore; + return nameStore == null ? null : nameStore.getEntry(tag); } - /** - * Whether an entry that lives at ancestor level {@code fromAncestor} is visible through this - * leaf: not shadowed and not tombstoned by any nearer level (the leaf, or a closer ancestor). - * Nearest-level-wins. This mirrors what {@link #getEntry(String)}'s recursion applies as it - * descends -- each nearer level contributes both its own local entries (shadowing) and its own - * read-through removals (tombstones). A non-leaf level can carry tombstones too: a map may remove - * an inherited key and then itself be frozen and reused as a parent. Exploits universal hashing — - * by {@code _hash} the only local entry that could shadow {@code parentEntry} at a level is in - * that level's same-index bucket, so each nearer level is probed at one bucket using {@code - * parentEntry}'s cached hash (no re-hash, no full-map probe). - */ - boolean parentEntryVisible(Entry parentEntry, TagMap fromAncestor) { - int hash = parentEntry.hash(); - String tag = parentEntry.tag; - for (TagMap nearer = this; nearer != fromAncestor; nearer = nearer.parent) { - // tombstoned by a nearer level (its own read-through removal hides the deeper entry) - if (nearer.removedFromParent != null && nearer.removedFromParent.contains(tag)) { - return false; - } - // shadowed by a nearer level's local entry - Object[] nearerBuckets = nearer.buckets; - Object nearerBucket = nearerBuckets[hash & (nearerBuckets.length - 1)]; - if (findInBucket(nearerBucket, hash, tag) != null) { - return false; - } - } - return true; + /** {@link #getEntryReader(String)} for a known tag id. */ + public EntryReader getEntryReader(long tagId) { + return this.knownStore.getEntryReader(tagId); } @Deprecated @Override public Object put(@Nonnull String tag, Object value) { - TagMap.Entry entry = this.getAndSet(anyEntryFor(tag, value)); + // a null value removes the tag, rather than mapping it to null as HashMap would + TagMap.Entry entry = + value == null ? this.getAndRemove(tag) : this.getAndSet(anyEntryFor(tag, value)); return entry == null ? null : entry.objectValue(); } @@ -1522,100 +1621,110 @@ public void set(@Nullable TagMap.EntryReader newEntryReader) { * from its id or a custom tag from its name. (Entry's own name factories are custom-only.) */ static Entry anyEntryFor(String tag, Object value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newAnyEntry(tagId, value) : Entry.newAnyEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newAnyEntry(key, value) : Entry.newAnyEntry(tag, value); } static Entry objectEntryFor(String tag, CharSequence value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newObjectEntry(tagId, value) : Entry.newObjectEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newObjectEntry(key, value) : Entry.newObjectEntry(tag, value); } static Entry booleanEntryFor(String tag, boolean value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newBooleanEntry(tagId, value) : Entry.newBooleanEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newBooleanEntry(key, value) : Entry.newBooleanEntry(tag, value); } static Entry intEntryFor(String tag, int value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newIntEntry(tagId, value) : Entry.newIntEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newIntEntry(key, value) : Entry.newIntEntry(tag, value); } static Entry longEntryFor(String tag, long value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newLongEntry(tagId, value) : Entry.newLongEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newLongEntry(key, value) : Entry.newLongEntry(tag, value); } static Entry floatEntryFor(String tag, float value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newFloatEntry(tagId, value) : Entry.newFloatEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newFloatEntry(key, value) : Entry.newFloatEntry(tag, value); } static Entry doubleEntryFor(String tag, double value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newDoubleEntry(tagId, value) : Entry.newDoubleEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newDoubleEntry(key, value) : Entry.newDoubleEntry(tag, value); } - public void set(@Nonnull String tag, @Nonnull Object value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + /** A null value removes the tag, as {@code setTag(tag, null)} does on a span. */ + public void set(@Nonnull String tag, @Nullable Object value) { + if (value == null) { + this.getAndRemove(tag); + return; + } + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newAnyEntry(key, value)); } else { - this.putEntry(Entry.newAnyEntry(tag, value)); + this.putCustom(Entry.newAnyEntry(tag, value)); } } - public void set(@Nonnull String tag, @Nonnull CharSequence value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + /** A null value removes the tag, as {@code setTag(tag, null)} does on a span. */ + public void set(@Nonnull String tag, @Nullable CharSequence value) { + if (value == null) { + this.getAndRemove(tag); + return; + } + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newObjectEntry(key, value)); } else { - this.putEntry(Entry.newObjectEntry(tag, value)); + this.putCustom(Entry.newObjectEntry(tag, value)); } } public void set(@Nonnull String tag, boolean value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newBooleanEntry(key, value)); } else { - this.putEntry(Entry.newBooleanEntry(tag, value)); + this.putCustom(Entry.newBooleanEntry(tag, value)); } } public void set(@Nonnull String tag, int value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newIntEntry(key, value)); } else { - this.putEntry(Entry.newIntEntry(tag, value)); + this.putCustom(Entry.newIntEntry(tag, value)); } } public void set(@Nonnull String tag, long value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newLongEntry(key, value)); } else { - this.putEntry(Entry.newLongEntry(tag, value)); + this.putCustom(Entry.newLongEntry(tag, value)); } } public void set(@Nonnull String tag, float value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newFloatEntry(key, value)); } else { - this.putEntry(Entry.newFloatEntry(tag, value)); + this.putCustom(Entry.newFloatEntry(tag, value)); } } public void set(@Nonnull String tag, double value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newDoubleEntry(key, value)); } else { - this.putEntry(Entry.newDoubleEntry(tag, value)); + this.putCustom(Entry.newDoubleEntry(tag, value)); } } @@ -1624,88 +1733,43 @@ public void set(@Nonnull String tag, double value) { * canonical name without the name lookup the String setters pay. The id must name a known tag; * custom tags have no id and use the String setters. */ - /** - * The tracer core's trusted operations on this map, which skip checks the core has already made. - * Restricted to the core; instrumentation uses the checked methods. - */ - @Restricted(allowedIn = {"datadog.trace.core"}) - public Internals internals() { - return new Internals(this); - } - - /** - * See {@link TagMap#internals()}. Created per call so escape analysis removes it: use it within - * the expression ({@code map.internals().setKnown(...)}) rather than storing it. - */ - @NoEscape - public static final class Internals { - private final TagMap map; - - Internals(TagMap map) { - this.map = map; - } - - /* - * Sets a known tag by id without validating it, unlike TagMap.set(long, ...): the caller has - * already checked KnownTagCodec.isKnown(tagId). - */ - public void setKnown(long tagId, @Nonnull Object value) { - this.map.putEntry(new Entry(tagId, null, Entry.ANY, 0L, value)); - } - public void setKnown(long tagId, @Nonnull CharSequence value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.OBJECT, 0L, value)); - } - - public void setKnown(long tagId, boolean value) { - this.map.putEntry( - new Entry( - tagId, null, EntryReader.BOOLEAN, Entry.boolean2Prim(value), Boolean.valueOf(value))); - } - - public void setKnown(long tagId, int value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.INT, Entry.int2Prim(value), null)); - } - - public void setKnown(long tagId, long value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.LONG, Entry.long2Prim(value), null)); - } - - public void setKnown(long tagId, float value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.FLOAT, Entry.float2Prim(value), null)); - } - - public void setKnown(long tagId, double value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.DOUBLE, Entry.double2Prim(value), null)); + /** A null value removes the tag, as {@code setTag(tag, null)} does on a span. */ + public void set(long tagId, @Nullable Object value) { + if (value == null) { + this.getAndRemove(tagId); + return; } + this.putKnown(Entry.newAnyEntry(tagId, value)); } - public void set(long tagId, @Nonnull Object value) { - this.putEntry(Entry.newAnyEntry(tagId, value)); - } - - public void set(long tagId, @Nonnull CharSequence value) { - this.putEntry(Entry.newObjectEntry(tagId, value)); + /** A null value removes the tag, as {@code setTag(tag, null)} does on a span. */ + public void set(long tagId, @Nullable CharSequence value) { + if (value == null) { + this.getAndRemove(tagId); + return; + } + this.putKnown(Entry.newObjectEntry(tagId, value)); } public void set(long tagId, boolean value) { - this.putEntry(Entry.newBooleanEntry(tagId, value)); + this.putKnown(Entry.newBooleanEntry(tagId, value)); } public void set(long tagId, int value) { - this.putEntry(Entry.newIntEntry(tagId, value)); + this.putKnown(Entry.newIntEntry(tagId, value)); } public void set(long tagId, long value) { - this.putEntry(Entry.newLongEntry(tagId, value)); + this.putKnown(Entry.newLongEntry(tagId, value)); } public void set(long tagId, float value) { - this.putEntry(Entry.newFloatEntry(tagId, value)); + this.putKnown(Entry.newFloatEntry(tagId, value)); } public void set(long tagId, double value) { - this.putEntry(Entry.newDoubleEntry(tagId, value)); + this.putKnown(Entry.newDoubleEntry(tagId, value)); } /** @@ -1718,22 +1782,11 @@ public Entry getAndSet(@Nullable Entry newEntry) { if (newEntry == null) { return null; } - // Capture whether the key was tombstoned BEFORE putEntry clears it: a tombstoned key had no - // visible prior value (it was removed), so getAndSet must report null rather than the parent's. - boolean wasTombstoned = - this.removedFromParent != null && this.removedFromParent.contains(newEntry.tag); + this.checkWriteAccess(); - Entry priorLocal = this.putEntry(newEntry); - if (priorLocal != null) { - return priorLocal; // replaced a local entry -> that is the prior value - } - // No local entry was replaced. The prior visible value, if any, was the parent's -- unless the - // key was tombstoned (then it was not visible). set(...) skips this via putEntry (no prior). - if (wasTombstoned || this.parent == null) { - return null; - } - // By the entry's own identity, not its name: the name would only resolve back to the same hash. - return this.parent.getEntry(newEntry.tag, newEntry.tagHash); + return newEntry.tagId() != 0 + ? entryOf(this.knownStore.getAndPut(newEntry)) + : this.nameStoreForWrite().getAndPut(newEntry); } /** @@ -1745,63 +1798,31 @@ public Entry getAndSet(@Nullable Entry newEntry) { Entry putEntry(@Nonnull Entry newEntry) { this.checkWriteAccess(); - // Re-setting a key clears any read-through tombstone for it (the new value overrides the - // removal). Gated on the lazy field, so this is a no-op for the common no-tombstone case. - if (this.removedFromParent != null) { - this.removedFromParent.remove(newEntry.tag); - } - - Object[] thisBuckets = this.buckets; - - int newHash = newEntry.hash(); - int bucketIndex = newHash & (thisBuckets.length - 1); - - Object bucket = thisBuckets[bucketIndex]; - if (bucket == null) { - thisBuckets[bucketIndex] = newEntry; - - this.size += 1; - return null; - } else if (bucket instanceof Entry) { - Entry existingEntry = (Entry) bucket; - if (existingEntry.matches(newEntry.tag)) { - thisBuckets[bucketIndex] = newEntry; - - // replaced existing entry - no size change - return existingEntry; - } else { - thisBuckets[bucketIndex] = - new BucketGroup(existingEntry.hash(), existingEntry, newHash, newEntry); + return newEntry.tagId() != 0 + ? entryOf(this.knownStore.put(newEntry)) + : this.nameStoreForWrite().put(newEntry); + } - this.size += 1; - return null; - } - } else if (bucket instanceof BucketGroup) { - BucketGroup lastGroup = (BucketGroup) bucket; + /** {@link #putEntry} for an entry known to be a known tag's. */ + void putKnown(Entry knownEntry) { + this.checkWriteAccess(); - BucketGroup containingGroup = lastGroup.findContainingGroupInChain(newHash, newEntry.tag); - if (containingGroup != null) { - // replaced existing entry - no size change - return containingGroup._replace(newHash, newEntry); - } + this.knownStore.put(knownEntry); + } - if (!lastGroup.insertInChain(newHash, newEntry)) { - thisBuckets[bucketIndex] = new BucketGroup(newHash, newEntry, lastGroup); - } - this.size += 1; - return null; - } + /** {@link #putEntry} for an entry known to be a custom tag's. */ + void putCustom(Entry customEntry) { + this.checkWriteAccess(); - // unreachable - return null; + this.nameStoreForWrite().put(customEntry); } public Entry getAndSet(@Nonnull String tag, Object value) { - return this.getAndSet(anyEntryFor(tag, value)); + return value == null ? this.getAndRemove(tag) : this.getAndSet(anyEntryFor(tag, value)); } public Entry getAndSet(@Nonnull String tag, CharSequence value) { - return this.getAndSet(objectEntryFor(tag, value)); + return value == null ? this.getAndRemove(tag) : this.getAndSet(objectEntryFor(tag, value)); } public TagMap.Entry getAndSet(@Nonnull String tag, boolean value) { @@ -1855,209 +1876,44 @@ public void putAll(TagMap that) { } private void putAllOptimizedMap(TagMap that) { - if (that.parent != null) { - // read-through source: the bucket-copy paths below only see that's local entries, so they - // would drop entries visible only through that's ancestor chain (and ignore its tombstones). + if (that.readsThrough()) { + // read-through source: the bucket-copy paths only see that's local entries, so they would + // drop entries visible only through that's ancestor chain (and ignore its tombstones). // Union-copy the full visible set instead -- still shares the source Entry objects. that.forEach(this, (self, entry) -> self.set(entry)); return; } - if (this.size == 0) { - this.putAllIntoEmptyMap(that); - } else { - this.putAllMerge(that); - } - } - - private void putAllMerge(TagMap that) { - Object[] thisBuckets = this.buckets; - Object[] thatBuckets = that.buckets; - - // Since TagMap-s don't support expansion, buckets are perfectly aligned - // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check - // elimination - for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) { - Object thatBucket = thatBuckets[i]; - - // if nothing incoming, nothing to do - if (thatBucket == null) continue; - - Object thisBucket = thisBuckets[i]; - if (thisBucket == null) { - // This bucket is null, easy case - // Either copy over the sole entry or clone the BucketGroup chain - - if (thatBucket instanceof Entry) { - thisBuckets[i] = thatBucket; - this.size += 1; - } else if (thatBucket instanceof BucketGroup) { - BucketGroup thatGroup = (BucketGroup) thatBucket; - - BucketGroup thisNewGroup = thatGroup.cloneChain(); - thisBuckets[i] = thisNewGroup; - this.size += thisNewGroup.sizeInChain(); - } - } else if (thisBucket instanceof Entry) { - // This bucket is a single entry, medium complexity case - // If other side is an Entry - just merge the entries into a bucket - // If other side is a BucketGroup - then clone the group and insert the entry normally into - // the cloned group - - Entry thisEntry = (Entry) thisBucket; - int thisHash = thisEntry.hash(); - - if (thatBucket instanceof Entry) { - Entry thatEntry = (Entry) thatBucket; - int thatHash = thatEntry.hash(); - - if (thisHash == thatHash && thisEntry.matches(thatEntry.tag())) { - thisBuckets[i] = thatEntry; - // replacing entry, no size change - } else { - thisBuckets[i] = - new BucketGroup( - thisHash, thisEntry, - thatHash, thatEntry); - this.size += 1; - } - } else if (thatBucket instanceof BucketGroup) { - BucketGroup thatGroup = (BucketGroup) thatBucket; - - // Clone the other group, then place this entry into that group - BucketGroup thisNewGroup = thatGroup.cloneChain(); - int thisNewGroupSize = thisNewGroup.sizeInChain(); - - Entry incomingEntry = thisNewGroup.findInChain(thisHash, thisEntry.tag()); - if (incomingEntry != null) { - // there's already an entry w/ the same tag from the incoming TagMap - // incoming entry clobbers the existing try, so we're done - thisBuckets[i] = thisNewGroup; - - // overlapping group - subtract one for clobbered existing entry - this.size += thisNewGroupSize - 1; - } else if (thisNewGroup.insertInChain(thisHash, thisEntry)) { - // able to add thisEntry into the existing groups - thisBuckets[i] = thisNewGroup; - - // non overlapping group - existing entry already accounted for in this.size - this.size += thisNewGroupSize; - } else { - // unable to add into the existing groups - thisBuckets[i] = new BucketGroup(thisHash, thisEntry, thisNewGroup); - - // non overlapping group - existing entry already accounted for in this.size - this.size += thisNewGroupSize; - } - } - } else if (thisBucket instanceof BucketGroup) { - // This bucket is a BucketGroup, medium to hard case - // If the other side is an entry, just normal insertion procedure - no cloning required - BucketGroup thisGroup = (BucketGroup) thisBucket; - - if (thatBucket instanceof Entry) { - Entry thatEntry = (Entry) thatBucket; - int thatHash = thatEntry.hash(); - - if (thisGroup.replaceInChain(thatHash, thatEntry) != null) { - // replaced existing entry no size change - } else if (thisGroup.insertInChain(thatHash, thatEntry)) { - this.size += 1; - } else { - thisBuckets[i] = new BucketGroup(thatHash, thatEntry, thisGroup); - this.size += 1; - } - } else if (thatBucket instanceof BucketGroup) { - // Most complicated case - need to walk that bucket group chain and update this chain - BucketGroup thatGroup = (BucketGroup) thatBucket; - - // Taking the easy / expensive way out for updating size - int thisPrevGroupSize = thisGroup.sizeInChain(); - - BucketGroup thisNewGroup = thisGroup.replaceOrInsertAllInChain(thatGroup); - int thisNewGroupSize = thisNewGroup.sizeInChain(); - - thisBuckets[i] = thisNewGroup; - this.size += (thisNewGroupSize - thisPrevGroupSize); - } - } - } - } - - /* - * Specially optimized version of putAll for the common case of destination map being empty - */ - private void putAllIntoEmptyMap(TagMap that) { - Object[] thisBuckets = this.buckets; - Object[] thatBuckets = that.buckets; - - // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check - // elimination - for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) { - Object thatBucket = thatBuckets[i]; - - // faster to explicitly null check first, then do instanceof - if (thatBucket == null) { - // do nothing - } else if (thatBucket instanceof BucketGroup) { - // if it is a BucketGroup, then need to clone - BucketGroup thatGroup = (BucketGroup) thatBucket; - - thisBuckets[i] = thatGroup.cloneChain(); - } else { // if ( thatBucket instanceof Entry ) - thisBuckets[i] = thatBucket; - } + this.knownStore.putAllLocal(that.knownStore); + HashStore thatNames = that.nameStore; + if (thatNames != null && thatNames.count != 0) { + this.nameStoreForWrite().putAllLocal(thatNames); } - this.size = that.size; } public void fillMap(Map map) { - if (this.parent != null) { + if (this.readsThrough()) { // read-through source: the bucket-only loop below sees just local entries, so it would drop // tags visible only through the parent chain (and ignore shadowing/tombstones). Walk the full // visible union instead -- mirrors putAllOptimizedMap. this.forEach(map, (m, entry) -> m.put(entry.tag(), entry.objectValue())); return; } - - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - - map.put(thisEntry.tag, thisEntry.objectValue()); - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - thisGroup.fillMapFromChain(map); - } + this.knownStore.fillMapLocal(map); + if (this.nameStore != null) { + this.nameStore.fillMapLocal(map); } } public void fillStringMap(Map stringMap) { - if (this.parent != null) { + if (this.readsThrough()) { // read-through source: walk the visible union so parent-only tags aren't dropped -- see // fillMap. this.forEach(stringMap, (m, entry) -> m.put(entry.tag(), entry.stringValue())); return; } - - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - - stringMap.put(thisEntry.tag, thisEntry.stringValue()); - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - thisGroup.fillStringMapFromChain(stringMap); - } + this.knownStore.fillStringMapLocal(stringMap); + if (this.nameStore != null) { + this.nameStore.fillStringMapLocal(stringMap); } } @@ -2077,94 +1933,29 @@ public Entry getAndRemove(String tag) { this.checkWriteAccess(); // See getEntry: entries are stored under their canonical Datadog name, so a removal by an - // OpenTelemetry rename must canonicalize first to find (and tombstone) the right entry. - long tagHash = Entry.tagHashOf(tag); - String canonicalTag = (tagHash >>> 32) != 0 ? KnownTagCodec.nameOf(tagHash) : tag; - return this.getAndRemove(canonicalTag, tagHash); + // OpenTelemetry rename must resolve to the id first to find (and tombstone) the right entry. + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? entryOf(this.knownStore.remove(key)) : this.removeCustom(tag); } /** Removes the entry for a known tag id; see {@link #getEntry(long)}. */ public Entry getAndRemove(long tagId) { this.checkWriteAccess(); - return this.getAndRemove(Entry.requireKnownName(tagId), tagId); - } - - private Entry getAndRemove(String canonicalTag, long tagHash) { - Entry localRemoved = this.removeLocal(canonicalTag, tagHash); - - TagMap parent = this.parent; - if (parent != null) { - // Read-through: if the parent still exposes this key, removing it must also hide it from - // fall-through — install a tombstone. The prior *visible* value (Map.remove contract) is the - // local entry if there was one, otherwise the parent's (which we now hide). Single-parent in - // phase 1; rare path (only when removing a parent-exposed key). - boolean alreadyTombstoned = - this.removedFromParent != null && this.removedFromParent.contains(canonicalTag); - if (!alreadyTombstoned) { - Entry parentEntry = parent.getEntry(canonicalTag, tagHash); - if (parentEntry != null) { - if (this.removedFromParent == null) { - // Small initial capacity: this set is rare and almost always holds only a handful of - // tombstoned keys, so the default 16-bucket HashSet table would be wasteful. - this.removedFromParent = new HashSet<>(4); - } - this.removedFromParent.add(canonicalTag); - return localRemoved != null ? localRemoved : parentEntry; - } - } - } - return localRemoved; - } - - /** Removes an entry from this map's own buckets only — no parent/tombstone handling. */ - private Entry removeLocal(String tag, long tagHash) { - Object[] thisBuckets = this.buckets; - - int hash = Entry.bucketHash(tagHash); - int bucketIndex = hash & (thisBuckets.length - 1); - - Object bucket = thisBuckets[bucketIndex]; - // null bucket case - do nothing - if (bucket instanceof Entry) { - Entry existingEntry = (Entry) bucket; - if (existingEntry.matches(tag)) { - thisBuckets[bucketIndex] = null; - - this.size -= 1; - return existingEntry; - } else { - return null; - } - } else if (bucket instanceof BucketGroup) { - BucketGroup lastGroup = (BucketGroup) bucket; - - BucketGroup containingGroup = lastGroup.findContainingGroupInChain(hash, tag); - if (containingGroup == null) { - return null; - } - - Entry existingEntry = containingGroup._remove(hash, tag); - if (containingGroup._isEmpty()) { - this.buckets[bucketIndex] = lastGroup.removeGroupInChain(containingGroup); - } - - this.size -= 1; - return existingEntry; - } - return null; + return entryOf(this.knownStore.remove(tagId)); } public TagMap copy() { - // Construct with the same (frozen, shared) parent up front — the parent is fixed at - // construction. putAll then clones this map's own (local) buckets + size. The copy stays - // independently mutable (writes land on its local buckets, never the shared parent). - TagMap copy = new TagMap(this.parent); - copy.putAllIntoEmptyMap(this); - if (this.removedFromParent != null) { - copy.removedFromParent = new HashSet<>(this.removedFromParent); - } - return copy; + // The same (frozen, shared) parent, fixed at construction, over a copy of this map's own + // entries and tombstones. The copy stays independently mutable (writes land on its local + // store, never the shared parent). + HashStore nameStore = this.nameStore; + boolean borrowed = this.nameStoreBorrowed; + return new TagMap( + this.parent, + this.knownStore.copy(), + nameStore == null || borrowed ? nameStore : nameStore.copy(), + borrowed); } public TagMap immutableCopy() { @@ -2175,172 +1966,80 @@ public TagMap immutableCopy() { } } + /** The tags as momentary readers (see {@link EntryReader}): read each one, don't keep it. */ @Override public Iterator iterator() { return new EntryReaderIterator(this); } + /** + * The tags as stable {@link Entry} objects, unlike {@link #iterator()}: a spliterator usually + * feeds a stream, and a stream may collect what it gets. Free where the store already keeps an + * Entry per tag; a store that doesn't builds one per tag. + */ + @Override + public Spliterator spliterator() { + Iterator readers = this.iterator(); + Iterator entries = + new Iterator() { + @Override + public boolean hasNext() { + return readers.hasNext(); + } + + @Override + public EntryReader next() { + return readers.next().entry(); + } + }; + return Spliterators.spliterator( + entries, this.size(), Spliterator.DISTINCT | Spliterator.NONNULL); + } + + /** + * The tags as stable {@link Entry} objects, so collecting them is safe (see {@link + * #spliterator}). + */ public Stream stream() { return StreamSupport.stream(spliterator(), false); } @Override public void forEach(Consumer consumer) { - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - - consumer.accept(thisEntry); - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - thisGroup.forEachInChain(consumer); - } - } - - // read-through: parent entries not shadowed locally or tombstoned. Kept out of line so the - // common parent == null path stays byte-identical to before (small / inlinable). - if (this.parent != null) { - this.forEachParent(consumer); - } - } - - private void forEachParent(Consumer consumer) { - // Walk the ancestor chain, nearest first. Each entry is emitted once, by the nearest level that - // defines its key, when not shadowed by a nearer level and not tombstoned. - for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { - Object[] parentBuckets = ancestor.buckets; - for (int i = 0; i < parentBuckets.length; ++i) { - Object parentBucket = parentBuckets[i]; - if (parentBucket instanceof Entry) { - Entry parentEntry = (Entry) parentBucket; - if (parentEntryVisible(parentEntry, ancestor)) consumer.accept(parentEntry); - } else if (parentBucket instanceof BucketGroup) { - for (BucketGroup curGroup = (BucketGroup) parentBucket; - curGroup != null; - curGroup = curGroup.prev) { - for (int j = 0; j < BucketGroup.LEN; ++j) { - Entry parentEntry = curGroup._entryAt(j); - if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) - consumer.accept(parentEntry); - } - } - } - } + this.knownStore.forEach(consumer); + HashStore nameStore = this.nameStore; + if (nameStore != null) { + nameStore.forEach(consumer); } } public void forEach(T thisObj, BiConsumer consumer) { - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - - consumer.accept(thisObj, thisEntry); - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - thisGroup.forEachInChain(thisObj, consumer); - } - } - - // read-through: parent entries not shadowed locally or tombstoned (kept out of line). - if (this.parent != null) { - this.forEachParent(thisObj, consumer); - } - } - - private void forEachParent(T thisObj, BiConsumer consumer) { - for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { - Object[] parentBuckets = ancestor.buckets; - for (int i = 0; i < parentBuckets.length; ++i) { - Object parentBucket = parentBuckets[i]; - if (parentBucket instanceof Entry) { - Entry parentEntry = (Entry) parentBucket; - if (parentEntryVisible(parentEntry, ancestor)) consumer.accept(thisObj, parentEntry); - } else if (parentBucket instanceof BucketGroup) { - for (BucketGroup curGroup = (BucketGroup) parentBucket; - curGroup != null; - curGroup = curGroup.prev) { - for (int j = 0; j < BucketGroup.LEN; ++j) { - Entry parentEntry = curGroup._entryAt(j); - if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) { - consumer.accept(thisObj, parentEntry); - } - } - } - } - } + this.knownStore.forEach(thisObj, consumer); + HashStore nameStore = this.nameStore; + if (nameStore != null) { + nameStore.forEach(thisObj, consumer); } } public void forEach( T thisObj, U otherObj, TriConsumer consumer) { - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - - consumer.accept(thisObj, otherObj, thisEntry); - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - thisGroup.forEachInChain(thisObj, otherObj, consumer); - } - } - - // read-through: parent entries not shadowed locally or tombstoned (kept out of line). - if (this.parent != null) { - this.forEachParent(thisObj, otherObj, consumer); - } - } - - private void forEachParent( - T thisObj, U otherObj, TriConsumer consumer) { - for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { - Object[] parentBuckets = ancestor.buckets; - for (int i = 0; i < parentBuckets.length; ++i) { - Object parentBucket = parentBuckets[i]; - if (parentBucket instanceof Entry) { - Entry parentEntry = (Entry) parentBucket; - if (parentEntryVisible(parentEntry, ancestor)) - consumer.accept(thisObj, otherObj, parentEntry); - } else if (parentBucket instanceof BucketGroup) { - for (BucketGroup curGroup = (BucketGroup) parentBucket; - curGroup != null; - curGroup = curGroup.prev) { - for (int j = 0; j < BucketGroup.LEN; ++j) { - Entry parentEntry = curGroup._entryAt(j); - if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) { - consumer.accept(thisObj, otherObj, parentEntry); - } - } - } - } - } + this.knownStore.forEach(thisObj, otherObj, consumer); + HashStore nameStore = this.nameStore; + if (nameStore != null) { + nameStore.forEach(thisObj, otherObj, consumer); } } public void clear() { this.checkWriteAccess(); - Arrays.fill(this.buckets, null); - this.size = 0; - // clear() removes ALL mappings, including any inherited through read-through. Detaching the - // parent (rather than tombstoning every inherited key) is simpler and cheaper, and leaves an + // clear() removes ALL mappings, including any inherited through read-through. Fresh parentless + // stores (rather than tombstoning every inherited key) are simpler and cheaper, and leave an // empty, parent-less map. Detach is one-way -- the parent is never re-pointed. + this.knownStore = IdStoreFactory.Default.INSTANCE.create(null); + this.nameStore = null; + this.nameStoreBorrowed = false; this.parent = null; - this.removedFromParent = null; } public TagMap freeze() { @@ -2358,85 +2057,21 @@ public void checkWriteAccess() { } void checkIntegrity() { - // Decided to use if ( cond ) throw new IllegalStateException rather than assert - // That was done to avoid the extra static initialization needed for an assertion - // While that's probably an unnecessary optimization, this method is only called in tests - - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - int thisHash = thisEntry.hash(); - - int expectedBucket = thisHash & (thisBuckets.length - 1); - if (expectedBucket != i) { - throw new IllegalStateException("incorrect bucket"); - } - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - for (BucketGroup curGroup = thisGroup; curGroup != null; curGroup = curGroup.prev) { - for (int j = 0; j < BucketGroup.LEN; ++j) { - Entry thisEntry = curGroup._entryAt(i); - if (thisEntry == null) continue; - - int thisHash = thisEntry.hash(); - assert curGroup._hashAt(i) == thisHash; - - int expectedBucket = thisHash & (thisBuckets.length - 1); - if (expectedBucket != i) { - throw new IllegalStateException("incorrect bucket"); - } - } - } - } - } - - if (this.size != this.computeSize()) { - throw new IllegalStateException("incorrect size"); - } - // Local-structure invariant: the size counter's emptiness must match the local buckets. Uses - // the local (this.size == 0), NOT isEmpty(), which under read-through resolves the parent too. - if ((this.size == 0) != this.checkIfEmpty()) { - throw new IllegalStateException("incorrect empty status"); + this.knownStore.checkIntegrity(); + if (this.nameStore != null && !this.nameStoreBorrowed) { + this.nameStore.checkIntegrity(); } } + /** The local entries, recomputed from both stores' structure. */ int computeSize() { - Object[] thisBuckets = this.buckets; - - int size = 0; - for (int i = 0; i < thisBuckets.length; ++i) { - Object curBucket = thisBuckets[i]; - - if (curBucket instanceof Entry) { - size += 1; - } else if (curBucket instanceof BucketGroup) { - BucketGroup curGroup = (BucketGroup) curBucket; - size += curGroup.sizeInChain(); - } - } - return size; + HashStore nameStore = this.nameStore; + return this.knownStore.computeSize() + + (nameStore == null || this.nameStoreBorrowed ? 0 : nameStore.computeSize()); } boolean checkIfEmpty() { - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object curBucket = thisBuckets[i]; - - if (curBucket instanceof Entry) { - return false; - } else if (curBucket instanceof BucketGroup) { - BucketGroup curGroup = (BucketGroup) curBucket; - if (!curGroup.isEmptyChain()) return false; - } - } - - return true; + return this.computeSize() == 0; } @Override @@ -2474,19 +2109,19 @@ public String toString() { String toPrettyString() { boolean first = true; - StringBuilder ledger = new StringBuilder(128); - ledger.append('{'); + StringBuilder builder = new StringBuilder(128); + builder.append('{'); for (EntryReader entry : this) { if (first) { first = false; } else { - ledger.append(", "); + builder.append(", "); } - ledger.append(entry.tag()).append('=').append(entry.stringValue()); + builder.append(entry.tag()).append('=').append(entry.stringValue()); } - ledger.append('}'); - return ledger.toString(); + builder.append('}'); + return builder.toString(); } /** @@ -2494,48 +2129,26 @@ String toPrettyString() { * debugging */ String toInternalString() { - Object[] thisBuckets = this.buckets; - - StringBuilder ledger = new StringBuilder(128); - for (int i = 0; i < thisBuckets.length; ++i) { - ledger.append('[').append(i).append("] = "); - - Object thisBucket = thisBuckets[i]; - if (thisBucket == null) { - ledger.append("null"); - } else if (thisBucket instanceof Entry) { - ledger.append('{').append(thisBucket).append('}'); - } else if (thisBucket instanceof BucketGroup) { - for (BucketGroup curGroup = (BucketGroup) thisBucket; - curGroup != null; - curGroup = curGroup.prev) { - ledger.append(curGroup).append(" -> "); - } - } - ledger.append('\n'); - } - return ledger.toString(); + HashStore nameStore = this.nameStore; + return "knownStore:\n" + + this.knownStore.toInternalString() + + "nameStore:\n" + + (nameStore == null ? "none\n" : nameStore.toInternalString()); } abstract static class IteratorBase { - private final TagMap map; - - // the level whose buckets are currently being walked: the leaf (map) first, then each ancestor - // in turn (read-through union). map == level means we're on the leaf's own entries. - private TagMap level; - private Object[] buckets; + // Two phases: the id store's visible entries, through its own cursor, then the custom store's + // chain -- the leaf's buckets first, then each ancestor's in turn (read-through union). + private IdStore.Cursor knownCursor; // null once the id phase is done + private final HashStore nameLeaf; + private HashStore nameLevel; // the custom level being walked; null before and after + private HashSupport.BucketWalker walker; - private Entry nextEntry; - - private int bucketIndex = -1; - - private BucketGroup group = null; - private int groupIndex = 0; + private EntryReader nextEntry; IteratorBase(TagMap map) { - this.map = map; - this.level = map; - this.buckets = map.buckets; + this.knownCursor = map.knownStore.cursor(); + this.nameLeaf = map.nameStore; } public final boolean hasNext() { @@ -2545,9 +2158,9 @@ public final boolean hasNext() { return this.nextEntry != null; } - final Entry nextEntryOrThrowNoSuchElement() { + final EntryReader nextEntryOrThrowNoSuchElement() { if (this.nextEntry != null) { - Entry nextEntry = this.nextEntry; + EntryReader nextEntry = this.nextEntry; this.nextEntry = null; return nextEntry; } @@ -2559,72 +2172,38 @@ final Entry nextEntryOrThrowNoSuchElement() { } } - final Entry nextEntryOrNull() { - if (this.nextEntry != null) { - Entry nextEntry = this.nextEntry; - this.nextEntry = null; - return nextEntry; - } - - return this.hasNext() ? this.nextEntry : null; - } - - private final Entry advance() { - while (true) { - Entry tagEntry = this.rawAdvance(); - if (tagEntry != null) { - // leaf entries emit as-is; ancestor entries only if visible from the leaf -- not shadowed - // by a nearer level and not tombstoned. (parentEntryVisible walks the nearer levels.) - if (this.level == this.map || this.map.parentEntryVisible(tagEntry, this.level)) { - return tagEntry; - } - continue; // ancestor entry shadowed/tombstoned -> skip + private EntryReader advance() { + IdStore.Cursor knownCursor = this.knownCursor; + if (knownCursor != null) { + EntryReader reader = knownCursor.next(); + if (reader != null) { + return reader; } - - // current level exhausted; advance to the next ancestor's buckets (read-through union) - if (this.level.parent != null) { - this.level = this.level.parent; - this.buckets = this.level.buckets; - this.bucketIndex = -1; - this.group = null; - this.groupIndex = 0; - continue; + this.knownCursor = null; + if (this.nameLeaf == null) { + return null; } - return null; - } - } - - /** Next raw entry in the current bucket array, ignoring shadowing/tombstones. */ - private final Entry rawAdvance() { - while (this.bucketIndex < this.buckets.length) { - if (this.group != null) { - for (++this.groupIndex; this.groupIndex < BucketGroup.LEN; ++this.groupIndex) { - Entry tagEntry = this.group._entryAt(this.groupIndex); - if (tagEntry != null) return tagEntry; + this.nameLevel = this.nameLeaf; + this.walker = new HashSupport.BucketWalker(); + this.walker.reset(this.nameLeaf.buckets); + } + + HashStore level = this.nameLevel; + while (level != null) { + Entry entry = this.walker.nextEntry(); + if (entry != null) { + // leaf entries emit as-is; ancestor entries only if not shadowed by a nearer level + if (level == this.nameLeaf || this.nameLeaf.parentEntryVisible(entry, level)) { + return entry; } - - // done processing - that group, go to next group - this.group = this.group.prev; - this.groupIndex = -1; + continue; } - - // if the group is null, then we've finished the current bucket - so advance the bucket - if (this.group == null) { - for (++this.bucketIndex; this.bucketIndex < this.buckets.length; ++this.bucketIndex) { - Object bucket = this.buckets[this.bucketIndex]; - - if (bucket instanceof Entry) { - return (Entry) bucket; - } else if (bucket instanceof BucketGroup) { - this.group = (BucketGroup) bucket; - this.groupIndex = -1; - - break; - } - } + level = level.parent; + this.nameLevel = level; + if (level != null) { + this.walker.reset(level.buckets); } } - return null; } } @@ -2799,6 +2378,31 @@ Entry findInChain(int hash, String tag) { return null; } + /** + * {@link #findInChain} for a known tag: matches on the entry's id, so it needs no name. Only an + * entry stored under {@code tagId} has that id, and the hash check filters first. + */ + Entry findInChainById(int hash, long tagId) { + for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { + Entry curEntry = curGroup._findById(hash, tagId); + if (curEntry != null) return curEntry; + } + return null; + } + + Entry _findById(int hash, long tagId) { + if (this.hash0 == hash && this.entry0.tagHash == tagId) { + return this.entry0; + } else if (this.hash1 == hash && this.entry1.tagHash == tagId) { + return this.entry1; + } else if (this.hash2 == hash && this.entry2.tagHash == tagId) { + return this.entry2; + } else if (this.hash3 == hash && this.entry3.tagHash == tagId) { + return this.entry3; + } + return null; + } + Entry _find(int hash, String tag) { // if ( this._mayContain(hash) ) return null; @@ -2988,6 +2592,28 @@ Entry _remove(int hash, String tag) { return existingEntry; } + BucketGroup cloneChain() { + BucketGroup thisClone = this._cloneEntries(); + + BucketGroup thisPriorClone = thisClone; + for (BucketGroup curGroup = this.prev; curGroup != null; curGroup = curGroup.prev) { + BucketGroup newClone = curGroup._cloneEntries(); + thisPriorClone.prev = newClone; + + thisPriorClone = newClone; + } + + return thisClone; + } + + BucketGroup _cloneEntries() { + return new BucketGroup( + this.hash0, this.entry0, + this.hash1, this.entry1, + this.hash2, this.entry2, + this.hash3, this.entry3); + } + void forEachInChain(Consumer consumer) { for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { curGroup._forEach(consumer); @@ -3028,79 +2654,17 @@ void _forEach(T thisObj, U otherObj, TriConsumer map) { - for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { - curGroup._fillMap(map); - } - } - - void _fillMap(Map map) { - Entry entry0 = this.entry0; - if (entry0 != null) map.put(entry0.tag, entry0.objectValue()); - - Entry entry1 = this.entry1; - if (entry1 != null) map.put(entry1.tag, entry1.objectValue()); - - Entry entry2 = this.entry2; - if (entry2 != null) map.put(entry2.tag, entry2.objectValue()); - - Entry entry3 = this.entry3; - if (entry3 != null) map.put(entry3.tag, entry3.objectValue()); - } - - void fillStringMapFromChain(Map map) { - for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { - curGroup._fillStringMap(map); - } - } - - void _fillStringMap(Map map) { - Entry entry0 = this.entry0; - if (entry0 != null) map.put(entry0.tag, entry0.stringValue()); - - Entry entry1 = this.entry1; - if (entry1 != null) map.put(entry1.tag, entry1.stringValue()); - - Entry entry2 = this.entry2; - if (entry2 != null) map.put(entry2.tag, entry2.stringValue()); - - Entry entry3 = this.entry3; - if (entry3 != null) map.put(entry3.tag, entry3.stringValue()); - } - - BucketGroup cloneChain() { - BucketGroup thisClone = this._cloneEntries(); - - BucketGroup thisPriorClone = thisClone; - for (BucketGroup curGroup = this.prev; curGroup != null; curGroup = curGroup.prev) { - BucketGroup newClone = curGroup._cloneEntries(); - thisPriorClone.prev = newClone; - - thisPriorClone = newClone; - } - - return thisClone; - } - - BucketGroup _cloneEntries() { - return new BucketGroup( - this.hash0, this.entry0, - this.hash1, this.entry1, - this.hash2, this.entry2, - this.hash3, this.entry3); - } - @Override public String toString() { - StringBuilder ledger = new StringBuilder(32); - ledger.append('['); + StringBuilder builder = new StringBuilder(32); + builder.append('['); for (int i = 0; i < BucketGroup.LEN; ++i) { - if (i != 0) ledger.append(", "); + if (i != 0) builder.append(", "); - ledger.append(this._entryAt(i)); + builder.append(this._entryAt(i)); } - ledger.append(']'); - return ledger.toString(); + builder.append(']'); + return builder.toString(); } } @@ -3123,9 +2687,23 @@ public boolean isEmpty() { @Override public Iterator> iterator() { - @SuppressWarnings({"rawtypes", "unchecked"}) - Iterator> iter = (Iterator) this.map.iterator(); - return iter; + return new EntriesIterator(this.map); + } + } + + /** + * Map entries for the {@link Map} view: an Entry is its own, while a flyweight reader (a dense + * store's) builds one, since a caller may keep it. + */ + static final class EntriesIterator extends IteratorBase + implements Iterator> { + EntriesIterator(TagMap map) { + super(map); + } + + @Override + public Map.Entry next() { + return this.nextEntryOrThrowNoSuchElement().mapEntry(); } } @@ -3208,113 +2786,6 @@ public Object next() { } } -final class EntryReadingHelper implements TagMap.EntryReader { - private Map.Entry mapEntry; - private String tag; - private Object value; - private long tagId; - - void set(String tag, Object value) { - this.mapEntry = null; - this.tag = tag; - this.value = value; - this.tagId = TagMap.TAG_ID_NOT_COMPUTED; // resolve lazily via keyOf on first tagId() access - } - - void set(Map.Entry mapEntry) { - this.mapEntry = mapEntry; - this.tag = mapEntry.getKey(); - this.value = mapEntry.getValue(); - this.tagId = TagMap.TAG_ID_NOT_COMPUTED; // resolve lazily via keyOf on first tagId() access - } - - @Override - public String tag() { - return this.tag; - } - - @Override - public long tagId() { - long id = this.tagId; - if (id != TagMap.TAG_ID_NOT_COMPUTED) return id; - - id = KnownTagCodec.keyOf(this.tag); - this.tagId = id; - return id; - } - - @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 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); - } - - @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; - } - - @Override - public TagMap.Entry entry() { - return TagMap.anyEntryFor(this.tag, this.value); - } - - @Override - public Map.Entry mapEntry() { - Map.Entry mapEntry = this.mapEntry; - return (mapEntry != null) ? mapEntry : this.entry(); - } -} - final class TagValueConversions { TagValueConversions() {} diff --git a/internal-api/src/main/java/datadog/trace/api/TagStore.java b/internal-api/src/main/java/datadog/trace/api/TagStore.java new file mode 100644 index 00000000000..b0e63cbf15f --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/TagStore.java @@ -0,0 +1,36 @@ +package datadog.trace.api; + +/** + * A span's tags as stored, for the tracer core's processing pipeline: loads and stores go straight + * to storage, with no interception or interpretation. Thread safety is up to the implementation; + * {@code DDSpanContext}'s expects the span's tag lock to be held. + * + *

    Its own verbs, {@code load} and {@code store}, keep it apart from {@link TagMap}'s {@code + * get}/{@code set} and the span's intercepted {@code getTag}/{@code setTag}, so a type can offer + * several of these without a clash. + * + *

    A known tag can be addressed by its {@link KnownTags} id or by its name, and both address the + * same tag. Prefer the id: it skips classifying the name on every call. A custom tag has no id. + */ +public interface TagStore { + /** The tag's value, or null when it's absent. */ + Object load(String tag); + + /** The tag's value as a string, or null when it's absent. */ + String loadString(String tag); + + /** The known tag's value, or null when it's absent. */ + Object load(long tagId); + + /** The known tag's value as a string, or null when it's absent. */ + String loadString(long tagId); + + /** Stores the tag; a null value removes it. */ + void store(String tag, Object value); + + /** Stores the known tag; a null value removes it. */ + void store(long tagId, Object value); + + /** Stores the entry's tag; a null entry is a no-op. */ + void store(TagMap.EntryReader entry); +} diff --git a/internal-api/src/main/java/datadog/trace/api/endpoint/EndpointResolver.java b/internal-api/src/main/java/datadog/trace/api/endpoint/EndpointResolver.java index c5e47328734..29533b14e18 100644 --- a/internal-api/src/main/java/datadog/trace/api/endpoint/EndpointResolver.java +++ b/internal-api/src/main/java/datadog/trace/api/endpoint/EndpointResolver.java @@ -1,7 +1,8 @@ package datadog.trace.api.endpoint; -import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ENDPOINT; +import static datadog.trace.api.KnownTags.HTTP_ENDPOINT_ID; +import datadog.trace.api.TagStore; import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,9 +51,7 @@ public EndpointResolver(boolean enabled, boolean alwaysSimplifiedEndpoint) { */ @Nullable public String resolveEndpoint( - java.util.Map unsafeTags, - @Nullable String httpRoute, - @Nullable String httpUrl) { + TagStore unsafeTags, @Nullable String httpRoute, @Nullable String httpUrl) { if (!enabled) { return null; } @@ -61,7 +60,7 @@ public String resolveEndpoint( if (alwaysSimplifiedEndpoint) { String endpoint = computeEndpoint(httpUrl); if (endpoint != null) { - unsafeTags.put(HTTP_ENDPOINT, endpoint); + unsafeTags.store(HTTP_ENDPOINT_ID, endpoint); } return endpoint; } @@ -74,7 +73,7 @@ public String resolveEndpoint( // Compute endpoint from URL and tag the span String endpoint = computeEndpoint(httpUrl); if (endpoint != null) { - unsafeTags.put(HTTP_ENDPOINT, endpoint); + unsafeTags.store(HTTP_ENDPOINT_ID, endpoint); } return endpoint; } diff --git a/internal-api/src/main/java/datadog/trace/api/naming/NamingSchema.java b/internal-api/src/main/java/datadog/trace/api/naming/NamingSchema.java index 9ddf3178f3b..7876901a68d 100644 --- a/internal-api/src/main/java/datadog/trace/api/naming/NamingSchema.java +++ b/internal-api/src/main/java/datadog/trace/api/naming/NamingSchema.java @@ -1,6 +1,6 @@ package datadog.trace.api.naming; -import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import java.util.function.Supplier; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -232,7 +232,7 @@ interface ForPeerService { * @param unsafeTags the span tags. Map to be mutated */ @Nonnull - void tags(@Nonnull TagMap unsafeTags); + void tags(@Nonnull TagStore unsafeTags); } interface ForServer { diff --git a/internal-api/src/main/java/datadog/trace/api/naming/v0/PeerServiceNamingV0.java b/internal-api/src/main/java/datadog/trace/api/naming/v0/PeerServiceNamingV0.java index 3e76d657069..004a8e654bd 100644 --- a/internal-api/src/main/java/datadog/trace/api/naming/v0/PeerServiceNamingV0.java +++ b/internal-api/src/main/java/datadog/trace/api/naming/v0/PeerServiceNamingV0.java @@ -1,6 +1,6 @@ package datadog.trace.api.naming.v0; -import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import datadog.trace.api.naming.NamingSchema; import javax.annotation.Nonnull; @@ -12,5 +12,5 @@ public boolean supports() { @Nonnull @Override - public void tags(@Nonnull final TagMap unsafeTags) {} + public void tags(@Nonnull final TagStore unsafeTags) {} } diff --git a/internal-api/src/main/java/datadog/trace/api/naming/v1/PeerServiceNamingV1.java b/internal-api/src/main/java/datadog/trace/api/naming/v1/PeerServiceNamingV1.java index 827a7489e09..4f88c13aa46 100644 --- a/internal-api/src/main/java/datadog/trace/api/naming/v1/PeerServiceNamingV1.java +++ b/internal-api/src/main/java/datadog/trace/api/naming/v1/PeerServiceNamingV1.java @@ -1,7 +1,11 @@ package datadog.trace.api.naming.v1; -import datadog.trace.api.DDTags; -import datadog.trace.api.TagMap; +import static datadog.trace.api.KnownTags.COMPONENT_ID; +import static datadog.trace.api.KnownTags.DD_PEER_SERVICE_SOURCE_ID; +import static datadog.trace.api.KnownTags.PEER_SERVICE_ID; +import static datadog.trace.api.KnownTags.SPAN_KIND_ID; + +import datadog.trace.api.TagStore; import datadog.trace.api.naming.NamingSchema; import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags; import datadog.trace.bootstrap.instrumentation.api.Tags; @@ -53,8 +57,8 @@ public boolean supports() { return true; } - private void resolve(@Nonnull final TagMap unsafeTags) { - final Object component = unsafeTags.getObject(Tags.COMPONENT); + private void resolve(@Nonnull final TagStore unsafeTags) { + final Object component = unsafeTags.load(COMPONENT_ID); // avoid issues with UTF8ByteString or others final String componentString = component == null ? null : component.toString(); final String override = overridesByComponent.get(componentString); @@ -71,14 +75,15 @@ private void resolve(@Nonnull final TagMap unsafeTags) { resolveBy(unsafeTags, DEFAULT_PRECURSORS); } - private boolean resolveBy(@Nonnull final TagMap unsafeTags, @Nullable final String[] precursors) { + private boolean resolveBy( + @Nonnull final TagStore unsafeTags, @Nullable final String[] precursors) { if (precursors == null) { return false; } Object value = null; String source = null; for (String precursor : precursors) { - value = unsafeTags.getObject(precursor); + value = unsafeTags.load(precursor); if (value != null) { // we have a match. Use the tag name for the source source = precursor; @@ -90,18 +95,18 @@ private boolean resolveBy(@Nonnull final TagMap unsafeTags, @Nullable final Stri return true; } - private void set(@Nonnull final TagMap unsafeTags, Object value, String source) { + private void set(@Nonnull final TagStore unsafeTags, Object value, String source) { if (value != null) { - unsafeTags.put(Tags.PEER_SERVICE, value); - unsafeTags.put(DDTags.PEER_SERVICE_SOURCE, source); + unsafeTags.store(PEER_SERVICE_ID, value); + unsafeTags.store(DD_PEER_SERVICE_SOURCE_ID, source); } } @Nonnull @Override - public void tags(@Nonnull final TagMap unsafeTags) { + public void tags(@Nonnull final TagStore unsafeTags) { // check span.kind eligibility - final Object kind = unsafeTags.getObject(Tags.SPAN_KIND); + final Object kind = unsafeTags.load(SPAN_KIND_ID); if (Tags.SPAN_KIND_CLIENT.equals(kind) || Tags.SPAN_KIND_PRODUCER.equals(kind)) { // we can calculate the peer service now resolve(unsafeTags); diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpan.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpan.java index 5e3918f123c..a9a4a8cc52f 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpan.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpan.java @@ -27,7 +27,8 @@ public interface AgentSpan ImplicitContextKeyed, Context, IGSpanInfo, - WithAgentSpan { + WithAgentSpan, + SpanData { /** * Extracts the span from the current {@link Context}. @@ -177,6 +178,18 @@ default AgentSpan setTagsFrom(final TagContributor contributor) { return this; } + /** + * Sets tags recorded earlier from a source that outlives the span -- the cached form of {@link + * #setTagsFrom(Object, TagExtractor)}: equivalent to replaying the recorded writes, without + * reading the source again. See {@link FrozenTags}. + */ + default AgentSpan setTagsFrom(final FrozenTags frozen) { + if (frozen != null) { + frozen.replay(this); + } + return this; + } + @Override AgentSpan setTag(String key, Number value); 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 71767ea7fae..c0fb2936ad0 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 @@ -360,6 +360,21 @@ default AgentSpan startSpan(@Nonnull SpanPrototype prototype, CharSequence opera */ SpanBuilder singleSpanBuilder(String instrumentationName, CharSequence spanName); + /** + * Returns a SpanBuilder seeded from a {@link SpanPrototype}, like {@link + * #buildSpan(SpanPrototype, CharSequence)}, that produces one and only one span, so it can be + * cheaper (see {@link #singleSpanBuilder(String, CharSequence)}). {@code operationName} + * overrides the prototype's when non-null. + * + *

    This default seeds identity only, like {@link #buildSpan(SpanPrototype, CharSequence)}'s. + */ + default SpanBuilder singleSpanBuilder( + @Nonnull SpanPrototype prototype, CharSequence operationName) { + return singleSpanBuilder( + prototype.instrumentationName(), + operationName != null ? operationName : prototype.operationName()); + } + void close(); /** diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/FrozenTags.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/FrozenTags.java new file mode 100644 index 00000000000..f9b865a65be --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/FrozenTags.java @@ -0,0 +1,274 @@ +package datadog.trace.bootstrap.instrumentation.api; + +import datadog.apicontract.Restricted; +import datadog.trace.api.TagMap; +import java.util.ArrayList; +import java.util.List; + +/** + * The tags a {@link TagExtractor} or {@link TagContributor} writes, recorded once and frozen, so a + * span can take them with {@link AgentSpan#setTagsFrom(FrozenTags)} instead of rerunning the + * extraction: the cached, cheaper form of {@code setTagsFrom}. + * + *

    Use it when the source outlives the spans that take its tags and the tags are a function of it + * alone -- a remote address, a connection, a client -- and especially when reading the source + * builds new objects (an IP string, a joined list). Build it once per source with {@link + * #of(Object, TagExtractor)} and keep it wherever the source's other per-object state lives; it is + * immutable, so any thread can apply it. + * + *

    Applying it is equivalent to making the recorded writes again, in order, interception + * included: it {@linkplain #replay replays} them, or a tracer core copies their net result in at + * once where it can show that's the same -- no write needs interception and none removes a tag (a + * null value) or sets an empty one. + * + *

    Tag writes only: an extraction that reads the span ({@code getTag}) or sets span-level state + * ({@code setResourceName}, {@code setError}, {@code setMeasured}) can't be frozen and throws. + */ +public final class FrozenTags { + /** No tags. */ + public static final FrozenTags EMPTY = new FrozenTags(TagMap.EMPTY, new Write[0], false); + + /** Records the tags {@code extractor} writes for {@code source}. */ + public static FrozenTags of(final T source, final TagExtractor extractor) { + if (source == null) { + return EMPTY; + } + Recorder recorder = new Recorder(); + extractor.extract(source, recorder); + return recorder.freeze(); + } + + /** Records the tags {@code contributor} writes. */ + public static FrozenTags of(final TagContributor contributor) { + if (contributor == null) { + return EMPTY; + } + Recorder recorder = new Recorder(); + contributor.addTo(recorder); + return recorder.freeze(); + } + + /** One recorded write: the same setter with the same value. */ + private interface Write { + void replay(SpanData span); + } + + /** The net result of the writes, frozen: what a bulk copy copies. */ + private final TagMap tags; + + /** The writes in the order they were made. */ + private final Write[] writes; + + /** Some write removed a tag or set an empty value, so only a replay is equivalent. */ + private final boolean replayOnly; + + /** + * The tracer core's plan for applying these tags, built on first use. Racy by design, like {@link + * SpanPrototype#seedPlan()}: the plan is immutable, so a reader sees null or a complete plan. + */ + private Object applyPlan; + + private FrozenTags(final TagMap tags, final Write[] writes, final boolean replayOnly) { + this.tags = tags; + this.writes = writes; + this.replayOnly = replayOnly; + } + + /** The net result of the recorded writes, frozen. */ + public TagMap tags() { + return tags; + } + + /** Whether nothing was written. */ + public boolean isEmpty() { + return writes.length == 0; + } + + /** + * Whether only {@link #replay} is equivalent to the writes: some write removed a tag or set an + * empty value, which copying {@link #tags} in wouldn't do. + */ + public boolean isReplayOnly() { + return replayOnly; + } + + /** Makes the recorded writes on {@code span}, in order. */ + public void replay(final SpanData span) { + for (Write write : writes) { + write.replay(span); + } + } + + /** The tracer core's apply plan for these tags, or null before the first span uses them. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public Object applyPlan() { + return applyPlan; + } + + /** Records the tracer core's apply plan; see {@link #applyPlan()}. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public void applyPlan(final Object plan) { + this.applyPlan = plan; + } + + /** The {@link SpanData} an extraction writes to while it is recorded. */ + private static final class Recorder implements SpanData { + private final TagMap tags = TagMap.create(); + private final List writes = new ArrayList<>(); + private boolean replayOnly; + + FrozenTags freeze() { + return writes.isEmpty() + ? EMPTY + : new FrozenTags(tags.freeze(), writes.toArray(new Write[0]), replayOnly); + } + + /** Notes a write whose value a bulk copy of the net result wouldn't reproduce. */ + private void check(final Object value) { + if (TagMap.Entry.isEmptyValue(value)) { + replayOnly = true; // null removes the tag; an empty value is stored + } + } + + @Override + public SpanData setTag(String key, Object value) { + check(value); + tags.set(key, value); + writes.add(span -> span.setTag(key, value)); + return this; + } + + @Override + public SpanData setTag(String key, String value) { + check(value); + tags.set(key, (CharSequence) value); + writes.add(span -> span.setTag(key, value)); + return this; + } + + @Override + public SpanData setTag(String key, boolean value) { + tags.set(key, value); + writes.add(span -> span.setTag(key, value)); + return this; + } + + @Override + public SpanData setTag(String key, int value) { + tags.set(key, value); + writes.add(span -> span.setTag(key, value)); + return this; + } + + @Override + public SpanData setTag(String key, long value) { + tags.set(key, value); + writes.add(span -> span.setTag(key, value)); + return this; + } + + @Override + public SpanData setTag(String key, float value) { + tags.set(key, value); + writes.add(span -> span.setTag(key, value)); + return this; + } + + @Override + public SpanData setTag(String key, double value) { + tags.set(key, value); + writes.add(span -> span.setTag(key, value)); + return this; + } + + @Override + public SpanData setTag(long tagId, Object value) { + check(value); + tags.set(tagId, value); + writes.add(span -> span.setTag(tagId, value)); + return this; + } + + @Override + public SpanData setTag(long tagId, CharSequence value) { + check(value); + tags.set(tagId, value); + writes.add(span -> span.setTag(tagId, value)); + return this; + } + + @Override + public SpanData setTag(long tagId, boolean value) { + tags.set(tagId, value); + writes.add(span -> span.setTag(tagId, value)); + return this; + } + + @Override + public SpanData setTag(long tagId, int value) { + tags.set(tagId, value); + writes.add(span -> span.setTag(tagId, value)); + return this; + } + + @Override + public SpanData setTag(long tagId, long value) { + tags.set(tagId, value); + writes.add(span -> span.setTag(tagId, value)); + return this; + } + + @Override + public SpanData setTag(long tagId, float value) { + tags.set(tagId, value); + writes.add(span -> span.setTag(tagId, value)); + return this; + } + + @Override + public SpanData setTag(long tagId, double value) { + tags.set(tagId, value); + writes.add(span -> span.setTag(tagId, value)); + return this; + } + + @Override + public SpanData setTag(TagMap.EntryReader entry) { + if (entry == null) { + return this; // no change, as on a span + } + // the reader may be a reused flyweight: keep an entry of its own + TagMap.Entry copy = entry.entry(); + tags.set(copy); + writes.add(span -> span.setTag(copy)); + return this; + } + + @Override + public Object getTag(String key) { + // the result would depend on the span the tags are later applied to + throw new UnsupportedOperationException( + "FrozenTags records writes only; an extraction that reads the span can't be frozen"); + } + + @Override + public SpanData setResourceName(CharSequence resourceName) { + throw spanLevelState("setResourceName"); + } + + @Override + public SpanData setError(boolean error) { + throw spanLevelState("setError"); + } + + @Override + public SpanData setMeasured(boolean measured) { + throw spanLevelState("setMeasured"); + } + + private static UnsupportedOperationException spanLevelState(String method) { + return new UnsupportedOperationException( + "FrozenTags records tags only; an extraction that calls " + method + " can't be frozen"); + } + } +} diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ImmutableSpan.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ImmutableSpan.java index 5fcdbb6baec..3cea65e9499 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ImmutableSpan.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ImmutableSpan.java @@ -77,6 +77,12 @@ public AgentSpan setTagsFrom(TagContributor contributor) { return this; } + /** Skips the replay: its tags would be discarded. */ + @Override + public AgentSpan setTagsFrom(FrozenTags frozen) { + return this; + } + @Override public AgentSpan setTag(String key, Number value) { return this; diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemantics.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemantics.java index 9e8a85ef0dc..7eac0369049 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemantics.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemantics.java @@ -5,6 +5,7 @@ import datadog.trace.api.DDTags; import datadog.trace.api.KnownTags; +import datadog.trace.api.TagStore; import datadog.trace.api.cache.RadixTreeCache; import java.net.URI; import java.util.Arrays; @@ -51,6 +52,12 @@ public static Object urlOrPath(final Map tags) { return url != null ? url : tags.get(KnownTags.URL_PATH_NAME); } + /** {@link #urlOrPath(Map)}, from a span's tags as stored. */ + public static Object urlOrPath(final TagStore tags) { + Object url = tags.load(KnownTags.HTTP_URL_ID); + return url != null ? url : tags.load(KnownTags.URL_PATH_ID); + } + public static String redactedUrl(final URI url) { final String full = url.toString(); // URI does not parse user-info for some hosts, including underscores and Unicode names. diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanData.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanData.java new file mode 100644 index 00000000000..ae6402f70f0 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanData.java @@ -0,0 +1,59 @@ +package datadog.trace.bootstrap.instrumentation.api; + +import datadog.trace.api.TagMap; + +/** + * What a {@link TagContributor} or {@link TagExtractor} may read and set on a span: its tags, with + * the span's full tag semantics (a null value removes the tag, and the tag interceptor runs), and + * the few span-level fields that aren't yet tags. + * + *

    The intercepted counterpart of {@link datadog.trace.api.TagStore}, which loads and stores raw. + * It uses the span's own vocabulary, {@code getTag}/{@code setTag}, so contributor code reads like + * span code. Unlike OpenTelemetry's {@code SpanData}, it is not a snapshot: writes go to the live + * span. + * + *

    {@link AgentSpan#setTagsFrom} may hand its contributor a short-lived {@code SpanData} that + * holds the span's tag lock for the whole call, so a run of {@code setTag} calls takes the lock + * once. Such an instance is valid only during that call: don't keep it. + */ +public interface SpanData { + SpanData setTag(String key, Object value); + + SpanData setTag(String key, String value); + + SpanData setTag(String key, boolean value); + + SpanData setTag(String key, int value); + + SpanData setTag(String key, long value); + + SpanData setTag(String key, float value); + + SpanData setTag(String key, double value); + + /** Sets a known tag by its {@code KnownTags.*_ID}; an id that names no known tag is ignored. */ + SpanData setTag(long tagId, Object value); + + SpanData setTag(long tagId, CharSequence value); + + SpanData setTag(long tagId, boolean value); + + SpanData setTag(long tagId, int value); + + SpanData setTag(long tagId, long value); + + SpanData setTag(long tagId, float value); + + SpanData setTag(long tagId, double value); + + /** A null entry leaves the tags unchanged. */ + SpanData setTag(TagMap.EntryReader entry); + + Object getTag(String key); + + SpanData setResourceName(CharSequence resourceName); + + SpanData setError(boolean error); + + SpanData setMeasured(boolean measured); +} diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java index 24ccd2b39d8..830ca3d33be 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java @@ -1,6 +1,9 @@ package datadog.trace.bootstrap.instrumentation.api; +import datadog.apicontract.Restricted; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; +import javax.annotation.Nullable; /** * A baked-once, frozen descriptor of a span's constant initial state — the per-decorator constants @@ -30,6 +33,23 @@ public static Builder builder() { private final CharSequence integrationName; private final TagMap tags; // frozen + /** + * The tracer core's plan for seeding these tags into a span (whether they can be copied in bulk, + * and the span kind they carry), built on first use. Racy by design: the plan is immutable, so a + * reader sees null or a complete plan, and two threads building it at once just build it twice. + */ + private Object seedPlan; + + /** + * The dense-store sizing hints for spans that use this prototype's own operation name, in the + * entry-span and child-span lanes, resolved on first use so later spans skip the table lookup. + * Racy like {@link #seedPlan}: a hint is stable for its operation name, so two threads resolving + * it at once agree. + */ + private SizingHint entryHint; + + private SizingHint childHint; + private SpanPrototype(final Builder builder) { this.instrumentationName = builder.instrumentationName; this.operationName = builder.operationName; @@ -64,6 +84,51 @@ public TagMap tags() { return tags; } + /** The tracer core's seed plan for these tags, or null before the first span uses it. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public Object seedPlan() { + return seedPlan; + } + + /** Records the tracer core's seed plan for these tags; see {@link #seedPlan()}. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public void seedPlan(Object plan) { + this.seedPlan = plan; + } + + /** + * The sizing hint for a span built from this prototype with {@code operationName}, in the + * entry-span or child-span lane; null with the dense store off. Hints are keyed by operation + * name, like {@link SizingHint#forOperation}: when the span uses the prototype's own operation + * name, the hint is resolved once per lane and cached here, so the span skips the table lookup; a + * span given a different operation name gets that name's hint from the table, so spans of unlike + * operations never share one. + */ + @Restricted(allowedIn = {"datadog.trace.core"}) + @Nullable + public SizingHint sizingHint(boolean entrySpan, @Nullable CharSequence operationName) { + CharSequence own = this.operationName; + if (own == null || !sameName(own, operationName)) { + return SizingHint.forOperation(operationName, entrySpan); + } + SizingHint hint = entrySpan ? this.entryHint : this.childHint; + if (hint == null) { + hint = SizingHint.forOperation(own, entrySpan); + if (entrySpan) { + this.entryHint = hint; + } else { + this.childHint = hint; + } + } + return hint; + } + + /** Whether {@code name} is {@code own}: the same instance, or the same characters. */ + private static boolean sameName(CharSequence own, @Nullable CharSequence name) { + // toString() is O(1) for the String and UTF8BytesString operation names we see + return own == name || (name != null && own.toString().equals(name.toString())); + } + public static final class Builder { private String instrumentationName; private CharSequence operationName; diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagContributor.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagContributor.java index 637600b4203..1bc2c8b9c62 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagContributor.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagContributor.java @@ -13,12 +13,13 @@ * List} sink — that re-megamorphizes and recreates the decorator problem. Prefer * stateless implementations. * - *

    Targets {@link AgentSpan} for now (so it can also drive span-level state — resource name, - * error, measured — that is not yet expressed as tags). As those span fields migrate into the tag - * model, this surface narrows toward a future {@code addTo(TagMap)}. See {@link TagExtractor} for - * the extrinsic counterpart (foreign objects we do not own). Apply it with {@link + *

    Targets {@link SpanData}: the span's tags with full tag semantics, plus the span-level state + * (resource name, error, measured) that is not yet expressed as tags. {@link + * AgentSpan#setTagsFrom(TagContributor)} may hand it a short-lived {@code SpanData} that holds the + * span's tag lock for the whole call; don't keep it past {@code addTo}. See {@link TagExtractor} + * for the extrinsic counterpart (foreign objects we do not own). Apply it with {@link * AgentSpan#setTagsFrom(TagContributor)}. */ public interface TagContributor { - void addTo(AgentSpan span); + void addTo(SpanData span); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagExtractor.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagExtractor.java index a62e10836b7..edc78c7e045 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagExtractor.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagExtractor.java @@ -23,9 +23,10 @@ * a non-capturing lambda constant is accepted but is the weaker, speculative shape. The consumer, * {@link AgentSpan#setTagsFrom(Object, TagExtractor)}, is small so it inlines and each call site * sees the exact extractor type. Don't hold extractors in a field or collection of the abstract - * type and dispatch from there. Targets {@link AgentSpan} so it can also drive span-level state - * (resource name, error, status) during the transition; that surface narrows as those fields - * migrate into the tag model. + * type and dispatch from there. Targets {@link SpanData}: the span's tags with full tag semantics, + * plus the span-level state (resource name, error, measured) not yet expressed as tags. {@link + * AgentSpan#setTagsFrom(Object, TagExtractor)} may hand it a short-lived {@code SpanData} that + * holds the span's tag lock for the whole call; don't keep it past {@code extract}. * *

    Apply it with {@link AgentSpan#setTagsFrom(Object, TagExtractor)}. * @@ -34,5 +35,5 @@ @Strategy @FunctionalInterface public interface TagExtractor { - void extract(T source, AgentSpan span); + void extract(T source, SpanData span); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/DatadogClientUrl.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/DatadogClientUrl.java index be452043f60..024560406c0 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/DatadogClientUrl.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/DatadogClientUrl.java @@ -3,7 +3,7 @@ import datadog.trace.api.Config; import datadog.trace.api.DDTags; import datadog.trace.api.KnownTags; -import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.SpanData; import datadog.trace.bootstrap.instrumentation.api.TagExtractor; import datadog.trace.bootstrap.instrumentation.api.URIUtils; import java.net.URI; @@ -15,7 +15,7 @@ public final class DatadogClientUrl implements TagExtractor { private DatadogClientUrl() {} @Override - public void extract(URI uri, AgentSpan span) { + public void extract(URI uri, SpanData span) { span.setTag( KnownTags.HTTP_URL_ID, URIUtils.lazyValidURL(uri.getScheme(), uri.getHost(), uri.getPort(), uri.getPath())); diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/DatadogServerUrl.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/DatadogServerUrl.java index b348db5658a..e30885d70cb 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/DatadogServerUrl.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/DatadogServerUrl.java @@ -3,7 +3,7 @@ import datadog.trace.api.Config; import datadog.trace.api.DDTags; import datadog.trace.api.KnownTags; -import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.SpanData; import datadog.trace.bootstrap.instrumentation.api.TagExtractor; /** Extracts the Datadog URL tags from a captured HTTP server URL. */ @@ -18,7 +18,7 @@ private DatadogServerUrl(boolean urlOnly) { } @Override - public void extract(HttpServerUrl url, AgentSpan span) { + public void extract(HttpServerUrl url, SpanData span) { CharSequence legacyUrl = url.datadogUrl(); if (legacyUrl != null) { span.setTag(KnownTags.HTTP_URL_ID, legacyUrl); diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/OtelClientUrl.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/OtelClientUrl.java index 1ed416a497a..fb650c8853a 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/OtelClientUrl.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/OtelClientUrl.java @@ -2,8 +2,8 @@ import datadog.trace.api.Config; import datadog.trace.api.KnownTags; -import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.OtelHttpSemantics; +import datadog.trace.bootstrap.instrumentation.api.SpanData; import datadog.trace.bootstrap.instrumentation.api.TagExtractor; import datadog.trace.bootstrap.instrumentation.api.URIUtils; import java.net.URI; @@ -15,7 +15,7 @@ public final class OtelClientUrl implements TagExtractor { private OtelClientUrl() {} @Override - public void extract(URI uri, AgentSpan span) { + public void extract(URI uri, SpanData span) { // The Object overload avoids calling length(), which would render the lazy URL immediately. span.setTag(KnownTags.HTTP_URL_ID, (Object) new ClientUrl(uri)); if (Config.get().isHttpClientTagQueryString()) { diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/OtelServerUrl.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/OtelServerUrl.java index 327ed7c5f5d..855bbf1eb76 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/OtelServerUrl.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/http/OtelServerUrl.java @@ -2,7 +2,7 @@ import datadog.trace.api.Config; import datadog.trace.api.KnownTags; -import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.SpanData; import datadog.trace.bootstrap.instrumentation.api.TagExtractor; /** Extracts the OpenTelemetry URL tags from a captured HTTP server URL. */ @@ -12,7 +12,7 @@ public final class OtelServerUrl implements TagExtractor { private OtelServerUrl() {} @Override - public void extract(HttpServerUrl url, AgentSpan span) { + public void extract(HttpServerUrl url, SpanData span) { HttpServerUrl.Origin origin = url.origin(); if (url.isValid() && origin.scheme != null) { span.setTag(KnownTags.URL_SCHEME_ID, origin.scheme); diff --git a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java index 285d725f0b5..7da805f14fd 100644 --- a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -2,13 +2,13 @@ import datadog.trace.api.function.Strategy; import datadog.trace.api.function.StrategyConsumer; -import java.lang.reflect.Array; import java.util.Arrays; import java.util.Iterator; import java.util.NoSuchElementException; import java.util.Objects; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.function.IntFunction; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -166,12 +166,19 @@ public static long hash(@Nullable Object key) { private final boolean growable; private final float loadFactor; + private final IntFunction newArray; // mints a grown table of the precise type private TEntry[] table; private int size; private int limit; // grow trigger when growable; hard cap when fixed - private D1(TEntry[] table, float loadFactor, boolean growable, int limit) { + private D1( + TEntry[] table, + float loadFactor, + boolean growable, + int limit, + IntFunction newArray) { this.growable = growable; + this.newArray = newArray; this.loadFactor = loadFactor; this.table = table; this.size = 0; @@ -184,14 +191,15 @@ private D1(TEntry[] table, float loadFactor, boolean growable, int limit) { */ @Nonnull public static > D1 createFixed( - @Nonnull Class type, int maxCapacity) { - return createFixed(type, maxCapacity, DEFAULT_LOAD_FACTOR); + int maxCapacity, @Nonnull IntFunction newArray) { + return createFixed(maxCapacity, DEFAULT_LOAD_FACTOR, newArray); } @Nonnull public static > D1 createFixed( - @Nonnull Class type, int maxCapacity, float loadFactor) { - return new D1<>(create(type, maxCapacity, loadFactor), loadFactor, false, maxCapacity); + int maxCapacity, float loadFactor, @Nonnull IntFunction newArray) { + return new D1<>( + create(maxCapacity, loadFactor, newArray), loadFactor, false, maxCapacity, newArray); } /** @@ -200,15 +208,15 @@ public static > D1 createFixed( */ @Nonnull public static > D1 createGrowable( - @Nonnull Class type, int initialCapacity) { - return createGrowable(type, initialCapacity, DEFAULT_LOAD_FACTOR); + int initialCapacity, @Nonnull IntFunction newArray) { + return createGrowable(initialCapacity, DEFAULT_LOAD_FACTOR, newArray); } @Nonnull public static > D1 createGrowable( - @Nonnull Class type, int initialCapacity, float loadFactor) { - E[] table = create(type, initialCapacity, loadFactor); - return new D1<>(table, loadFactor, true, (int) (table.length * loadFactor)); + int initialCapacity, float loadFactor, @Nonnull IntFunction newArray) { + E[] table = create(initialCapacity, loadFactor, newArray); + return new D1<>(table, loadFactor, true, (int) (table.length * loadFactor), newArray); } public int size() { @@ -302,7 +310,7 @@ public boolean insert(@Nonnull TEntry entry) { } private void grow() { - table = resize(table); + table = resize(table, newArray); limit = (int) (table.length * loadFactor); } @@ -378,12 +386,19 @@ public static long hash(@Nullable Object key1, @Nullable Object key2) { private final boolean growable; private final float loadFactor; + private final IntFunction newArray; // mints a grown table of the precise type private TEntry[] table; private int size; private int limit; // grow trigger when growable; hard cap when fixed - private D2(TEntry[] table, float loadFactor, boolean growable, int limit) { + private D2( + TEntry[] table, + float loadFactor, + boolean growable, + int limit, + IntFunction newArray) { this.growable = growable; + this.newArray = newArray; this.loadFactor = loadFactor; this.table = table; this.size = 0; @@ -396,14 +411,15 @@ private D2(TEntry[] table, float loadFactor, boolean growable, int limit) { */ @Nonnull public static > D2 createFixed( - @Nonnull Class type, int maxCapacity) { - return createFixed(type, maxCapacity, DEFAULT_LOAD_FACTOR); + int maxCapacity, @Nonnull IntFunction newArray) { + return createFixed(maxCapacity, DEFAULT_LOAD_FACTOR, newArray); } @Nonnull public static > D2 createFixed( - @Nonnull Class type, int maxCapacity, float loadFactor) { - return new D2<>(create(type, maxCapacity, loadFactor), loadFactor, false, maxCapacity); + int maxCapacity, float loadFactor, @Nonnull IntFunction newArray) { + return new D2<>( + create(maxCapacity, loadFactor, newArray), loadFactor, false, maxCapacity, newArray); } /** @@ -412,15 +428,15 @@ public static > D2 createFixed( */ @Nonnull public static > D2 createGrowable( - @Nonnull Class type, int initialCapacity) { - return createGrowable(type, initialCapacity, DEFAULT_LOAD_FACTOR); + int initialCapacity, @Nonnull IntFunction newArray) { + return createGrowable(initialCapacity, DEFAULT_LOAD_FACTOR, newArray); } @Nonnull public static > D2 createGrowable( - @Nonnull Class type, int initialCapacity, float loadFactor) { - E[] table = create(type, initialCapacity, loadFactor); - return new D2<>(table, loadFactor, true, (int) (table.length * loadFactor)); + int initialCapacity, float loadFactor, @Nonnull IntFunction newArray) { + E[] table = create(initialCapacity, loadFactor, newArray); + return new D2<>(table, loadFactor, true, (int) (table.length * loadFactor), newArray); } public int size() { @@ -520,7 +536,7 @@ public boolean insert(@Nonnull TEntry entry) { } private void grow() { - table = resize(table); + table = resize(table, newArray); limit = (int) (table.length * loadFactor); } @@ -700,27 +716,28 @@ public static int capacityFor(int cardinalityLimit, float loadFactor) { } /** - * Allocates a correctly-typed table for a cardinality budget ({@link #capacityFor} slots). - * Passing {@code type} makes the array's runtime component type {@code E} rather than {@code - * Object[]} — typed reads, real array-store checks, and a monomorphic element type for the JIT. - * Callers can't {@code new E[]} themselves under erasure; this does the one reflective allocation - * at construction (off any hot path). Note: this {@code create} mints the backing array; {@link - * CreateStrategy#create} mints an entry — different types, no ambiguity at the call site. + * Allocates a table for a cardinality budget ({@link #capacityFor} slots). {@code newArray} is an + * array constructor of the entry type, e.g. {@code MyEntry[]::new}, so the table's runtime + * component type is {@code E} rather than {@code Object[]}: typed reads, real array-store checks, + * and a precise element type for the JIT. Unlike {@code Array.newInstance}, an array constructor + * isn't reflective, so the table works unchanged under GraalVM native-image with no reflection + * registration. Note: this {@code create} mints the backing array; {@link CreateStrategy#create} + * mints an entry -- different types, no ambiguity at the call site. */ - @SuppressWarnings("unchecked") @Nonnull - public static E[] create(@Nonnull Class type, int cardinalityLimit) { - return (E[]) Array.newInstance(type, capacityFor(cardinalityLimit)); + public static E[] create(int cardinalityLimit, @Nonnull IntFunction newArray) { + return newArray.apply(capacityFor(cardinalityLimit)); } /** - * {@link #create(Class, int)} at an explicit {@code loadFactor} (see {@link #capacityFor(int, - * float)} and the {@link #DEFAULT_LOAD_FACTOR} / {@link #LOW_LOAD_FACTOR} constants). + * {@link #create(int, IntFunction)} at an explicit {@code loadFactor} (see {@link + * #capacityFor(int, float)} and the {@link #DEFAULT_LOAD_FACTOR} / {@link #LOW_LOAD_FACTOR} + * constants). */ - @SuppressWarnings("unchecked") @Nonnull - public static E[] create(@Nonnull Class type, int cardinalityLimit, float loadFactor) { - return (E[]) Array.newInstance(type, capacityFor(cardinalityLimit, loadFactor)); + public static E[] create( + int cardinalityLimit, float loadFactor, @Nonnull IntFunction newArray) { + return newArray.apply(capacityFor(cardinalityLimit, loadFactor)); } /** @@ -843,12 +860,13 @@ static int home(long hash, int mask) { * Doubles capacity and rehashes every entry into a new table — call when {@link #insert} returns * {@code false} and you want to grow rather than reject; the caller stores the returned array * back. Convenience over the {@link HashStrategy}-taking overload for {@link Entry}-based entries - * (the home comes from {@link Entry#hash}). See {@link #resizingInsert(Entry[], Entry)} to do - * both in one call, and its note on growing over unbounded key domains. + * (the home comes from {@link Entry#hash}). See {@link #resizingInsert(Entry[], Entry, + * IntFunction)} to do both in one call, and its note on growing over unbounded key domains. */ @Nonnull - public static E[] resize(@Nonnull E[] table) { - E[] grown = allocateGrown(table); + public static E[] resize( + @Nonnull E[] table, @Nonnull IntFunction newArray) { + E[] grown = newArray.apply(grownLength(table.length)); for (final E e : table) { if (e != null) { placeAt(grown, e, e.hash); @@ -858,13 +876,14 @@ public static E[] resize(@Nonnull E[] table) { } /** - * {@link #resize(Entry[])} for any entry type: each entry's home comes from {@link + * {@link #resize(Entry[], IntFunction)} for any entry type: each entry's home comes from {@link * HashStrategy#hashOf}. Not a {@link StrategyConsumer} — the rehash is a cold, one-off traversal, * not a hot specialization site. */ @Nonnull - public static E[] resize(@Nonnull E[] table, @Nonnull HashStrategy hashStrat) { - E[] grown = allocateGrown(table); + public static E[] resize( + @Nonnull E[] table, @Nonnull HashStrategy hashStrat, @Nonnull IntFunction newArray) { + E[] grown = newArray.apply(grownLength(table.length)); for (final E e : table) { if (e != null) { placeAt(grown, e, hashStrat.hashOf(e)); @@ -873,20 +892,12 @@ public static E[] resize(@Nonnull E[] table, @Nonnull HashStrategy hashSt return grown; } - /** - * A new, empty table of twice the capacity, of the same runtime component type as {@code table}. - */ - @SuppressWarnings("unchecked") - private static E[] allocateGrown(E[] table) { - return (E[]) Array.newInstance(table.getClass().getComponentType(), grownLength(table.length)); - } - /** * {@code currentLength << 1}, guarded against overflow. {@code currentLength << 1} overflows to a * negative value at {@code currentLength == 1 << 30}; without this check the failure surfaces as - * an opaque {@code NegativeArraySizeException} from {@code Array.newInstance} in {@link - * #allocateGrown}. Same overflow class as {@link #capacityFor}'s guard -- this is the growth-time - * counterpart. + * an opaque {@code NegativeArraySizeException} from the array constructor in {@link + * #resize(Entry[], IntFunction)}. Same overflow class as {@link #capacityFor}'s guard -- this is + * the growth-time counterpart. */ static int grownLength(int currentLength) { int grownLength = currentLength << 1; @@ -899,11 +910,11 @@ static int grownLength(int currentLength) { /** * {@link #insert(Entry[], Entry) insert} that grows on demand: adds {@code entry}, {@link - * #resize(Entry[]) resizing} first if the table is full, and returns the table to store back — - * the same array if it fit, a new larger one if it grew: + * #resize(Entry[], IntFunction) resizing} first if the table is full, and returns the table to + * store back — the same array if it fit, a new larger one if it grew: * *

    {@code
    -   * table = FlatHashtable.resizingInsert(table, entry); // always reassign
    +   * table = FlatHashtable.resizingInsert(table, entry, MyEntry[]::new); // always reassign
        * }
    * * Same comparison-free, caller-ensures-absence contract as {@link #insert}. @@ -924,25 +935,29 @@ static int grownLength(int currentLength) { * size (as {@link D1} / {@link D2} do) can grow at a load factor of its own choosing instead. */ @Nonnull - public static E[] resizingInsert(@Nonnull E[] table, @Nonnull E entry) { + public static E[] resizingInsert( + @Nonnull E[] table, @Nonnull E entry, @Nonnull IntFunction newArray) { E[] t = table; while (!insert(t, entry)) { - t = resize(t); // one doubling always suffices; the loop is belt-and-braces + t = resize(t, newArray); // one doubling always suffices; the loop is belt-and-braces } return t; } /** - * {@link #resizingInsert(Entry[], Entry)} for any entry type (home via {@link + * {@link #resizingInsert(Entry[], Entry, IntFunction)} for any entry type (home via {@link * HashStrategy#hashOf}). Same grows-unboundedly caution and same probe-to-full growth trigger. */ @StrategyConsumer @Nonnull public static E[] resizingInsert( - @Nonnull E[] table, @Nonnull E entry, @Nonnull HashStrategy hashStrat) { + @Nonnull E[] table, + @Nonnull E entry, + @Nonnull HashStrategy hashStrat, + @Nonnull IntFunction newArray) { E[] t = table; while (!insert(t, entry, hashStrat)) { - t = resize(t, hashStrat); + t = resize(t, hashStrat, newArray); } return t; } diff --git a/internal-api/src/test/java/datadog/trace/api/DenseIdStoreConcurrentReadTest.java b/internal-api/src/test/java/datadog/trace/api/DenseIdStoreConcurrentReadTest.java new file mode 100644 index 00000000000..27848409198 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/DenseIdStoreConcurrentReadTest.java @@ -0,0 +1,73 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * Point reads of a frozen dense store that several threads share -- a span prototype's tags, the + * tracer-level tags -- must not share state: each read builds its own reader or Entry. + */ +class DenseIdStoreConcurrentReadTest { + + @Test + void concurrentPointReadsOfASharedStoreKeepTheirOwnValues() throws Exception { + List ids = new ArrayList<>(); + for (java.lang.reflect.Field field : KnownTags.class.getFields()) { + if (field.getType() == long.class && ids.size() < 12) { + long id = field.getLong(null); + if (KnownTagCodec.isKnown(id) && !ids.contains(id)) { + ids.add(id); + } + } + } + DenseIdStore store = new DenseIdStore(null); + for (long id : ids) { + store.store(id, KnownTagCodec.nameOf(id)); // each value names its own tag + } + TagMap shared = TagMap.viewOf(store, null, false); + + int threads = 8; + CountDownLatch start = new CountDownLatch(1); + AtomicReference mismatch = new AtomicReference<>(); + List readers = new ArrayList<>(); + for (int t = 0; t < threads; ++t) { + final int offset = t; + Thread reader = + new Thread( + () -> { + try { + start.await(); + } catch (InterruptedException e) { + return; + } + for (int i = 0; i < 20_000 && mismatch.get() == null; ++i) { + long id = ids.get((i + offset) % ids.size()); + String name = KnownTagCodec.nameOf(id); + TagMap.Entry entry = shared.getEntry(id); + TagMap.EntryReader viaReader = store.getEntryReader(id); + if (!name.equals(entry.tag()) || !name.equals(entry.objectValue())) { + mismatch.set(name + " read as " + entry.tag() + "=" + entry.objectValue()); + } else if (!name.equals(viaReader.tag()) + || !name.equals(viaReader.objectValue())) { + mismatch.set( + name + " read as " + viaReader.tag() + "=" + viaReader.objectValue()); + } + } + }); + readers.add(reader); + reader.start(); + } + start.countDown(); + for (Thread reader : readers) { + reader.join(30_000); + assertTrue(!reader.isAlive(), "reader finished"); + } + assertEquals(null, mismatch.get()); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/DenseIdStoreForkedTest.java b/internal-api/src/test/java/datadog/trace/api/DenseIdStoreForkedTest.java new file mode 100644 index 00000000000..a2f0dd37a3f --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/DenseIdStoreForkedTest.java @@ -0,0 +1,50 @@ +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.assertSame; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * The dense-store flag on: only {@link DenseIdStore} is loaded, so calls on an {@link IdStore} stay + * monomorphic. The flag is read once into a static final, so this runs in its own JVM ({@code + * ForkedTest}) with the flag set before anything reads it. The shared suites cover the dense + * store's behavior when run with {@code DD_TRACE_EXPERIMENTAL_DENSE_TAGS_ENABLED=true}. + */ +class DenseIdStoreForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + } + + @Test + void onlyTheDenseStoreIsLoaded() { + TagMap map = TagMap.create(); + map.set(KnownTags.COMPONENT_ID, "okhttp"); + map.set("custom.tag", "custom"); + + assertSame(DenseIdStore.class, map.knownStore.getClass()); + assertSame(DenseIdStore.class, TagMap.EMPTY.knownStore.getClass()); + assertEquals("okhttp", map.getObject(KnownTags.COMPONENT_NAME)); + assertFalse(isLoaded("datadog.trace.api.HashIdStore")); + } + + /** Whether {@code className} has been loaded by TagMap's class loader, without loading it. */ + private static boolean isLoaded(String className) { + Method findLoadedClass; + try { + findLoadedClass = ClassLoader.class.getDeclaredMethod("findLoadedClass", String.class); + findLoadedClass.setAccessible(true); + } catch (RuntimeException | NoSuchMethodException e) { + assumeTrue(false, "cannot inspect loaded classes on this JVM: " + e); + return false; + } + try { + return findLoadedClass.invoke(TagMap.class.getClassLoader(), className) != null; + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/DenseReaderTest.java b/internal-api/src/test/java/datadog/trace/api/DenseReaderTest.java new file mode 100644 index 00000000000..ae9b0f4b31d --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/DenseReaderTest.java @@ -0,0 +1,107 @@ +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 datadog.trace.api.DenseIdStore.DenseReader; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DenseReaderTest { + + @Test + void exposesAnIntValue() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + + assertEquals(200, reader.intValue()); + assertEquals(200L, reader.longValue()); + assertEquals(TagMap.EntryReader.INT, reader.type()); + assertTrue(reader.isNumber()); + assertTrue(reader.isNumericPrimitive()); + assertFalse(reader.isObject()); + assertTrue(reader.is(TagMap.EntryReader.INT)); + } + + @Test + void exposesALongValue() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_STATUS_CODE_ID, 123456789L); + + assertEquals(123456789L, reader.longValue()); + assertEquals(TagMap.EntryReader.LONG, reader.type()); + assertTrue(reader.isNumericPrimitive()); + } + + @Test + void exposesABooleanValue() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_STATUS_CODE_ID, true); + + assertTrue(reader.booleanValue()); + assertEquals(TagMap.EntryReader.BOOLEAN, reader.type()); + assertFalse(reader.isNumber()); + assertFalse(reader.isObject()); + } + + @Test + void exposesFloatAndDoubleValues() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_STATUS_CODE_ID, 1.5f); + assertEquals(1.5f, reader.floatValue()); + assertEquals(TagMap.EntryReader.FLOAT, reader.type()); + + reader.set(KnownTags.HTTP_STATUS_CODE_ID, 3.14); + assertEquals(3.14, reader.doubleValue()); + assertEquals(TagMap.EntryReader.DOUBLE, reader.type()); + } + + @Test + void exposesAStringAsAnObject() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_METHOD_ID, "GET"); + + assertEquals("GET", reader.stringValue()); + assertEquals("GET", reader.objectValue()); + assertEquals(TagMap.EntryReader.OBJECT, reader.type()); + assertTrue(reader.isObject()); + assertFalse(reader.isNumber()); + } + + @Test + void namesTheTagFromItsId() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_METHOD_ID, "GET"); + + assertEquals(KnownTags.HTTP_METHOD_ID, reader.tagId()); + assertEquals(KnownTags.HTTP_METHOD_NAME, reader.tag()); + assertEquals(KnownTags.HTTP_METHOD_NAME, reader.tag()); // the resolved name is reused + } + + @Test + void aNewTagReplacesTheResolvedName() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_METHOD_ID, "GET"); + assertEquals(KnownTags.HTTP_METHOD_NAME, reader.tag()); + + reader.set(KnownTags.HTTP_ROUTE_ID, "/users"); + assertEquals(KnownTags.HTTP_ROUTE_ID, reader.tagId()); + assertEquals(KnownTags.HTTP_ROUTE_NAME, reader.tag()); + } + + @Test + void buildsAnEntryForTheTag() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_METHOD_ID, "GET"); + + TagMap.Entry entry = reader.entry(); + assertEquals(KnownTags.HTTP_METHOD_ID, entry.tagId()); + assertEquals(KnownTags.HTTP_METHOD_NAME, entry.tag()); + assertEquals("GET", entry.objectValue()); + + Map.Entry mapEntry = reader.mapEntry(); + assertEquals(KnownTags.HTTP_METHOD_NAME, mapEntry.getKey()); + assertEquals("GET", mapEntry.getValue()); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java b/internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java deleted file mode 100644 index 5da8564cb87..00000000000 --- a/internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java +++ /dev/null @@ -1,169 +0,0 @@ -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.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.AbstractMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class EntryReadingHelperTest { - - @Test - void setTagValueExposesIntValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("my.tag", 42); - - assertEquals("my.tag", helper.tag()); - assertEquals(42, helper.intValue()); - assertEquals(42L, helper.longValue()); - assertEquals(TagMap.EntryReader.INT, helper.type()); - assertTrue(helper.isNumber()); - assertTrue(helper.isNumericPrimitive()); - assertFalse(helper.isObject()); - assertTrue(helper.is(TagMap.EntryReader.INT)); - } - - @Test - void setTagValueExposesLongValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("id", 123456789L); - - assertEquals(123456789L, helper.longValue()); - assertEquals(TagMap.EntryReader.LONG, helper.type()); - assertTrue(helper.isNumber()); - assertTrue(helper.isNumericPrimitive()); - } - - @Test - void setTagValueExposesBooleanValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("flag", true); - - assertTrue(helper.booleanValue()); - assertEquals(TagMap.EntryReader.BOOLEAN, helper.type()); - assertFalse(helper.isNumber()); - assertFalse(helper.isObject()); - } - - @Test - void setTagValueExposesFloatValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("rate", 1.5f); - - assertEquals(1.5f, helper.floatValue()); - assertEquals(TagMap.EntryReader.FLOAT, helper.type()); - assertTrue(helper.isNumericPrimitive()); - } - - @Test - void setTagValueExposesDoubleValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("pi", 3.14); - - assertEquals(3.14, helper.doubleValue()); - assertEquals(TagMap.EntryReader.DOUBLE, helper.type()); - assertTrue(helper.isNumericPrimitive()); - } - - @Test - void setTagValueExposesStringAsObject() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("name", "foo"); - - assertEquals("foo", helper.stringValue()); - assertEquals("foo", helper.objectValue()); - assertEquals(TagMap.EntryReader.OBJECT, helper.type()); - assertTrue(helper.isObject()); - assertFalse(helper.isNumber()); - assertFalse(helper.isNumericPrimitive()); - } - - @Test - void setTagValueCreatesEntry() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("tag", "value"); - - TagMap.Entry entry = helper.entry(); - assertNotNull(entry); - assertEquals("tag", entry.tag()); - assertEquals("value", entry.objectValue()); - } - - @Test - void mapEntryFallsBackToNewEntryWhenSetWithTagValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("tag", "value"); - - Map.Entry mapEntry = helper.mapEntry(); - assertEquals("tag", mapEntry.getKey()); - assertEquals("value", mapEntry.getValue()); - } - - @Test - void setMapEntryExposesReadMethods() { - EntryReadingHelper helper = new EntryReadingHelper(); - Map.Entry original = new AbstractMap.SimpleEntry<>("key", "hello"); - helper.set(original); - - assertEquals("key", helper.tag()); - assertEquals("hello", helper.stringValue()); - assertEquals("hello", helper.objectValue()); - assertEquals(TagMap.EntryReader.OBJECT, helper.type()); - assertTrue(helper.isObject()); - assertFalse(helper.isNumber()); - } - - @Test - void mapEntryReturnsSameInstanceWhenSetWithMapEntry() { - EntryReadingHelper helper = new EntryReadingHelper(); - Map.Entry original = new AbstractMap.SimpleEntry<>("key", "value"); - helper.set(original); - - assertSame(original, helper.mapEntry()); - } - - @Test - void setMapEntryCreatesEntry() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set(new AbstractMap.SimpleEntry<>("k", 99)); - - TagMap.Entry entry = helper.entry(); - assertNotNull(entry); - assertEquals("k", entry.tag()); - assertEquals(99, entry.objectValue()); - } - - @Test - void tagIdResolvesLazilyAndCaches() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("http.method", "GET"); - - long expectedId = KnownTagCodec.keyOf("http.method"); - assertEquals(expectedId, helper.tagId()); - // second call hits the already-resolved cache rather than re-resolving - assertEquals(expectedId, helper.tagId()); - } - - @Test - void tagIdOfUnknownTagResolvesToZeroAndCaches() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("my.custom.tag", "value"); - - assertEquals(0L, helper.tagId()); - assertEquals(0L, helper.tagId()); - } - - @Test - void tagIdIsRecomputedAfterSet() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("http.method", "GET"); - assertEquals(KnownTagCodec.keyOf("http.method"), helper.tagId()); - - helper.set(new AbstractMap.SimpleEntry<>("db.type", "mysql")); - assertEquals(KnownTagCodec.keyOf("db.type"), helper.tagId()); - } -} diff --git a/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java b/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java new file mode 100644 index 00000000000..8e2d6d5f6b5 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java @@ -0,0 +1,287 @@ +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.assertTrue; + +import datadog.trace.api.TagMap.Entry; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** A custom-tag store reads through to its parent, and a local removal hides the inherited tag. */ +class HashStoreTest { + private static final String A = "custom.a"; + private static final String B = "custom.b"; + private static final String C = "custom.c"; + + @Test + void storedTagsAreFound() { + HashStore store = HashStore.create(null); + + store.store(A, "1"); + store.store(B, 2); + + assertEquals("1", store.getEntry(A).objectValue()); + assertEquals(2, store.getEntry(B).objectValue()); + assertEquals(2, store.size()); + store.checkIntegrity(); + } + + @Test + void readsThroughToItsParent() { + HashStore parent = parentWith(A, "parent"); + HashStore child = HashStore.create(parent); + + assertEquals("parent", child.getEntry(A).objectValue()); + assertEquals(1, child.size()); + assertFalse(child.isEmpty()); + } + + @Test + void aLocalTagShadowsTheParents() { + HashStore child = HashStore.create(parentWith(A, "parent")); + + child.store(A, "child"); + + assertEquals("child", child.getEntry(A).objectValue()); + assertEquals(1, child.size()); + assertEquals(tags(A, "child"), visible(child)); + } + + @Test + void removingAnInheritedTagHidesIt() { + HashStore parent = parentWith(A, "parent"); + HashStore child = HashStore.create(parent); + + Entry removed = child.remove(A); + + assertSame(parent.getEntry(A), removed); + assertNull(child.getEntry(A)); + assertEquals(0, child.size()); + assertTrue(child.isEmpty()); + assertEquals(tags(), visible(child)); + assertEquals("parent", parent.getEntry(A).objectValue()); + } + + @Test + void getAndPutAfterRemovingAnInheritedTagReportsNoPriorValue() { + HashStore child = HashStore.create(parentWith(A, "parent")); + child.remove(A); + + Entry prior = child.getAndPut(Entry.create(A, "child")); + + assertNull(prior); + assertEquals("child", child.getEntry(A).objectValue()); + assertEquals(1, child.size()); + } + + @Test + void getAndPutReportsTheInheritedPriorValue() { + HashStore parent = parentWith(A, "parent"); + HashStore child = HashStore.create(parent); + + Entry prior = child.getAndPut(Entry.create(A, "child")); + + assertSame(parent.getEntry(A), prior); + assertEquals("child", child.getEntry(A).objectValue()); + } + + @Test + void forEachSkipsShadowedAndRemovedParentTags() { + HashStore parent = HashStore.create(null); + parent.store(A, "parent-a"); + parent.store(B, "parent-b"); + HashStore child = HashStore.create(parent); + child.store(B, "child-b"); + child.store(C, "child-c"); + child.remove(A); + + assertEquals(tags(B, "child-b", C, "child-c"), visible(child)); + assertEquals(2, child.size()); + } + + @Test + void putAllLocalMergesAnotherStoresLocalTags() { + HashStore target = HashStore.create(null); + target.store(A, "old"); + HashStore source = HashStore.create(null); + source.store(A, "new"); + source.store(B, "b"); + + target.putAllLocal(source); + + assertEquals(tags(A, "new", B, "b"), visible(target)); + assertEquals(2, target.size()); + target.checkIntegrity(); + } + + @Test + void typedStoresRoundTrip() { + HashStore store = HashStore.create(null); + + store.store(A, true); + store.store(B, 7L); + store.store(C, 1.5f); + store.store("custom.d", 2.5d); + store.store("custom.e", (CharSequence) "chars"); + + assertEquals(true, store.getEntry(A).objectValue()); + assertEquals(7L, store.getEntry(B).objectValue()); + assertEquals(1.5f, store.getEntry(C).objectValue()); + assertEquals(2.5d, store.getEntry("custom.d").objectValue()); + assertEquals("chars", store.getEntry("custom.e").objectValue().toString()); + store.checkIntegrity(); + } + + @Test + void storingNullRemoves() { + HashStore store = HashStore.create(null); + store.store(A, "1"); + store.store(B, (CharSequence) "2"); + + store.store(A, (Object) null); + store.store(B, (CharSequence) null); + + assertNull(store.getEntry(A)); + assertNull(store.getEntry(B)); + assertTrue(store.isEmpty()); + } + + @Test + void removingALocalTagWithNoParent() { + HashStore store = HashStore.create(null); + store.store(A, "1"); + + assertEquals("1", store.remove(A).objectValue()); + assertNull(store.remove(A)); + assertEquals(0, store.size()); + } + + @Test + void removingAnInheritedTagTwiceReportsItOnce() { + HashStore child = HashStore.create(parentWith(A, "parent")); + + assertEquals("parent", child.remove(A).objectValue()); + assertNull(child.remove(A)); + } + + @Test + void getAndPutReportsTheLocalPriorValue() { + HashStore store = HashStore.create(null); + store.store(A, "old"); + + assertEquals("old", store.getAndPut(Entry.create(A, "new")).objectValue()); + assertNull(store.getAndPut(Entry.create(B, "b"))); + } + + @Test + void sizesAcrossLevels() { + HashStore parent = parentWith(A, "parent"); + HashStore child = HashStore.create(parent); + assertFalse(child.isDefinitelyEmpty()); + assertEquals(1, child.estimateSize()); + + HashStore empty = HashStore.create(HashStore.create(null)); + assertTrue(empty.isDefinitelyEmpty()); + assertTrue(empty.isEmpty()); + assertEquals(0, empty.estimateSize()); + } + + /** Enough parent tags to form bucket groups, so read-through walks group chains too. */ + @Test + void readThroughWalksBucketGroups() { + HashStore parent = HashStore.create(null); + Map expected = new HashMap<>(); + for (int i = 0; i < 64; i++) { + parent.store("custom.tag" + i, i); + expected.put("custom.tag" + i, i); + } + HashStore child = HashStore.create(parent); + child.remove("custom.tag0"); + expected.remove("custom.tag0"); + child.store("custom.tag1", "local"); + expected.put("custom.tag1", "local"); + + assertEquals(expected.size(), child.size()); + assertEquals(expected, visible(child)); + + Map viaConsumer = new HashMap<>(); + child.forEach(entry -> viaConsumer.put(entry.tag(), entry.objectValue())); + assertEquals(expected, viaConsumer); + + Map viaTwoContexts = new HashMap<>(); + child.forEach( + viaTwoContexts, "-", (map, unused, entry) -> map.put(entry.tag(), entry.objectValue())); + assertEquals(expected, viaTwoContexts); + + for (int i = 1; i < 64; i++) { + child.remove("custom.tag" + i); + } + assertTrue(child.isEmpty()); + } + + @Test + void aCopyIsIndependent() { + HashStore child = HashStore.create(parentWith(A, "parent")); + child.store(B, "b"); + child.remove(A); + + HashStore copy = child.copy(); + copy.store(A, "copy"); + copy.store(B, "copy-b"); + + assertEquals(tags(B, "b"), visible(child)); + assertEquals(tags(A, "copy", B, "copy-b"), visible(copy)); + } + + @Test + void putAllLocalHandlesEmptyStores() { + HashStore target = HashStore.create(null); + target.putAllLocal(HashStore.create(null)); + assertEquals(0, target.size()); + + HashStore source = HashStore.create(null); + source.store(A, "a"); + target.putAllLocal(source); + assertEquals(tags(A, "a"), visible(target)); + target.checkIntegrity(); + } + + @Test + void fillsMapsWithLocalTags() { + HashStore store = HashStore.create(null); + store.store(A, 1); + + Map objects = new HashMap<>(); + store.fillMapLocal(objects); + Map strings = new HashMap<>(); + store.fillStringMapLocal(strings); + + assertEquals(tags(A, 1), objects); + assertEquals("1", strings.get(A)); + assertTrue(store.toInternalString().contains(A)); + } + + private static Map tags(Object... tagsAndValues) { + Map tags = new HashMap<>(); + for (int i = 0; i < tagsAndValues.length; i += 2) { + tags.put((String) tagsAndValues[i], tagsAndValues[i + 1]); + } + return tags; + } + + private static HashStore parentWith(String tag, Object value) { + HashStore parent = HashStore.create(null); + parent.store(tag, value); + return parent; + } + + private static Map visible(HashStore store) { + Map tags = new HashMap<>(); + store.forEach(tags, (map, entry) -> map.put(entry.tag(), entry.objectValue())); + return tags; + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java new file mode 100644 index 00000000000..af6c067abe2 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java @@ -0,0 +1,673 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Named.named; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import datadog.trace.api.TagMap.EntryReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * The {@link IdStore} contract, run against both stores in the normal test JVM: the dense store is + * behind a flag that is off by default, so the shared {@link TagMap} suites only reach the hash + * store. Each store is built from a parent of its own kind, as the factories do. + */ +class IdStoreContractTest { + + static Stream stores() { + return Stream.of( + arguments(named("HashIdStore", (Function) HashIdStore::new)), + arguments(named("DenseIdStore", (Function) DenseIdStore::new))); + } + + /** Every known tag id, in declaration order. */ + private static List knownIds() throws IllegalAccessException { + List ids = new ArrayList<>(); + for (java.lang.reflect.Field field : KnownTags.class.getFields()) { + if (field.getType() == long.class) { + long id = field.getLong(null); + if (KnownTagCodec.isKnown(id) && !ids.contains(id)) { + ids.add(id); + } + } + } + return ids; + } + + private static Map visible(IdStore store) { + Map seen = new HashMap<>(); + store.forEach(entry -> assertNull(seen.put(entry.tag(), entry.objectValue()), entry.tag())); + return seen; + } + + @ParameterizedTest + @MethodSource("stores") + void typedSettersRoundTripThroughTheReader(Function newStore) { + IdStore store = newStore.apply(null); + store.store(KnownTags.HTTP_ROUTE_ID, (Object) "/users"); + store.store(KnownTags.HTTP_METHOD_ID, (CharSequence) "GET"); + store.store(KnownTags.ERROR_ID, true); + store.store(KnownTags.HTTP_STATUS_CODE_ID, 200); + store.store(KnownTags.PEER_PORT_ID, 5432L); + store.store(KnownTags.DD_AGENT_PSR_ID, 0.5f); + store.store(KnownTags.DB_TYPE_ID, (Object) "postgres"); + store.store(KnownTags.COMPONENT_ID, (CharSequence) "jdbc"); + store.store(KnownTags.SPAN_KIND_ID, (Object) "client"); + store.store(KnownTags.DB_POOL_NAME_ID, (Object) "pool"); + store.store(KnownTags.HTTP_URL_ID, (Object) "http://x"); + store.store(KnownTags.DB_INSTANCE_ID, 2.5d); + + assertEquals("/users", store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals("GET", store.load(KnownTags.HTTP_METHOD_ID)); + + EntryReader flag = store.getEntryReader(KnownTags.ERROR_ID); + assertTrue(flag.booleanValue()); + assertEquals(EntryReader.BOOLEAN, flag.type()); + assertFalse(flag.isNumber()); + + EntryReader status = store.getEntryReader(KnownTags.HTTP_STATUS_CODE_ID); + assertEquals(200, status.intValue()); + assertEquals(200L, status.longValue()); + assertTrue(status.is(EntryReader.INT)); + assertTrue(status.isNumericPrimitive()); + assertFalse(status.isObject()); + assertEquals(KnownTags.HTTP_STATUS_CODE_ID, status.tagId()); + assertEquals("http.status_code", status.tag()); + + assertEquals(5432L, store.getEntryReader(KnownTags.PEER_PORT_ID).longValue()); + assertEquals(0.5f, store.getEntryReader(KnownTags.DD_AGENT_PSR_ID).floatValue()); + assertEquals(2.5d, store.getEntryReader(KnownTags.DB_INSTANCE_ID).doubleValue()); + + EntryReader route = store.getEntryReader(KnownTags.HTTP_ROUTE_ID); + assertEquals("/users", route.stringValue()); + assertTrue(route.isObject()); + assertEquals("/users", route.entry().objectValue()); + assertEquals("http.route", route.mapEntry().getKey()); + + assertEquals(12, store.size()); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void putAndGetAndPutReturnThePriorValue(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore store = newStore.apply(parent); + + assertNull(store.put(TagMap.Entry.newAnyEntry(KnownTags.HTTP_METHOD_ID, "GET"))); + EntryReader prior = store.put(TagMap.Entry.newAnyEntry(KnownTags.HTTP_METHOD_ID, "POST")); + assertEquals("GET", prior.objectValue()); + + // getAndPut reports the prior visible value, inherited included + EntryReader inherited = + store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/local")); + assertEquals("/inherited", inherited.objectValue()); + assertEquals("/local", store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals( + "/local", + store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/again")).objectValue()); + assertNull(store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.COMPONENT_ID, "okhttp"))); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void removingAnInheritedTagHidesItUntilItIsSetAgain(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.store(KnownTags.COMPONENT_ID, "okhttp"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); + assertTrue(store.hasParent()); + assertEquals(3, store.size()); + + assertEquals("/inherited", store.remove(KnownTags.HTTP_ROUTE_ID).objectValue()); + assertNull(store.load(KnownTags.HTTP_ROUTE_ID)); + assertNull(store.remove(KnownTags.HTTP_ROUTE_ID), "already hidden"); + assertEquals(2, store.size()); + assertEquals("/inherited", parent.load(KnownTags.HTTP_ROUTE_ID), "parent untouched"); + + // a local value that also hides an inherited one is removed with its tombstone in place + store.store(KnownTags.COMPONENT_ID, "local"); + assertEquals("local", store.remove(KnownTags.COMPONENT_ID).objectValue()); + assertNull(store.load(KnownTags.COMPONENT_ID)); + + assertEquals("GET", store.remove(KnownTags.HTTP_METHOD_ID).objectValue()); + assertNull(store.remove(KnownTags.HTTP_METHOD_ID), "absent"); + assertTrue(store.isEmpty()); + assertTrue(store.isDefinitelyEmpty() || store.estimateSize() >= 0); + + // setting a hidden tag again revives it + store.store(KnownTags.HTTP_ROUTE_ID, "/revived"); + assertEquals("/revived", store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals(1, store.size()); + assertFalse(store.isEmpty()); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void sizeAndEmptinessCountWhatIsVisible(Function newStore) { + IdStore empty = newStore.apply(null); + assertTrue(empty.isEmpty()); + assertTrue(empty.isDefinitelyEmpty()); + assertEquals(0, empty.estimateSize()); + assertFalse(empty.hasParent()); + + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/a"); + parent.store(KnownTags.HTTP_METHOD_ID, "GET"); + IdStore child = newStore.apply(parent); + assertFalse(child.isEmpty(), "inherits visible tags"); + assertFalse(child.isDefinitelyEmpty()); + + child.store(KnownTags.HTTP_METHOD_ID, "POST"); // shadows + child.store(KnownTags.COMPONENT_ID, "okhttp"); + assertEquals(3, child.size()); + assertTrue(child.estimateSize() >= 3); + + child.remove(KnownTags.HTTP_ROUTE_ID); + child.remove(KnownTags.HTTP_METHOD_ID); + child.remove(KnownTags.COMPONENT_ID); + assertTrue(child.isEmpty(), "every inherited tag hidden"); + assertEquals(0, child.size()); + } + + @ParameterizedTest + @MethodSource("stores") + void everyIterationSeesEachVisibleTagOnce(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/a"); + parent.store(KnownTags.HTTP_METHOD_ID, "GET"); + parent.store(KnownTags.COMPONENT_ID, "okhttp"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.HTTP_METHOD_ID, "POST"); // shadows the parent's + store.store(KnownTags.SPAN_KIND_ID, "client"); + store.remove(KnownTags.COMPONENT_ID); // hides the parent's + + Map expected = new HashMap<>(); + expected.put("http.route", "/a"); + expected.put("http.method", "POST"); + expected.put("span.kind", "client"); + assertEquals(expected, visible(store)); + + Map viaContext = new HashMap<>(); + store.forEach(viaContext, (map, entry) -> map.put(entry.tag(), entry.objectValue())); + assertEquals(expected, viaContext); + + Map viaTwoContexts = new HashMap<>(); + store.forEach( + viaTwoContexts, "-", (map, suffix, entry) -> map.put(entry.tag(), entry.objectValue())); + assertEquals(expected, viaTwoContexts); + + Map viaCursor = new HashMap<>(); + IdStore.Cursor cursor = store.cursor(); + for (EntryReader entry = cursor.next(); entry != null; entry = cursor.next()) { + assertNull(viaCursor.put(entry.tag(), entry.objectValue()), entry.tag()); + } + assertEquals(expected, viaCursor); + } + + @ParameterizedTest + @MethodSource("stores") + void aCopyIsIndependent(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/a"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); + store.remove(KnownTags.HTTP_ROUTE_ID); + + IdStore copy = store.copy(); + copy.store(KnownTags.HTTP_METHOD_ID, "POST"); + copy.store(KnownTags.HTTP_ROUTE_ID, "/copy"); + + assertEquals("GET", store.load(KnownTags.HTTP_METHOD_ID)); + assertNull(store.load(KnownTags.HTTP_ROUTE_ID), "the original keeps its tombstone"); + assertEquals("POST", copy.load(KnownTags.HTTP_METHOD_ID)); + assertEquals("/copy", copy.load(KnownTags.HTTP_ROUTE_ID)); + copy.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void putAllLocalMergesAnotherStoresTags(Function newStore) { + IdStore source = newStore.apply(null); + source.store(KnownTags.HTTP_ROUTE_ID, "/a"); + source.store(KnownTags.HTTP_METHOD_ID, "GET"); + + IdStore empty = newStore.apply(null); + empty.putAllLocal(source); // nothing here yet + assertEquals(visible(source), visible(empty)); + + IdStore merged = newStore.apply(null); + merged.store(KnownTags.HTTP_METHOD_ID, "POST"); + merged.store(KnownTags.COMPONENT_ID, "okhttp"); + merged.putAllLocal(source); + assertEquals("GET", merged.load(KnownTags.HTTP_METHOD_ID), "the source wins"); + assertEquals("okhttp", merged.load(KnownTags.COMPONENT_ID)); + assertEquals(3, merged.size()); + + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore tombstoned = newStore.apply(parent); + tombstoned.remove(KnownTags.HTTP_ROUTE_ID); + tombstoned.putAllLocal(source); // replaces the tombstone + assertEquals("/a", tombstoned.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals(2, tombstoned.size()); + + newStore.apply(null).putAllLocal(newStore.apply(null)); // an empty source is a no-op + merged.checkIntegrity(); + tombstoned.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void fillsCopyTheLocalTags(Function newStore) { + IdStore store = newStore.apply(null); + store.store(KnownTags.HTTP_ROUTE_ID, "/a"); + store.store(KnownTags.HTTP_STATUS_CODE_ID, 200); + + Map map = new HashMap<>(); + store.fillMapLocal(map); + assertEquals("/a", map.get("http.route")); + assertEquals(200, map.get("http.status_code")); + + Map strings = new HashMap<>(); + store.fillStringMapLocal(strings); + assertEquals("200", strings.get("http.status_code")); + } + + @ParameterizedTest + @MethodSource("stores") + void growsPastItsFirstCapacity(Function newStore) throws Exception { + IdStore store = newStore.apply(null); + List ids = knownIds(); + int n = Math.min(40, ids.size()); + for (int i = 0; i < n; ++i) { + store.store(ids.get(i), "v" + i); + } + for (int i = 0; i < n; ++i) { + assertEquals("v" + i, store.load(ids.get(i))); + } + assertEquals(n, store.size()); + assertTrue(store.computeSize() > 0); + assertNotNull(store.toInternalString()); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void putAllLocalMergesOverlappingStoresWithCollidingTags(Function newStore) + throws Exception { + // enough tags that buckets hold groups of colliding entries on both sides of the merge + List ids = knownIds(); + IdStore target = newStore.apply(null); + IdStore source = newStore.apply(null); + for (int i = 0; i < 30; ++i) { + target.store(ids.get(i), "target" + i); + } + for (int i = 20; i < 50; ++i) { + source.store(ids.get(i), "source" + i); + } + target.putAllLocal(source); + assertEquals(50, target.size()); + for (int i = 0; i < 50; ++i) { + assertEquals(i < 20 ? "target" + i : "source" + i, target.load(ids.get(i))); + } + target.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void getAndPutRevivesARemovedInheritedTag(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore store = newStore.apply(parent); + store.remove(KnownTags.HTTP_ROUTE_ID); + + // hidden, so there is no visible prior value + assertNull(store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/revived"))); + assertEquals("/revived", store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals(1, store.size()); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void readsGoThroughEveryAncestor(Function newStore) { + IdStore grandparent = newStore.apply(null); + grandparent.store(KnownTags.HTTP_ROUTE_ID, "/grandparent"); + grandparent.store(KnownTags.COMPONENT_ID, "okhttp"); + IdStore parent = newStore.apply(grandparent); + parent.store(KnownTags.HTTP_METHOD_ID, "GET"); + parent.remove(KnownTags.COMPONENT_ID); + IdStore store = newStore.apply(parent); + + assertEquals("/grandparent", store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals("GET", store.load(KnownTags.HTTP_METHOD_ID)); + assertNull(store.load(KnownTags.COMPONENT_ID), "hidden by the parent"); + assertEquals(2, store.size()); + Map expected = new HashMap<>(); + expected.put("http.route", "/grandparent"); + expected.put("http.method", "GET"); + assertEquals(expected, visible(store)); + } + + @ParameterizedTest + @MethodSource("stores") + void getStringFormatsTheVisibleValue(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.store(KnownTags.COMPONENT_ID, "hidden"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.PEER_PORT_ID, 5432); + store.store(KnownTags.DB_INSTANCE_ID, 1.5d); + store.remove(KnownTags.COMPONENT_ID); + + assertEquals("5432", store.loadString(KnownTags.PEER_PORT_ID)); + assertEquals("1.5", store.loadString(KnownTags.DB_INSTANCE_ID)); + assertEquals("/inherited", store.loadString(KnownTags.HTTP_ROUTE_ID)); + assertNull(store.loadString(KnownTags.COMPONENT_ID), "hidden by the removal"); + assertNull(store.loadString(KnownTags.HTTP_METHOD_ID)); + assertThrows( + IllegalArgumentException.class, () -> store.loadString(KnownTagCodec.makeTagId(9999))); + } + + @ParameterizedTest + @MethodSource("stores") + void aViewReadsValuesFromEitherStore(Function newStore) { + IdStore store = newStore.apply(null); + store.store(KnownTags.HTTP_ROUTE_ID, "/a"); + store.store(KnownTags.PEER_PORT_ID, 5432); + HashStore names = HashStore.create(null); + names.store("my.tag", 7); + + TagMap view = TagMap.viewOf(store, names, false); + assertEquals("/a", view.getObject(KnownTags.HTTP_ROUTE_NAME)); + assertEquals(5432, view.getObject("peer.port")); + assertEquals("5432", view.getString("peer.port")); + assertEquals(7, view.getObject("my.tag")); + assertEquals("7", view.getString("my.tag")); + assertNull(view.getObject(KnownTags.HTTP_METHOD_NAME)); + assertNull(view.getString("other.tag")); + assertNull(TagMap.viewOf(store, null, false).getString("my.tag"), "no custom store"); + } + + @ParameterizedTest + @MethodSource("stores") + void aViewReadsPrimitivesThroughAReader(Function newStore) { + IdStore store = newStore.apply(null); + store.store(KnownTags.PEER_PORT_ID, 5432); + store.store(KnownTags.DB_INSTANCE_ID, 1.5d); + store.store(KnownTags.DD_MEASURED_ID, true); + HashStore names = HashStore.create(null); + names.store("my.count", 7L); + + TagMap view = TagMap.viewOf(store, names, false); + assertEquals(5432, view.getInt("peer.port")); + assertEquals(5432L, view.getLong("peer.port")); + assertEquals(1.5d, view.getDouble("db.instance")); + assertEquals(1.5f, view.getFloat("db.instance")); + assertTrue(view.getBoolean(KnownTags.DD_MEASURED_NAME)); + assertEquals(7L, view.getLong("my.count")); + assertEquals(-1, view.getIntOrDefault(KnownTags.HTTP_METHOD_NAME, -1)); + assertEquals(-1L, view.getLongOrDefault("other.tag", -1L)); + assertTrue(view.containsKey("peer.port")); + assertTrue(view.containsKey("my.count")); + assertFalse(view.containsKey("other.tag")); + + EntryReader port = view.getEntryReader(KnownTags.PEER_PORT_ID); + assertEquals(5432, port.intValue()); + assertEquals(KnownTags.PEER_PORT_ID, port.tagId()); + assertEquals(7L, view.getEntryReader("my.count").longValue()); + assertNull(view.getEntryReader(KnownTags.HTTP_METHOD_ID)); + assertNull(TagMap.viewOf(store, null, false).getEntryReader("my.count"), "no custom store"); + } + + @ParameterizedTest + @MethodSource("stores") + void aStreamYieldsEntriesThatStayValidOnceCollected(Function newStore) { + IdStore store = newStore.apply(null); + store.store(KnownTags.HTTP_ROUTE_ID, "/a"); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); + store.store(KnownTags.PEER_PORT_ID, 5432); + HashStore names = HashStore.create(null); + names.store("my.tag", "custom"); + TagMap view = TagMap.viewOf(store, names, false); + + // collected, then read: each element must still describe its own tag + List collected = view.stream().collect(Collectors.toList()); + Map byName = new HashMap<>(); + for (EntryReader entryReader : collected) { + assertNull(byName.put(entryReader.tag(), entryReader.objectValue()), entryReader.tag()); + } + Map expected = new HashMap<>(); + expected.put("http.route", "/a"); + expected.put("http.method", "GET"); + expected.put("peer.port", 5432); + expected.put("my.tag", "custom"); + assertEquals(expected, byName); + } + + @ParameterizedTest + @MethodSource("stores") + void settingANullValueRemovesTheTag(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); + + store.store(KnownTags.HTTP_METHOD_ID, (Object) null); + store.store(KnownTags.HTTP_ROUTE_ID, (CharSequence) null); // hides the inherited tag + assertNull(store.load(KnownTags.HTTP_METHOD_ID)); + assertNull(store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals(0, store.size()); + assertEquals("/inherited", parent.load(KnownTags.HTTP_ROUTE_ID), "the parent keeps it"); + } + + @ParameterizedTest + @MethodSource("stores") + void aNullValueThroughTheMapApiRemovesTheTag(Function newStore) { + TagMap view = TagMap.viewOf(newStore.apply(null), HashStore.create(null), false); + view.put("http.route", "/a"); + view.put("my.tag", "custom"); + view.set("http.method", "GET"); + view.set("peer.port", 5432); + + assertEquals("/a", view.put("http.route", null), "returns the prior value"); + assertEquals("custom", view.put("my.tag", null)); + view.set("http.method", (Object) null); + assertEquals(5432, view.getAndSet("peer.port", (Object) null).objectValue()); + Map withNull = new HashMap<>(); + withNull.put("http.route", null); + view.putAll(withNull); // already absent: still absent + + for (String tag : new String[] {"http.route", "my.tag", "http.method", "peer.port"}) { + assertFalse(view.containsKey(tag), tag); + assertNull(view.get(tag), tag); + } + assertEquals(0, view.size()); + assertTrue(view.isEmpty()); + } + + @ParameterizedTest + @MethodSource("stores") + void aReaderWithNoEntryIsANoOp(Function newStore) { + IdStore store = newStore.apply(null); + store.store(KnownTags.HTTP_ROUTE_ID, "/a"); + NoEntryReader empty = new NoEntryReader("http.route", KnownTags.HTTP_ROUTE_ID); + + assertNull(store.put(empty)); + assertNull(store.getAndPut(empty)); + assertEquals("/a", store.load(KnownTags.HTTP_ROUTE_ID), "left as it was"); + assertEquals(1, store.size()); + } + + @ParameterizedTest + @MethodSource("stores") + void getEntryReturnsAnEntryTheCallerMayKeep(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); + + TagMap.Entry method = store.getEntry(KnownTags.HTTP_METHOD_ID); + TagMap.Entry route = store.getEntry(KnownTags.HTTP_ROUTE_ID); + EntryReader methodReader = store.getEntryReader(KnownTags.HTTP_METHOD_ID); + EntryReader routeReader = store.getEntryReader(KnownTags.HTTP_ROUTE_ID); + + // kept side by side, each still holds its own tag + assertEquals("GET", method.objectValue()); + assertEquals(KnownTags.HTTP_METHOD_ID, method.tagId()); + assertEquals("/inherited", route.objectValue()); + assertEquals("GET", methodReader.objectValue()); + assertEquals("/inherited", routeReader.objectValue()); + assertNull(store.getEntry(KnownTags.COMPONENT_ID)); + assertThrows( + IllegalArgumentException.class, () -> store.getEntry(KnownTagCodec.makeTagId(9999))); + } + + @ParameterizedTest + @MethodSource("stores") + void anUnknownIdIsRejected(Function newStore) { + IdStore store = newStore.apply(null); + long unknown = KnownTagCodec.makeTagId(9999); + assertThrows(IllegalArgumentException.class, () -> store.load(unknown)); + assertThrows(IllegalArgumentException.class, () -> store.remove(unknown)); + } + + /** An EntryReader with no entry and no value, as a caller outside the tracer may pass. */ + static final class NoEntryReader implements TagMap.EntryReader { + private final String tag; + private final long tagId; + + NoEntryReader(String tag, long tagId) { + this.tag = tag; + this.tagId = tagId; + } + + @Override + public String tag() { + return tag; + } + + @Override + public long tagId() { + return tagId; + } + + @Override + public byte type() { + return TagMap.EntryReader.OBJECT; + } + + @Override + public boolean is(byte type) { + return false; + } + + @Override + public boolean isNumericPrimitive() { + return false; + } + + @Override + public boolean isNumber() { + return false; + } + + @Override + public boolean isObject() { + return true; + } + + @Override + public Object objectValue() { + return null; + } + + @Override + public String stringValue() { + return null; + } + + @Override + public boolean booleanValue() { + return false; + } + + @Override + public int intValue() { + return 0; + } + + @Override + public long longValue() { + return 0; + } + + @Override + public float floatValue() { + return 0; + } + + @Override + public double doubleValue() { + return 0; + } + + @Override + public java.util.Map.Entry mapEntry() { + return null; + } + + @Override + public TagMap.Entry entry() { + return null; + } + } + + /** + * The cursor walks single-entry buckets, bucket groups and chains of groups, reads through to the + * parent, and skips inherited tags removed or shadowed locally: it sees what forEach sees. + */ + @ParameterizedTest + @MethodSource("stores") + void cursorSeesWhatForEachSees(Function newStore) + throws IllegalAccessException { + List ids = knownIds(); + IdStore parent = newStore.apply(null); + for (int i = 0; i < ids.size(); i += 3) { + parent.store(ids.get(i), "parent-" + i); + } + IdStore store = newStore.apply(parent); + for (int i = 0; i < ids.size(); i += 2) { + store.store(ids.get(i), "local-" + i); + } + store.remove(ids.get(3)); // inherited, not shadowed: removal hides it + + Map viaCursor = new HashMap<>(); + IdStore.Cursor cursor = store.cursor(); + for (EntryReader entry = cursor.next(); entry != null; entry = cursor.next()) { + assertNull(viaCursor.put(entry.tag(), entry.objectValue()), entry.tag()); + } + + assertEquals(visible(store), viaCursor); + assertNull(viaCursor.get(KnownTagCodec.nameOf(ids.get(3)))); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/IdStoreFactoryTest.java b/internal-api/src/test/java/datadog/trace/api/IdStoreFactoryTest.java new file mode 100644 index 00000000000..149e842af23 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreFactoryTest.java @@ -0,0 +1,35 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** The JVM's store factory builds working stores, whichever implementation it is. */ +class IdStoreFactoryTest { + private static final IdStoreFactory FACTORY = IdStoreFactory.Default.INSTANCE; + + @Test + void createdStoreReadsThroughToItsParent() { + IdStore parent = FACTORY.create(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/a"); + + IdStore store = FACTORY.create(parent); + + assertEquals("/a", store.load(KnownTags.HTTP_ROUTE_ID)); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); + assertEquals("GET", store.load(KnownTags.HTTP_METHOD_ID)); + assertNull(parent.load(KnownTags.HTTP_METHOD_ID)); + } + + @Test + void createdEmptyStoreHasNoParent() { + IdStore store = FACTORY.createEmpty(); + + assertTrue(store.isEmpty()); + assertTrue(!store.hasParent()); + store.store(KnownTags.HTTP_ROUTE_ID, "/a"); + assertEquals("/a", store.load(KnownTags.HTTP_ROUTE_ID)); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java b/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java index ded02b397e5..34c240757c8 100644 --- a/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java @@ -81,4 +81,14 @@ void anUnslottedTagSharesTheReservedTopBit() { assertEquals(1L << 63, 1L << KnownTagCodec.slotOf(KnownTags.RESOURCE_NAME_ID)); assertTrue(KnownTagCodec.slotCount() <= 63, "real slots must stay below the shared bit 63"); } + + @Test + void makeTagIdBuildsTheRealIdOfAKnownTag() { + // a slotted, intercepted tag: its constant carries slot and flag bits beyond the serial + long id = KnownTags.HTTP_STATUS_CODE_ID; + assertEquals(id, KnownTagCodec.makeTagId(KnownTagCodec.serialNum(id))); + + long unknown = KnownTagCodec.makeTagId(KnownTagCodec.serialLimit() + 1); + assertFalse(KnownTagCodec.isKnown(unknown)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java index f7de5fd4712..bd10ace0b3d 100644 --- a/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java @@ -316,6 +316,21 @@ void canonicalTagNameMapsAnOpenTelemetryRenameToItsDatadogName() { assertEquals("my.custom.tag", KnownTagCodec.canonicalTagName("my.custom.tag")); } + @Test + void idOfRecoversEveryKnownTagsFullIdFromItsSerial() throws IllegalAccessException { + int checked = 0; + for (java.lang.reflect.Field field : KnownTags.class.getFields()) { + if (field.getType() == long.class && field.getName().endsWith("_ID")) { + long tagId = field.getLong(null); + if (KnownTagCodec.isKnown(tagId)) { + assertEquals(tagId, KnownTagCodec.idOf(KnownTagCodec.serialNum(tagId)), field.getName()); + checked++; + } + } + } + assertTrue(checked > 0); + } + @TableTest({ "scenario | kind | direction", "no kind | | -1 ", diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java new file mode 100644 index 00000000000..9d18d50a2d1 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java @@ -0,0 +1,218 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Registry semantics for {@link SizingHintTable}, and how {@link DenseIdStore} sizes from and tunes + * a {@link SizingHint}. Hints are only minted with the dense store on, and the flag is read once + * into a static final, so this runs in its own JVM ({@code ForkedTest}) with the flag set first. + * + *

    The table is process-wide static state; each test uses distinct operation names so tests don't + * interfere. The overflow test fills the child lane, so no other test here uses that lane expecting + * a fresh hint. + */ +class SizingHintTableForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + } + + /** Distinct known tag ids, in a stable order. */ + static final List KNOWN_IDS = new ArrayList<>(); + + @BeforeAll + static void checkSetup() throws IllegalAccessException { + assertTrue(IdStoreFactory.Default.DENSE, "dense store must be on in this JVM"); + for (Field field : KnownTags.class.getFields()) { + if (field.getType() != long.class) continue; + long id = field.getLong(null); + if (KnownTagCodec.isKnown(id) && !KNOWN_IDS.contains(id)) { + KNOWN_IDS.add(id); + } + } + assertTrue(KNOWN_IDS.size() > 24, "need enough known tags to exercise growth"); + } + + private static IdStore storeFor(SizingHint hint) { + IdStore store = IdStore.create(null, hint); + assertSame(DenseIdStore.class, store.getClass()); + return store; + } + + private static Object field(IdStore store, String name) { + try { + Field f = DenseIdStore.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(store); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private static int capacity(IdStore store) { + char[] serials = (char[]) field(store, "serials"); + Object[] values = (Object[]) field(store, "values"); + assertEquals(serials.length, values.length, "dense arrays stay parallel"); + return serials.length; + } + + /** Sets the first {@code n} known tags. */ + private static void fill(IdStore store, int n) { + for (int i = 0; i < n; ++i) { + store.store(KNOWN_IDS.get(i), "v" + i); + } + assertEquals(n, store.size()); + } + + @Test + void nullOperationNameGetsNoHint() { + assertNull(SizingHintTable.hintFor(null, true)); + assertNull(SizingHintTable.hintFor(null, false)); + } + + @Test + void nonStringCharSequenceIsKeyedByContent() { + // Operation names are commonly UTF8BytesString, not String; a content-equal name must resolve + // to the same hint as its String form (keyed by toString(), which is O(1) for these types). + SizingHint viaString = SizingHintTable.hintFor("registry.utf8", true); + CharSequence utf8 = UTF8BytesString.create("registry.utf8"); + assertNotNull(viaString); + assertSame(viaString, SizingHintTable.hintFor(utf8, true)); + } + + @Test + void freshHintIsSeededAndUncapped() { + SizingHint hint = SizingHintTable.hintFor("registry.fresh", true); + assertNotNull(hint); + assertEquals(SizingHintTable.SEED_SIZE, hint.size); + assertFalse(hint.capped); + assertEquals("registry.fresh", hint.key()); + } + + @Test + void sameOperationAndLaneReturnsSameInstance() { + assertSame( + SizingHintTable.hintFor("registry.stable", true), + SizingHintTable.hintFor("registry.stable", true)); + } + + @Test + void entryAndChildLanesAreIndependent() { + SizingHint entry = SizingHintTable.hintFor("registry.twolane", true); + SizingHint child = SizingHintTable.hintFor("registry.twolane", false); + assertNotNull(entry); + assertNotNull(child); + assertNotSame(entry, child, "each lane holds its own hint for the same operation name"); + assertSame(entry, SizingHintTable.hintFor("registry.twolane", true)); + assertSame(child, SizingHintTable.hintFor("registry.twolane", false)); + } + + @Test + void beyondCardinalityBudgetSharesACappedOverflowHint() { + // A hint minted before the lane fills keeps resolving to itself afterwards. + SizingHint early = SizingHintTable.hintFor("registry.flood.early", false); + assertFalse(early.capped); + + // Fill the child lane past its budget; a capped shared hint must appear. + SizingHint firstOverflow = null; + for (int i = 0; i <= SizingHintTable.CARDINALITY_LIMIT && firstOverflow == null; i++) { + SizingHint hint = SizingHintTable.hintFor("registry.flood." + i, false); + assertNotNull(hint); + if (hint.capped) { + firstOverflow = hint; + } + } + assertNotNull(firstOverflow, "lane collapses to a capped overflow hint once full"); + assertEquals(SizingHintTable.OVERFLOW_SEED, firstOverflow.size); + + // Every further over-budget name shares that same capped instance... + SizingHint another = SizingHintTable.hintFor("registry.flood.after", false); + assertTrue(another.capped); + assertSame(firstOverflow, another); + // ...existing hints still resolve (the cap blocks only creation)... + assertSame(early, SizingHintTable.hintFor("registry.flood.early", false)); + // ...and the entry lane has its own budget. + assertFalse(SizingHintTable.hintFor("registry.flood.after", true).capped); + + // A capped hint is never tuned: a heavy span sharing it does not grow it. + IdStore heavy = storeFor(another); + fill(heavy, SizingHintTable.OVERFLOW_SEED + 4); + heavy.recordSize(); + assertEquals(SizingHintTable.OVERFLOW_SEED, another.size); + } + + @Test + void sizingHintPresizesTheDenseStore() { + SizingHint hint = SizingHintTable.hintFor("registry.presize", true); + assertEquals( + hint.size, capacity(storeFor(hint)), "presized at the hint's size, before a write"); + + // Without a hint, a store starts on the shared empty arrays. + assertEquals(0, capacity(storeFor(null))); + assertEquals(0, capacity(IdStore.create(null))); + } + + @Test + void sizingHintFeedsAndTunesTheDenseStore() { + SizingHint hint = SizingHintTable.hintFor("registry.tuning", true); + assertEquals(SizingHintTable.SEED_SIZE, hint.size); + + int observed = SizingHintTable.SEED_SIZE + 3; + IdStore store = storeFor(hint); + fill(store, observed); + store.recordSize(); + assertEquals(observed, hint.size, "hint self-tunes up to the observed high-water mark"); + + // The next store of the operation is presized to fit, so it never grows. + IdStore next = storeFor(hint); + assertEquals(observed, capacity(next)); + fill(next, observed); + assertEquals(observed, capacity(next)); + + // A smaller later observation does not shrink the hint. + IdStore smaller = storeFor(hint); + fill(smaller, 1); + smaller.recordSize(); + assertEquals(observed, hint.size, "recordSize never shrinks the hint"); + + // A store without a hint records nothing. + storeFor(null).recordSize(); + } + + @Test + void fullArraysDoubleUpToTheSerialLimit() { + // Presized small: 4 -> 8 -> 16 + IdStore store = storeFor(new SizingHint("registry.growth")); + fill(store, 4); + assertEquals(4, capacity(store)); + fill(store, 5); + assertEquals(8, capacity(store)); + fill(store, 9); + assertEquals(16, capacity(store)); + + // Unhinted: first allocation at INIT_CAP, then doubling + IdStore unhinted = storeFor(null); + fill(unhinted, 1); + assertEquals(DenseIdStore.INIT_CAP, capacity(unhinted)); + fill(unhinted, DenseIdStore.INIT_CAP + 1); + assertEquals(2 * DenseIdStore.INIT_CAP, capacity(unhinted)); + + // Growth never passes the serial limit, yet always makes room. + IdStore all = storeFor(new SizingHint("registry.growth.all")); + fill(all, KNOWN_IDS.size()); + assertTrue(capacity(all) <= KnownTagCodec.serialLimit()); + assertTrue(capacity(all) >= KNOWN_IDS.size()); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java new file mode 100644 index 00000000000..7e54c6cc7eb --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java @@ -0,0 +1,59 @@ +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.Assumptions.assumeTrue; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * With the dense store off, {@link SizingHintTable} hands out no hints and never allocates its + * lanes, and the hash store ignores a hint. Runs in its own JVM so the flag is read as off. + */ +class SizingHintTableHashOnlyForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "false"); + } + + @Test + void noHintWhenTheDenseStoreIsOff() { + assertFalse(IdStoreFactory.Default.DENSE); + assertNull(SizingHintTable.hintFor("registry.off", true)); + assertNull(SizingHintTable.hintFor("registry.off", false)); + assertNull(SizingHintTable.hintFor(null, true)); + assertFalse(isLoaded("datadog.trace.api.SizingHintTable$Lanes")); + } + + @Test + void theHashStoreIgnoresAHint() { + SizingHint hint = new SizingHint("registry.off.direct"); + IdStore store = IdStore.create(null, hint); + store.store(KnownTags.COMPONENT_ID, "okhttp"); + store.store(KnownTags.DD_BASE_SERVICE_ID, "svc"); + store.recordSize(); + + assertSame(HashIdStore.class, store.getClass()); + assertEquals(SizingHintTable.SEED_SIZE, hint.size, "recordSize is a no-op on the hash store"); + assertFalse(isLoaded("datadog.trace.api.DenseIdStore")); + } + + /** Whether {@code className} has been loaded by TagMap's class loader, without loading it. */ + private static boolean isLoaded(String className) { + Method findLoadedClass; + try { + findLoadedClass = ClassLoader.class.getDeclaredMethod("findLoadedClass", String.class); + findLoadedClass.setAccessible(true); + } catch (RuntimeException | NoSuchMethodException e) { + assumeTrue(false, "cannot inspect loaded classes on this JVM: " + e); + return false; + } + try { + return findLoadedClass.invoke(TagMap.class.getClassLoader(), className) != null; + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapBucketHashTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapBucketHashTest.java new file mode 100644 index 00000000000..7a58dbe3534 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapBucketHashTest.java @@ -0,0 +1,67 @@ +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** How {@link TagMap.Entry#bucketHash} places known and custom tags in the buckets. */ +class TagMapBucketHashTest { + private static final int BUCKET_MASK = 15; // TagMap's 16 buckets + + /** Every known tag id the registry generated. */ + static List knownTagIds() throws IllegalAccessException { + List ids = new ArrayList<>(); + for (Field field : KnownTags.class.getFields()) { + if (Modifier.isStatic(field.getModifiers()) + && field.getType() == long.class + && field.getName().endsWith("_ID")) { + ids.add(field.getLong(null)); + } + } + return ids; + } + + @Test + void knownTagsGetDistinctNonzeroBucketHashes() throws IllegalAccessException { + List ids = knownTagIds(); + assertFalse(ids.isEmpty()); + Set hashes = new HashSet<>(); + for (long id : ids) { + int hash = TagMap.Entry.bucketHash(id); + assertNotEquals(0, hash, "zero marks a vacant BucketGroup slot"); + assertTrue( + hashes.add(hash), "two known tags share a bucket hash: " + KnownTagCodec.nameOf(id)); + } + } + + @Test + void aSlottedKnownTagIsBucketedBySlot() throws IllegalAccessException { + for (long id : knownTagIds()) { + int slot = KnownTagCodec.slotOf(id); + if (slot == KnownTagCodec.NO_SLOT) { + continue; + } + // trace-level tags reuse the span-level slot numbers, so they move to the other half + int expected = (slot ^ (KnownTagCodec.isTraceLevel(id) ? 8 : 0)) & BUCKET_MASK; + assertEquals(expected, TagMap.Entry.bucketHash(id) & BUCKET_MASK, KnownTagCodec.nameOf(id)); + } + } + + @Test + void tagsWithoutASlotAndCustomTagsKeepTheirHashes() { + long noSlot = ((long) 5 << 48) | ((long) KnownTagCodec.NO_SLOT << 32); + assertEquals((int) (noSlot >>> 48) ^ (int) noSlot, TagMap.Entry.bucketHash(noSlot)); + + long custom = TagMap.Entry.customHash("my.custom.tag"); + assertEquals((int) custom == 0 ? 0xDD06 : (int) custom, TagMap.Entry.bucketHash(custom)); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java index b8764472847..261422c2732 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java @@ -6,12 +6,16 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import java.util.HashMap; +import java.util.Map; import org.junit.jupiter.api.Test; /** * Pins the Entry-pathway null-tolerance contract: {@code create(...)} returns null for a null or * empty value, and the {@code Entry} sinks ({@code set(Entry)} / {@code getAndSet(Entry)}) treat - * null as a no-op -- so a null/empty value flows through as "no tag" without any caller guarding. + * null as a no-op -- so a null/empty value flows through as "no tag" without any caller guarding. A + * null value given to a setter removes the tag, as {@code setTag(tag, null)} does on a span. */ class TagMapNullToleranceTest { @@ -63,4 +67,55 @@ void nullOrEmptyFlowsThroughTheEntryPathwayAsNoTag() { assertEquals(1, map.size()); assertTrue(map.containsKey("present")); } + + @Test + void setNullRemovesAKnownOrCustomTag() { + TagMap map = TagMap.create(); + map.set(Tags.COMPONENT, "okhttp"); + map.set("custom.tag", "v"); + + map.set(Tags.COMPONENT, (Object) null); + map.set("custom.tag", (CharSequence) null); + assertEquals(0, map.size()); + assertFalse(map.containsKey(Tags.COMPONENT)); + assertFalse(map.containsKey("custom.tag")); + } + + @Test + void setNullByIdRemovesTheTag() { + TagMap map = TagMap.create(); + map.set(KnownTags.COMPONENT_ID, "okhttp"); + map.set(KnownTags.COMPONENT_ID, (Object) null); + assertNull(map.getObject(Tags.COMPONENT)); + + map.set(KnownTags.COMPONENT_ID, "okhttp"); + map.set(KnownTags.COMPONENT_ID, (CharSequence) null); + assertEquals(0, map.size()); + } + + @Test + void putAndGetAndSetNullRemoveTheTagAndReturnItsPriorValue() { + TagMap map = TagMap.create(); + map.set("custom.tag", "v"); + assertEquals("v", map.put("custom.tag", null)); + assertFalse(map.containsKey("custom.tag")); + + map.set(Tags.COMPONENT, "okhttp"); + assertEquals("okhttp", map.getAndSet(Tags.COMPONENT, (Object) null).objectValue()); + assertNull(map.getAndSet(Tags.COMPONENT, (CharSequence) null), "already removed"); + assertEquals(0, map.size()); + } + + @Test + void putAllWithANullValueLeavesNoTag() { + Map source = new HashMap<>(); + source.put("custom.tag", null); + source.put(Tags.COMPONENT, null); + source.put("present", "v"); + + TagMap map = TagMap.fromMap(source); + assertEquals(1, map.size()); + assertFalse(map.containsKey("custom.tag")); + assertFalse(map.containsKey(Tags.COMPONENT)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java index 4634a02da58..ecef85afdd4 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java @@ -72,40 +72,4 @@ void anUnknownIdIsRejected() { assertThrows(IllegalArgumentException.class, () -> map.set(0L, "value")); assertThrows(IllegalArgumentException.class, () -> map.set(KnownTagCodec.makeTagId(9999), 1)); } - - @Test - void theCoresTrustedSetsStoreWhatTheCheckedSetsStore() { - TagMap checked = TagMap.create(); - checked.set(KnownTags.HTTP_ROUTE_ID, "/users/{id}"); - checked.set(KnownTags.HTTP_STATUS_CODE_ID, 200); - checked.set(KnownTags.HTTP_RESEND_COUNT_ID, 2L); - checked.set(KnownTags.DD_PROFILING_ENABLED_ID, true); - - TagMap trusted = TagMap.create(); - trusted.internals().setKnown(KnownTags.HTTP_ROUTE_ID, "/users/{id}"); - trusted.internals().setKnown(KnownTags.HTTP_STATUS_CODE_ID, 200); - trusted.internals().setKnown(KnownTags.HTTP_RESEND_COUNT_ID, 2L); - trusted.internals().setKnown(KnownTags.DD_PROFILING_ENABLED_ID, true); - - assertEquals(new HashMap<>(checked), new HashMap<>(trusted)); - assertEquals(KnownTags.HTTP_ROUTE_ID, trusted.getEntry(KnownTags.HTTP_ROUTE_NAME).tagId()); - } - - @Test - void theCoresTrustedSetsCoverTheRemainingValueShapes() { - Object component = new StringBuilder("jdbc"); // not a String: exercises the Object overload - TagMap checked = TagMap.create(); - checked.set(KnownTags.COMPONENT_ID, component); - checked.set(KnownTags.DB_POOL_NAME_ID, 1.5f); - checked.set(KnownTags.DB_INSTANCE_ID, 2.25d); - - TagMap trusted = TagMap.create(); - trusted.internals().setKnown(KnownTags.COMPONENT_ID, component); - trusted.internals().setKnown(KnownTags.DB_POOL_NAME_ID, 1.5f); - trusted.internals().setKnown(KnownTags.DB_INSTANCE_ID, 2.25d); - - assertEquals(new HashMap<>(checked), new HashMap<>(trusted)); - assertEquals(1.5f, trusted.getEntry(KnownTags.DB_POOL_NAME_ID).floatValue()); - assertEquals(2.25d, trusted.getEntry(KnownTags.DB_INSTANCE_ID).doubleValue()); - } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java new file mode 100644 index 00000000000..c843a427248 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java @@ -0,0 +1,700 @@ +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +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 static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * A TagMap's two stores -- an {@link IdStore} for known tags ({@link HashIdStore} unless dense mode + * is on), a {@link HashStore} for custom tags, created lazily -- the name-or-id routing over them, + * and the span's view ({@link TagMap#viewOf}). + */ +class TagStoresTest { + private static TagMap frozenParent() { + TagMap parent = TagMap.create(); + parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + parent.set("inherited.custom", "c"); + return parent.freeze(); + } + + @Test + void aMapWithNoCustomTagsHasNoCustomStore() { + TagMap tags = TagMap.create(); + tags.set(KnownTags.HTTP_ROUTE_ID, "/a"); + tags.set(KnownTags.COMPONENT_NAME, "jdbc"); + assertNull(tags.nameStore); + + tags.set("my.tag", "x"); + assertNotNull(tags.nameStore); + assertEquals(3, tags.size()); + } + + @Test + void aChildBorrowsItsParentsCustomStoreUntilItWritesOne() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + assertSame(parent.nameStore, child.nameStore); + assertTrue(child.nameStoreBorrowed); + assertEquals("c", child.getString("inherited.custom")); + + child.set(KnownTags.HTTP_METHOD_ID, "GET"); // a known write keeps borrowing + assertTrue(child.nameStoreBorrowed); + assertFalse(child.remove("not.there")); // nothing to hide keeps borrowing + assertTrue(child.nameStoreBorrowed); + + child.set("own.custom", "o"); + assertFalse(child.nameStoreBorrowed); + assertNotSame(parent.nameStore, child.nameStore); + assertEquals("c", child.getString("inherited.custom")); // still reads through + assertEquals(5, child.size()); + assertEquals("c", parent.getString("inherited.custom")); + assertNull(parent.getString("own.custom")); + } + + @Test + void removingABorrowedCustomTagHidesItLocally() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + + assertEquals("c", child.getAndRemove("inherited.custom").stringValue()); + + assertFalse(child.nameStoreBorrowed); + assertNull(child.getString("inherited.custom")); + assertEquals(2, child.size()); + assertEquals("c", parent.getString("inherited.custom")); + } + + @Test + void removingAnInheritedKnownTagTombstonesItWithoutCountingIt() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + + assertEquals("/inherited", child.getAndRemove(KnownTags.HTTP_ROUTE_ID).stringValue()); + assertNull(child.getAndRemove(KnownTags.HTTP_ROUTE_ID)); // already removed + + assertNull(child.getEntry(KnownTags.HTTP_ROUTE_ID)); + assertEquals(2, child.size()); + assertFalse(child.containsKey(KnownTags.HTTP_ROUTE_NAME)); + for (TagMap.EntryReader entry : child) { + assertNotEquals(KnownTags.HTTP_ROUTE_ID, entry.tagId()); + } + IdStore ids = child.knownStore; + assertEquals(0, ids.computeSize()); + // whichever store this JVM uses records that it holds a tombstone + assertTrue( + ids instanceof DenseIdStore + ? ((DenseIdStore) ids).tombstoneMask != 0L + : ((HashIdStore) ids).hasTombstones); + child.checkIntegrity(); + + // a re-set undoes the removal: no prior visible value + assertNull(child.getAndSet(KnownTags.HTTP_ROUTE_NAME, "/again")); + assertEquals("/again", child.getString(KnownTags.HTTP_ROUTE_NAME)); + assertEquals(3, child.size()); + } + + @Test + void tombstonesAreSharedPerTag() { + TagMap.Entry first = TagMap.Entry.tombstoneOf(KnownTags.HTTP_ROUTE_ID); + assertSame(first, TagMap.Entry.tombstoneOf(KnownTags.HTTP_ROUTE_ID)); + assertTrue(first.tombstone); + assertEquals(KnownTags.HTTP_ROUTE_NAME, first.tag()); + } + + /** A span-level known tag colored into {@code traceLevelId}'s slot, or 0 if there is none. */ + private static long spanLevelTagInSlotOf(long traceLevelId) throws IllegalAccessException { + for (java.lang.reflect.Field field : KnownTags.class.getFields()) { + if (field.getType() != long.class) continue; + long id = field.getLong(null); + if (KnownTagCodec.isKnown(id) + && !KnownTagCodec.isTraceLevel(id) + && KnownTagCodec.slotOf(id) == KnownTagCodec.slotOf(traceLevelId)) { + return id; + } + } + return 0L; + } + + @Test + void aSpanLevelReadMissesAParentHoldingATraceLevelTagInItsSlot() throws Exception { + // Span-level and trace-level tags are colored independently, so their slots overlap: find a + // span-level tag that shares runtime-id's slot (the coloring decides which one). + long spanLevelId = spanLevelTagInSlotOf(KnownTags.RUNTIME_ID); + assumeTrue(spanLevelId != 0L, "no span-level tag shares runtime-id's slot"); + assertTrue(KnownTagCodec.isTraceLevel(KnownTags.RUNTIME_ID)); + + HashIdStore traceLevel = new HashIdStore(null); + traceLevel.store(KnownTags.RUNTIME_ID, "abc"); + HashIdStore span = new HashIdStore(traceLevel); + long other = + spanLevelId == KnownTags.HTTP_ROUTE_ID ? KnownTags.COMPONENT_ID : KnownTags.HTTP_ROUTE_ID; + span.store(other, "/a"); + + assertNull(span.getEntryReader(spanLevelId)); + assertEquals("abc", span.load(KnownTags.RUNTIME_ID)); + } + + @Test + void aDenseSpanLevelReadSkipsAParentHoldingOnlyTraceLevelTagsInItsSlot() throws Exception { + long spanLevelId = spanLevelTagInSlotOf(KnownTags.RUNTIME_ID); + assumeTrue(spanLevelId != 0L, "no span-level tag shares runtime-id's slot"); + assertEquals(DenseIdStore.TRACE_LEVEL, DenseIdStore.levelOf(KnownTags.RUNTIME_ID)); + + DenseIdStore traceLevel = new DenseIdStore(null); + traceLevel.store(KnownTags.RUNTIME_ID, "abc"); + DenseIdStore span = new DenseIdStore(traceLevel); + long other = + spanLevelId == KnownTags.HTTP_ROUTE_ID ? KnownTags.COMPONENT_ID : KnownTags.HTTP_ROUTE_ID; + span.store(other, "/a"); + + long bit = DenseIdStore.slotBit(spanLevelId); + assertFalse(span.mayInherit(bit, DenseIdStore.SPAN_LEVEL)); + assertTrue(span.mayInherit(bit, DenseIdStore.TRACE_LEVEL)); + assertFalse(traceLevel.mayHaveLocal(bit, DenseIdStore.SPAN_LEVEL)); + assertNull(span.getEntryReader(spanLevelId)); + assertEquals("abc", span.load(KnownTags.RUNTIME_ID)); + } + + @Test + void aClearDenseSlotBitIsADefiniteMiss() { + DenseIdStore ids = new DenseIdStore(null); + assertEquals(0L, ids.spanOccupancy); + assertNull(ids.getEntryReader(KnownTags.HTTP_ROUTE_ID)); + + ids.store(KnownTags.HTTP_ROUTE_ID, "/a"); + assertNotEquals(0L, ids.spanOccupancy & DenseIdStore.slotBit(KnownTags.HTTP_ROUTE_ID)); + assertEquals("/a", ids.load(KnownTags.HTTP_ROUTE_ID)); + + // a child inherits its parent's slots, so a read-through miss can stop early + DenseIdStore child = new DenseIdStore(ids); + assertEquals(ids.spanOccupancy, child.inheritedMask); + assertEquals("/a", child.load(KnownTags.HTTP_ROUTE_ID)); + } + + /** A frozen-style source: a parentless dense store holding {@code ids}, each set to its name. */ + private static DenseIdStore denseSource(long... ids) { + DenseIdStore source = new DenseIdStore(null); + for (long id : ids) { + source.store(id, KnownTagCodec.nameOf(id)); + } + return source; + } + + @Test + void seedAbsentCopiesEveryTagWhenNoneMayBePresent() { + DenseIdStore source = denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID); + DenseIdStore target = new DenseIdStore(null); + target.store(KnownTags.HTTP_ROUTE_ID, "/a"); + assertEquals( + 0L, source.spanOccupancy & target.spanOccupancy, "test precondition: disjoint slots"); + + assertTrue(target.seedAbsent(source)); + assertEquals(3, target.size()); + assertEquals("component", target.load(KnownTags.COMPONENT_ID)); + assertEquals("span.kind", target.load(KnownTags.SPAN_KIND_ID)); + assertEquals("/a", target.load(KnownTags.HTTP_ROUTE_ID)); + target.checkIntegrity(); + } + + @Test + void seedAbsentRefusesWhenAnyTagMayBePresent() { + DenseIdStore source = denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID); + DenseIdStore target = new DenseIdStore(null); + target.store(KnownTags.COMPONENT_ID, "explicit"); + + assertFalse(target.seedAbsent(source)); + assertEquals(1, target.size(), "nothing copied"); + assertEquals("explicit", target.load(KnownTags.COMPONENT_ID)); + } + + @Test + void seedAbsentRefusesASourceWithAParentOrTombstones() { + DenseIdStore parent = denseSource(KnownTags.COMPONENT_ID); + DenseIdStore child = new DenseIdStore(parent); + child.store(KnownTags.SPAN_KIND_ID, "server"); + assertFalse(new DenseIdStore(null).seedAbsent(child), "read-through source"); + + DenseIdStore tombstoned = new DenseIdStore(parent); + tombstoned.remove(KnownTags.COMPONENT_ID); + assertFalse(new DenseIdStore(null).seedAbsent(tombstoned), "tombstoned source"); + } + + @Test + void seedAbsentGrowsTheTargetToFit() { + DenseIdStore source = + denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID, KnownTags.HTTP_ROUTE_ID); + DenseIdStore target = new DenseIdStore(null); // shared empty arrays: no room yet + + assertTrue(target.seedAbsent(source)); + assertEquals(3, target.size()); + assertEquals("http.route", target.load(KnownTags.HTTP_ROUTE_ID)); + target.checkIntegrity(); + } + + @Test + void theHashStoreNeverSeedsInBulk() { + HashIdStore target = new HashIdStore(null); + assertFalse(target.seedAbsent(denseSource(KnownTags.COMPONENT_ID))); + assertFalse(target.seedAbsent(new HashIdStore(null))); + assertFalse( + target.seedPartial(denseSource(KnownTags.COMPONENT_ID), target, TagStoresTest::put)); + } + + /** The caller's tag-by-tag fallback for a partial seed: an overwriting put. */ + private static void put(IdStore target, TagMap.EntryReader entry) { + target.store(entry.tagId(), entry.objectValue()); + } + + @Test + void partialSeedAppendsTheAbsentAndHandsBackTheRest() { + DenseIdStore source = + denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID, KnownTags.HTTP_ROUTE_ID); + DenseIdStore target = new DenseIdStore(null); + target.store(KnownTags.COMPONENT_ID, "explicit"); + long componentBit = DenseIdStore.slotBit(KnownTags.COMPONENT_ID); + assertNotEquals(componentBit, DenseIdStore.slotBit(KnownTags.SPAN_KIND_ID), "precondition"); + assertNotEquals(componentBit, DenseIdStore.slotBit(KnownTags.HTTP_ROUTE_ID), "precondition"); + + Map handedBack = new HashMap<>(); + assertTrue( + target.seedPartial( + source, + target, + (store, entry) -> { + handedBack.put(entry.tagId(), entry.objectValue()); + put(store, entry); + })); + + assertEquals(1, handedBack.size()); + assertEquals("component", handedBack.get(KnownTags.COMPONENT_ID)); + assertEquals(3, target.size()); + assertEquals("component", target.load(KnownTags.COMPONENT_ID)); // overwritten by the caller + assertEquals("span.kind", target.load(KnownTags.SPAN_KIND_ID)); + assertEquals("http.route", target.load(KnownTags.HTTP_ROUTE_ID)); + target.checkIntegrity(); + } + + @Test + void partialSeedCopiesInOneGoWhenNothingMayBePresent() { + DenseIdStore source = denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID); + DenseIdStore target = new DenseIdStore(null); + + assertTrue( + target.seedPartial( + source, + target, + (store, entry) -> { + throw new AssertionError("nothing may be present: " + entry.tag()); + })); + assertEquals(2, target.size()); + target.checkIntegrity(); + } + + @Test + void partialSeedHandsBackATagThatOnlySharesASlot() { + // a set bit only means "maybe present": a different tag in the same slot still goes the slow + // way, and the caller's put then appends it + long[] pair = twoSpanLevelTagsInOneSlot(); + DenseIdStore source = denseSource(pair[0]); + DenseIdStore target = new DenseIdStore(null); + target.store(pair[1], "other"); + + Map handedBack = new HashMap<>(); + assertTrue( + target.seedPartial( + source, + target, + (store, entry) -> { + handedBack.put(entry.tagId(), entry.objectValue()); + put(store, entry); + })); + + assertEquals(1, handedBack.size()); + assertEquals(2, target.size()); + assertEquals("other", target.load(pair[1])); + assertNotNull(target.load(pair[0])); + target.checkIntegrity(); + } + + /** Two known span-level tags that share an occupancy slot. */ + private static long[] twoSpanLevelTagsInOneSlot() { + Map bySlot = new HashMap<>(); + for (int serial = 1; serial < KnownTagCodec.serialLimit(); serial++) { + long tagId = KnownTagCodec.idOf((char) serial); + if (tagId == 0 || DenseIdStore.levelOf(tagId) != DenseIdStore.SPAN_LEVEL) { + continue; + } + Long earlier = bySlot.putIfAbsent(DenseIdStore.slotBit(tagId), tagId); + if (earlier != null) { + return new long[] {tagId, earlier}; + } + } + assumeTrue(false, "no two span-level tags share a slot"); + return null; + } + + @Test + void partialSeedRefusesASourceWithAParentOrTombstones() { + DenseIdStore parent = denseSource(KnownTags.COMPONENT_ID); + DenseIdStore child = new DenseIdStore(parent); + child.store(KnownTags.SPAN_KIND_ID, "server"); + DenseIdStore target = new DenseIdStore(null); + target.store(KnownTags.SPAN_KIND_ID, "client"); // so the all-at-once copy can't take it + + assertFalse(target.seedPartial(child, target, TagStoresTest::put), "read-through source"); + assertEquals(1, target.size(), "nothing copied"); + } + + @Test + void aTraceLevelTagDoesNotDefeatADenseSpanLevelMissInTheSameSlot() { + // A local root span holds trace-level and span-level tags in one store, and the two levels are + // colored independently, so their slot numbers overlap. + DenseIdStore ids = new DenseIdStore(null); + ids.store(KnownTags.RUNTIME_ID, "abc"); + ids.store(KnownTags.HTTP_ROUTE_ID, "/a"); + long runtimeIdBit = DenseIdStore.slotBit(KnownTags.RUNTIME_ID); + assertNotEquals(runtimeIdBit, DenseIdStore.slotBit(KnownTags.HTTP_ROUTE_ID), "precondition"); + + assertTrue(ids.mayHaveLocal(runtimeIdBit, DenseIdStore.TRACE_LEVEL)); + assertFalse(ids.mayHaveLocal(runtimeIdBit, DenseIdStore.SPAN_LEVEL)); + } + + @Test + void aDenseRemovalOfAnInheritedTagMarksATombstone() { + DenseIdStore parent = new DenseIdStore(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/a"); + DenseIdStore child = new DenseIdStore(parent); + assertEquals(0L, child.tombstoneMask); + + child.remove(KnownTags.HTTP_ROUTE_ID); + assertNotEquals(0L, child.tombstoneMask); + assertNull(child.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals(0, child.computeSize()); + } + + @Test + void anUnknownIdIsRejected() { + HashIdStore ids = new HashIdStore(null); + assertThrows(IllegalArgumentException.class, () -> ids.getEntryReader(0L)); + assertThrows(IllegalArgumentException.class, () -> ids.remove(0L)); + } + + @Test + void theIdStoreSetsEveryValueShape() { + HashIdStore ids = new HashIdStore(null); + ids.store(KnownTags.COMPONENT_ID, (Object) new StringBuilder("jdbc")); + ids.store(KnownTags.HTTP_ROUTE_ID, "/a"); + ids.store(KnownTags.DD_PROFILING_ENABLED_ID, true); + ids.store(KnownTags.HTTP_STATUS_CODE_ID, 200); + ids.store(KnownTags.HTTP_RESEND_COUNT_ID, 2L); + ids.store(KnownTags.DB_POOL_NAME_ID, 1.5f); + ids.store(KnownTags.DB_INSTANCE_ID, 2.25d); + + TagMap checked = TagMap.create(); + checked.set(KnownTags.COMPONENT_ID, ids.getEntryReader(KnownTags.COMPONENT_ID).objectValue()); + checked.set(KnownTags.HTTP_ROUTE_ID, "/a"); + checked.set(KnownTags.DD_PROFILING_ENABLED_ID, true); + checked.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + checked.set(KnownTags.HTTP_RESEND_COUNT_ID, 2L); + checked.set(KnownTags.DB_POOL_NAME_ID, 1.5f); + checked.set(KnownTags.DB_INSTANCE_ID, 2.25d); + + Map actual = new HashMap<>(); + ids.fillMapLocal(actual); + assertEquals(new HashMap<>(checked), actual); + assertEquals(7, ids.size()); + assertEquals(7, ids.computeSize()); + assertTrue(ids.toInternalString().contains(KnownTags.HTTP_ROUTE_NAME)); + } + + @Test + void putAllIntoAStoreWithTombstonesPutsOneAtATime() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove(KnownTags.HTTP_ROUTE_NAME); // a tombstone + + TagMap source = TagMap.create(); + source.set(KnownTags.HTTP_ROUTE_ID, "/from-source"); + source.set(KnownTags.HTTP_METHOD_ID, "GET"); + child.putAll(source); + + assertEquals("/from-source", child.getString(KnownTags.HTTP_ROUTE_NAME)); + assertEquals(4, child.size()); // route, method, status code, inherited custom + child.checkIntegrity(); + } + + @Test + void aCopyKeepsTombstonesAndBorrowing() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove(KnownTags.HTTP_ROUTE_NAME); + + TagMap copy = child.copy(); + assertNull(copy.getString(KnownTags.HTTP_ROUTE_NAME)); + assertTrue(copy.nameStoreBorrowed); + assertEquals(child.size(), copy.size()); + + copy.set(KnownTags.HTTP_ROUTE_NAME, "/copy"); + assertNull(child.getString(KnownTags.HTTP_ROUTE_NAME)); // independent + } + + @Test + void anIteratorWalksBothStoresAndTheirParents() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set(KnownTags.HTTP_METHOD_ID, "GET"); + child.set("own.custom", "o"); + child.remove("inherited.custom"); + + Map seen = new HashMap<>(); + for (TagMap.EntryReader entry : child) { + seen.put(entry.tag(), entry.objectValue()); + } + Map expected = new HashMap<>(); + expected.put(KnownTags.HTTP_ROUTE_NAME, "/inherited"); + expected.put(KnownTags.HTTP_STATUS_CODE_NAME, 200); + expected.put(KnownTags.HTTP_METHOD_NAME, "GET"); + expected.put("own.custom", "o"); + assertEquals(expected, seen); + assertEquals(expected, new HashMap<>(child)); + } + + @Test + void routingReadsKnownTagsByIdAndEverythingElseByName() { + IdStore ids = IdStore.create(null); + HashStore names = HashStore.create(null); + // a direction-scoped OpenTelemetry name is resolved at the OpenTelemetry boundary, not here + assertEquals(0L, KnownTagCodec.keyOf("server.address")); + + ids.store(KnownTags.HTTP_ROUTE_ID, "/a"); + names.store("my.tag", "custom"); + + TagMap view = TagMap.viewOf(ids, names, false); + assertEquals("/a", view.getEntry(KnownTags.HTTP_ROUTE_NAME).stringValue()); + assertEquals("custom", view.getEntry("my.tag").stringValue()); + assertNull(TagMap.viewOf(ids, null, false).getEntry("my.tag")); // no custom store yet + } + + @Test + void anOwnerReadsThroughItsParentsStores() { + assertNull(TagMap.readThroughParent(null)); + assertThrows(IllegalStateException.class, () -> TagMap.readThroughParent(TagMap.create())); + assertNull(TagMap.readThroughParent(TagMap.create().freeze())); // empty parent dropped + + TagMap parent = TagMap.readThroughParent(frozenParent()); + IdStore ids = IdStore.create(parent); + HashStore borrowed = HashStore.inheritedFrom(parent); + assertSame(parent.nameStore, borrowed); + assertEquals("/inherited", ids.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals("c", borrowed.getEntry("inherited.custom").stringValue()); + + HashStore own = HashStore.create(borrowed); + own.store("own", "o"); + assertEquals("c", own.getEntry("inherited.custom").stringValue()); + assertNull(borrowed.getEntry("own")); + } + + @Test + void theCustomStoreSetsEveryValueShape() { + HashStore names = HashStore.create(null); + names.store("o", (Object) "obj"); + names.store("s", "v"); + names.store("b", true); + names.store("i", 1); + names.store("l", 2L); + names.store("f", 3f); + names.store("d", 4d); + assertNull(names.getAndPut(TagMap.Entry.create("p", "x"))); + assertEquals("x", names.put(TagMap.Entry.create("p", "y")).stringValue()); + + assertEquals(true, names.getEntry("b").objectValue()); + assertEquals(4d, names.getEntry("d").objectValue()); + assertEquals(8, names.size()); + } + + @Test + void aViewWritesThroughToItsOwnersStoresAndHandsBackANewCustomStore() { + TagMap parent = TagMap.readThroughParent(frozenParent()); + IdStore ids = IdStore.create(parent); + HashStore borrowed = HashStore.inheritedFrom(parent); + TagMap view = TagMap.viewOf(ids, borrowed, true); + assertSame(borrowed, view.customStore()); + assertTrue(view.isCustomStoreBorrowed()); + + view.set(KnownTags.HTTP_METHOD_NAME, "GET"); + view.set("own", "o"); + assertEquals("GET", ids.load(KnownTags.HTTP_METHOD_ID)); + assertFalse(view.isCustomStoreBorrowed()); + assertNotSame(borrowed, view.customStore()); + assertEquals("o", view.customStore().getEntry("own").stringValue()); + } + + @Test + void clearDetachesBothStores() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("own", "o"); + child.clear(); + assertTrue(child.isEmpty()); + assertNull(child.nameStore); + assertNull(child.getString("inherited.custom")); + assertTrue(child.toInternalString().contains("nameStore:")); + } + + /** Every known tag's real id, from the registry. */ + private static java.util.List knownIds() { + java.util.List ids = new java.util.ArrayList<>(); + for (int serial = 1; serial < KnownTagCodec.serialLimit(); serial++) { + long id = KnownTagCodec.keyOf(KnownTagCodec.nameOf(KnownTagCodec.makeTagId(serial))); + if (id > 0) { + ids.add(id); + } + } + return ids; + } + + /** + * A three-level chain with enough tags in each store to fill bucket groups, checked against a + * HashMap model through every read path: lookups, size, emptiness, iteration and forEach. + */ + @Test + void aDeepChainReadsLikeItsModel() { + java.util.List ids = knownIds(); + Map model = new HashMap<>(); + + TagMap grandparent = TagMap.create(); + for (int i = 0; i < ids.size(); i += 2) { + grandparent.set(ids.get(i), "gp" + i); + model.put(KnownTagCodec.nameOf(ids.get(i)), "gp" + i); + } + for (int i = 0; i < 40; i++) { + grandparent.set("custom." + i, "gp" + i); + model.put("custom." + i, "gp" + i); + } + grandparent.freeze(); + + TagMap parent = TagMap.createFromParent(grandparent); + for (int i = 1; i < ids.size(); i += 4) { + parent.set(ids.get(i), "p" + i); + model.put(KnownTagCodec.nameOf(ids.get(i)), "p" + i); + } + for (int i = 0; i < 40; i += 3) { + parent.set("custom." + i, "p" + i); + model.put("custom." + i, "p" + i); + } + parent.remove(KnownTagCodec.nameOf(ids.get(0))); // tombstone a grandparent known tag + model.remove(KnownTagCodec.nameOf(ids.get(0))); + parent.remove("custom.1"); + model.remove("custom.1"); + parent.freeze(); + + TagMap child = TagMap.createFromParent(parent); + for (int i = 0; i < ids.size(); i += 3) { + child.set(ids.get(i), "c" + i); + model.put(KnownTagCodec.nameOf(ids.get(i)), "c" + i); + } + for (int i = 2; i < ids.size(); i += 5) { + String name = KnownTagCodec.nameOf(ids.get(i)); + child.remove(name); + model.remove(name); + } + for (int i = 0; i < 40; i += 4) { + child.set("custom." + i, "c" + i); + model.put("custom." + i, "c" + i); + } + child.remove("custom.2"); + model.remove("custom.2"); + + assertEquals(model.size(), child.size()); + assertFalse(child.isEmpty()); + assertFalse(child.isDefinitelyEmpty()); + assertTrue(child.estimateSize() >= model.size()); + assertEquals(model, new HashMap<>(child)); + for (Map.Entry entry : model.entrySet()) { + assertEquals(entry.getValue(), child.getObject(entry.getKey()), entry.getKey()); + } + + Map viaConsumer = new HashMap<>(); + child.forEach(entry -> viaConsumer.put(entry.tag(), entry.objectValue())); + assertEquals(model, viaConsumer); + Map viaBi = new HashMap<>(); + child.forEach(viaBi, (m, entry) -> m.put(entry.tag(), entry.objectValue())); + assertEquals(model, viaBi); + Map viaTri = new HashMap<>(); + child.forEach(viaTri, "", (m, unused, entry) -> m.put(entry.tag(), entry.objectValue())); + assertEquals(model, viaTri); + Map filled = new HashMap<>(); + child.fillMap(filled); + assertEquals(model, filled); + + TagMap flat = TagMap.create(); + flat.set("custom.0", "flat"); + flat.set(ids.get(0), "flat"); + flat.putAll(child); // a read-through source: copied as its visible union + assertEquals(model, new HashMap<>(flat)); + flat.checkIntegrity(); + } + + @Test + void aChildThatHidesEverythingInheritedIsEmpty() { + TagMap parent = TagMap.create(); + parent.set(KnownTags.HTTP_ROUTE_ID, "/a"); + parent.set("c", "x"); + TagMap child = TagMap.createFromParent(parent.freeze()); + assertFalse(child.isEmpty()); + + child.remove(KnownTags.HTTP_ROUTE_NAME); + assertFalse(child.isEmpty()); + child.remove("c"); + assertTrue(child.isEmpty()); + assertEquals(0, child.size()); + assertTrue(child.isDefinitelyEmpty() == false); // the parent still holds local entries + } + + @Test + void removingALocalOverrideOfAnInheritedTagReturnsTheLocalValue() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set(KnownTags.HTTP_ROUTE_ID, "/local"); + child.set("inherited.custom", "local"); + + assertEquals("/local", child.getAndRemove(KnownTags.HTTP_ROUTE_ID).stringValue()); + assertEquals("local", child.getAndRemove("inherited.custom").stringValue()); + assertNull(child.getString(KnownTags.HTTP_ROUTE_NAME)); + assertNull(child.getString("inherited.custom")); + } + + @Test + void putAllMergesIntoNonEmptyStores() { + TagMap dest = TagMap.create(); + dest.set(KnownTags.HTTP_ROUTE_ID, "/dest"); + dest.set("d", "dest"); + TagMap source = TagMap.create(); + source.set(KnownTags.HTTP_METHOD_ID, "GET"); + source.set(KnownTags.HTTP_ROUTE_ID, "/source"); + source.set("s", "source"); + + dest.putAll(source); + + assertEquals("/source", dest.getString(KnownTags.HTTP_ROUTE_NAME)); + assertEquals("GET", dest.getString(KnownTags.HTTP_METHOD_NAME)); + assertEquals("source", dest.getString("s")); + assertEquals("dest", dest.getString("d")); + assertEquals(4, dest.size()); + dest.checkIntegrity(); + } + + @Test + void aCustomStoreRejectsAKnownTagsName() { + HashStore names = new HashStore(null); + assertThrows(AssertionError.class, () -> names.getEntry(KnownTags.HTTP_ROUTE_NAME)); + assertThrows(AssertionError.class, () -> names.remove(KnownTags.HTTP_ROUTE_NAME)); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/endpoint/EndpointResolverTest.java b/internal-api/src/test/java/datadog/trace/api/endpoint/EndpointResolverTest.java index 10d6ad5b940..eeec260b9eb 100644 --- a/internal-api/src/test/java/datadog/trace/api/endpoint/EndpointResolverTest.java +++ b/internal-api/src/test/java/datadog/trace/api/endpoint/EndpointResolverTest.java @@ -6,8 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.HashMap; -import java.util.Map; +import datadog.trace.api.TagMap; import org.junit.jupiter.api.Test; import org.tabletest.junit.TableTest; @@ -47,7 +46,7 @@ void computeEndpointDelegatesToEndpointSimplifier(String input, String expected) @Test void disabledResolverReturnsNull() { EndpointResolver resolver = new EndpointResolver(false, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, "/users", "http://example.com/users/123"); @@ -59,7 +58,7 @@ void disabledResolverReturnsNull() { @Test void alwaysSimplifiedEndpointComputesFromUrlEvenWithValidRoute() { EndpointResolver resolver = new EndpointResolver(true, true); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, "/users", "http://example.com/users/123"); @@ -71,7 +70,7 @@ void alwaysSimplifiedEndpointComputesFromUrlEvenWithValidRoute() { @Test void usesEligibleRouteWithoutTagging() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, "/api/v1/users", "http://example.com/users/123"); @@ -91,7 +90,7 @@ void usesEligibleRouteWithoutTagging() { void computesEndpointAndTagsWhenRouteIsIneligible( String ineligibleRoute, String url, String expectedEndpoint) { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, ineligibleRoute, url); @@ -115,7 +114,7 @@ void isAlwaysSimplifiedEndpointReturnsConfigurationValue() { @Test void realWorldScenarioServiceWithRoute() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); // framework provides accurate route String result = @@ -132,7 +131,7 @@ void realWorldScenarioServiceWithRoute() { @Test void realWorldScenarioProxyWithoutRoute() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); // no route available (tracer in proxy) String result = @@ -147,7 +146,7 @@ void realWorldScenarioProxyWithoutRoute() { @Test void realWorldScenarioBadInstrumentationWithCatchAll() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); // instrumentation provides unhelpful catch-all route String result = @@ -161,7 +160,7 @@ void realWorldScenarioBadInstrumentationWithCatchAll() { @Test void realWorldScenarioTestingWithAlwaysSimplified() { EndpointResolver resolver = new EndpointResolver(true, true); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); // testing mode to compare with backend inference String result = @@ -176,7 +175,7 @@ void realWorldScenarioTestingWithAlwaysSimplified() { @Test void edgeCaseMalformedUrlReturnsDefaultEndpoint() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, null, "not-a-url"); @@ -189,7 +188,7 @@ void edgeCaseMalformedUrlReturnsDefaultEndpoint() { @Test void edgeCaseBothRouteAndUrlMissing() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, null, null); diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/FrozenTagsTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/FrozenTagsTest.java new file mode 100644 index 00000000000..8318f587adb --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/FrozenTagsTest.java @@ -0,0 +1,141 @@ +package datadog.trace.bootstrap.instrumentation.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 static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import datadog.trace.api.KnownTags; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +class FrozenTagsTest { + + @Test + void recordsTheWritesFrozen() { + TagExtractor extractor = + (ip, span) -> { + span.setTag(KnownTags.PEER_IPV4_ID, ip); + span.setTag(Tags.PEER_PORT, 5432); + span.setTag("custom.tag", true); + }; + FrozenTags frozen = FrozenTags.of("10.0.0.1", extractor); + + assertTrue(frozen.tags().isFrozen()); + assertEquals(3, frozen.tags().size()); + assertEquals("10.0.0.1", frozen.tags().getObject(Tags.PEER_HOST_IPV4)); + assertEquals(5432, frozen.tags().getObject(Tags.PEER_PORT)); + assertEquals(true, frozen.tags().getObject("custom.tag")); + } + + @Test + void aLaterWriteOfTheSameTagWins() { + FrozenTags frozen = + FrozenTags.of( + (TagContributor) + span -> { + span.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + span.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.2"); + }); + + assertEquals(1, frozen.tags().size()); + assertEquals("10.0.0.2", frozen.tags().getObject(Tags.PEER_HOST_IPV4)); + } + + @Test + void aNullOrEmptyValueMakesTheTagsReplayOnly() { + // a null removes the tag and an empty value is stored, which copying the net result in + // wouldn't reproduce + FrozenTags removing = + FrozenTags.of( + (TagContributor) + span -> { + span.setTag(KnownTags.PEER_HOSTNAME_ID, (CharSequence) null); + span.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + }); + assertTrue(removing.isReplayOnly()); + assertEquals(1, removing.tags().size()); + assertNull(removing.tags().getObject(Tags.PEER_HOSTNAME)); + + assertTrue(FrozenTags.of((TagContributor) span -> span.setTag("custom", "")).isReplayOnly()); + assertFalse(FrozenTags.of((TagContributor) span -> span.setTag("custom", "x")).isReplayOnly()); + } + + @Test + void aRemovalAloneIsNotEmpty() { + FrozenTags frozen = + FrozenTags.of((TagContributor) span -> span.setTag(Tags.PEER_HOSTNAME, (Object) null)); + + assertFalse(frozen.isEmpty()); + assertTrue(frozen.tags().isEmpty()); + } + + @Test + void replayMakesTheWritesInOrderWithTheirOwnSetters() { + FrozenTags frozen = + FrozenTags.of( + (TagContributor) + span -> { + span.setTag("custom.b", 2); + span.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + span.setTag("custom.a", (Object) null); + span.setTag("custom.b", 3L); + }); + SpanData span = mock(SpanData.class); + + frozen.replay(span); + + InOrder order = inOrder(span); + order.verify(span).setTag("custom.b", 2); + order.verify(span).setTag(KnownTags.PEER_IPV4_ID, (CharSequence) "10.0.0.1"); + order.verify(span).setTag("custom.a", (Object) null); + order.verify(span).setTag("custom.b", 3L); + verifyNoMoreInteractions(span); + } + + @Test + void nothingRecordedIsEmpty() { + assertSame(FrozenTags.EMPTY, FrozenTags.of((TagContributor) span -> {})); + assertSame(FrozenTags.EMPTY, FrozenTags.of(null, (source, span) -> span.setTag("x", 1))); + assertSame(FrozenTags.EMPTY, FrozenTags.of((TagContributor) null)); + assertTrue(FrozenTags.EMPTY.isEmpty()); + } + + @Test + void readingTheSpanCannotBeFrozen() { + assertThrows( + UnsupportedOperationException.class, + () -> FrozenTags.of((TagContributor) span -> span.getTag(Tags.PEER_HOSTNAME))); + } + + @Test + void spanLevelStateCannotBeFrozen() { + assertThrows( + UnsupportedOperationException.class, + () -> FrozenTags.of((TagContributor) span -> span.setResourceName("r"))); + assertThrows( + UnsupportedOperationException.class, + () -> FrozenTags.of((TagContributor) span -> span.setError(true))); + assertThrows( + UnsupportedOperationException.class, + () -> FrozenTags.of((TagContributor) span -> span.setMeasured(true))); + } + + @Test + void theDefaultSetTagsFromReplaysTheTags() { + FrozenTags frozen = + FrozenTags.of("10.0.0.1", (ip, span) -> span.setTag(KnownTags.PEER_IPV4_ID, ip)); + AgentSpan span = mock(AgentSpan.class, CALLS_REAL_METHODS); + + span.setTagsFrom(frozen); + + verify(span).setTag(KnownTags.PEER_IPV4_ID, (CharSequence) "10.0.0.1"); + } +} diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/HttpUrlExtractorsTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/HttpUrlExtractorsTest.java index 3f89a0634b3..62cf8a2582c 100644 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/HttpUrlExtractorsTest.java +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/HttpUrlExtractorsTest.java @@ -36,7 +36,7 @@ @WithConfig(key = "http.client.tag.query-string", value = "true") @WithConfig(key = "http.server.tag.query-string", value = "true") class HttpUrlExtractorsTest { - private final AgentSpan span = mock(AgentSpan.class); + private final SpanData span = mock(SpanData.class); @TableTest({ "Scenario | Get first", @@ -129,7 +129,7 @@ void otelClientDecodesQuerySoTheObfuscatorCanMatchEncodedNames( }) void clientsPreserveEncodedQueryBoundaries(String rawQuery, String expectedQuery) { URI uri = URI.create("http://host/orders?" + rawQuery); - AgentSpan datadog = mock(AgentSpan.class); + SpanData datadog = mock(SpanData.class); DatadogClientUrl.INSTANCE.extract(uri, datadog); OtelClientUrl.INSTANCE.extract(uri, span); @@ -150,7 +150,7 @@ void serversPreserveEncodedQueryBoundaries(String rawQuery, String expectedQuery URI uri = URI.create("http://host/orders?" + rawQuery); HttpServerUrl url = HttpServerUrl.fromRequest(new URIDefaultDataAdapter(uri), uri.getPath(), null, null); - AgentSpan datadog = mock(AgentSpan.class); + SpanData datadog = mock(SpanData.class); DatadogServerUrl.INSTANCE.extract(url, datadog); OtelServerUrl.INSTANCE.extract(url, span); @@ -295,7 +295,7 @@ void serverNormalizesRequestPath(String path, String expectedPath) { void serverExtractsMalformedRequestUrl(String rawUrl, String expectedPath, String expectedQuery) { HttpServerUrl url = HttpServerUrl.fromRequest(new UnparseableURIDataAdapter(rawUrl), null, null, null); - AgentSpan datadog = mock(AgentSpan.class); + SpanData datadog = mock(SpanData.class); DatadogServerUrl.INSTANCE.extract(url, datadog); OtelServerUrl.INSTANCE.extract(url, span); @@ -378,7 +378,7 @@ void datadogServerKeepsBackendUrlAndOriginalForwardedHostname() { void transactionPreservesOriginalUrlAndResolvesOtelOrigin( String originalUrl, String host, String port, String expectedHost, int expectedPort) { HttpServerUrl url = HttpServerUrl.fromTransaction(originalUrl, host, port); - AgentSpan datadog = mock(AgentSpan.class); + SpanData datadog = mock(SpanData.class); DatadogServerUrl.URL_ONLY.extract(url, datadog); OtelServerUrl.INSTANCE.extract(url, span); @@ -411,7 +411,7 @@ void transactionRespectsQueryOptOut() { @Test void inferredProxySupportsBothUrlStyles() { HttpServerUrl url = HttpServerUrl.fromInferredProxy("api.host", "/a"); - AgentSpan datadog = mock(AgentSpan.class); + SpanData datadog = mock(SpanData.class); DatadogServerUrl.URL_ONLY.extract(url, datadog); OtelServerUrl.INSTANCE.extract(url, span); @@ -427,7 +427,7 @@ void inferredProxySupportsBothUrlStyles() { @NullAndEmptySource void inferredProxyOmitsUnknownOrigin(String domain) { HttpServerUrl url = HttpServerUrl.fromInferredProxy(domain, "/a"); - AgentSpan datadog = mock(AgentSpan.class); + SpanData datadog = mock(SpanData.class); DatadogServerUrl.URL_ONLY.extract(url, datadog); OtelServerUrl.INSTANCE.extract(url, span); diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemanticsTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemanticsTest.java index bfbf0a748fe..ea6ec759ffa 100644 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemanticsTest.java +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemanticsTest.java @@ -13,6 +13,8 @@ import datadog.trace.api.DDTags; import datadog.trace.api.KnownTags; +import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import java.net.URI; import java.util.HashMap; import java.util.Map; @@ -79,6 +81,26 @@ void urlOrPathFallsBackOnlyWhenUrlIsAbsent(String httpUrl, String path, String e assertEquals(expected, OtelHttpSemantics.urlOrPath(tags)); } + /** From a span's tags as stored, it answers as it does from the same tags as a map. */ + @TableTest({ + "Scenario | Http URL | URL path ", + "No URL or path | | ", + "URL only | https://example.com/full | ", + "Path only | | /orders ", + "Prefer URL | https://example.com/full | /different", + "Empty URL | '' | /orders ", + "Empty path | | '' " + }) + void urlOrPathFromTagStoreMatchesMap(String httpUrl, String path) { + TagMap tags = TagMap.create(); + tags.store(KnownTags.HTTP_URL_ID, httpUrl); + tags.store(KnownTags.URL_PATH_ID, path); + + assertEquals( + OtelHttpSemantics.urlOrPath((Map) tags), + OtelHttpSemantics.urlOrPath((TagStore) tags)); + } + @Test void urlOrPathDoesNotRenderLazyUrl() { URIUtils.LazyUrl url = URIUtils.lazyValidURL("https", "example.com", 443, "/orders"); diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java index e01ba8e8591..e01d0cc0971 100644 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java @@ -2,8 +2,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; import org.junit.jupiter.api.Test; @@ -177,4 +179,37 @@ void initTagNullEntryReaderIsNoOp() { assertTrue(proto.tags().isEmpty()); } + + @Test + void theSeedPlanIsNullUntilTheCoreRecordsOne() { + final SpanPrototype proto = SpanPrototype.builder().initOperationName("op").build(); + assertNull(proto.seedPlan()); + + final Object plan = new Object(); + proto.seedPlan(plan); + assertSame(plan, proto.seedPlan()); + } + + @Test + void theOwnOperationNamesHintIsResolvedOncePerLane() { + final SpanPrototype proto = SpanPrototype.builder().initOperationName("proto.op").build(); + + // the same name as a String or a UTF8BytesString resolves to the table's hint, then is cached + assertSame(SizingHint.forOperation("proto.op", true), proto.sizingHint(true, "proto.op")); + assertSame( + proto.sizingHint(true, "proto.op"), + proto.sizingHint(true, UTF8BytesString.create("proto.op"))); + assertSame(SizingHint.forOperation("proto.op", false), proto.sizingHint(false, "proto.op")); + } + + @Test + void anotherOperationNameGetsThatNamesHint() { + final SpanPrototype proto = SpanPrototype.builder().initOperationName("proto.op").build(); + assertSame(SizingHint.forOperation("other.op", true), proto.sizingHint(true, "other.op")); + assertSame(SizingHint.forOperation(null, false), proto.sizingHint(false, null)); + + // a prototype with no operation name of its own always asks the table + assertSame( + SizingHint.forOperation("other.op", true), SpanPrototype.NONE.sizingHint(true, "other.op")); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java index 56594c292fc..a28aa8d24b3 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java @@ -57,11 +57,11 @@ static final class CollidingKeyEntry extends FlatHashtable.D1.Entry growable(int initialCapacity) { - return FlatHashtable.D1.createGrowable(StringIntEntry.class, initialCapacity); + return FlatHashtable.D1.createGrowable(initialCapacity, StringIntEntry[]::new); } private static FlatHashtable.D1 fixed(int maxCapacity) { - return FlatHashtable.D1.createFixed(StringIntEntry.class, maxCapacity); + return FlatHashtable.D1.createFixed(maxCapacity, StringIntEntry[]::new); } @Test @@ -171,7 +171,7 @@ void nullKeyIsPermittedAndDistinctFromAbsent() { void hashCollisionsResolveByEquality() { // Two distinct keys with the same hashCode: the probe must distinguish them via matches(). FlatHashtable.D1 table = - FlatHashtable.D1.createGrowable(CollidingKeyEntry.class, 4); + FlatHashtable.D1.createGrowable(4, CollidingKeyEntry[]::new); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 100); diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java index 948501185cd..8dcc9fa17e0 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java @@ -57,11 +57,11 @@ static final class CollidingPairEntry extends FlatHashtable.D2.Entry growable(int initialCapacity) { - return FlatHashtable.D2.createGrowable(PairEntry.class, initialCapacity); + return FlatHashtable.D2.createGrowable(initialCapacity, PairEntry[]::new); } private static FlatHashtable.D2 fixed(int maxCapacity) { - return FlatHashtable.D2.createFixed(PairEntry.class, maxCapacity); + return FlatHashtable.D2.createFixed(maxCapacity, PairEntry[]::new); } @Test @@ -260,7 +260,7 @@ void fixedInsertReturnsFalseWhenFull() { @Test void hashCollisionsResolveByKeyEquality() { FlatHashtable.D2 table = - FlatHashtable.D2.createGrowable(CollidingPairEntry.class, 8); + FlatHashtable.D2.createGrowable(8, CollidingPairEntry[]::new); CollidingKey key1 = new CollidingKey("a", 7); CollidingKey key2 = new CollidingKey("x", 3); CollidingPairEntry e = new CollidingPairEntry(key1, key2, 100); diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java index 0ecf3712d3f..0b23c758a95 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java @@ -162,6 +162,17 @@ public long hashOf(TestEntry entry) { } } + @Test + void tablesKeepThePreciseEntryArrayTypeThroughGrowth() { + // the array constructor mints a real TestHashedEntry[], not an Object[] or a base-type array + TestHashedEntry[] table = FlatHashtable.create(1, TestHashedEntry[]::new); + assertSame(TestHashedEntry[].class, table.getClass()); + + TestHashedEntry[] grown = FlatHashtable.resize(table, TestHashedEntry[]::new); + assertSame(TestHashedEntry[].class, grown.getClass()); + assertEquals(table.length * 2, grown.length); + } + @Test void capacityFor_roundsToPowerOfTwoAtLeastTwiceLimit() { assertEquals(2, FlatHashtable.capacityFor(1)); @@ -177,14 +188,14 @@ void capacityFor_rejectsNonPositive() { @Test void create_allocatesTypedTableOfCapacity() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry[] table = FlatHashtable.create(4, TestEntry[]::new); assertEquals(8, table.length); assertEquals(TestEntry.class, table.getClass().getComponentType()); } @Test void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + TestEntry[] table = FlatHashtable.create(8, TestEntry[]::new); TestEntry first = FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); assertEquals("a", first.key); // A second call must return the SAME instance, not mint a new one. @@ -194,7 +205,7 @@ void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { @Test void get_returnsNullForAbsentKey() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + TestEntry[] table = FlatHashtable.create(8, TestEntry[]::new); assertNull(FlatHashtable.get(table, "missing", TestEntryStrategy.INSTANCE)); FlatHashtable.tryGetOrCreate(table, "present", TestEntryStrategy.INSTANCE, CREATE); assertNull(FlatHashtable.get(table, "still-missing", TestEntryStrategy.INSTANCE)); @@ -203,7 +214,7 @@ void get_returnsNullForAbsentKey() { @Test void getOrCreate_returnsNullWhenTableIsFull() { // capacityFor(1) == 2 slots. - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); + TestEntry[] table = FlatHashtable.create(1, TestEntry[]::new); assertTrue( FlatHashtable.tryGetOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE) != null); assertTrue( @@ -226,7 +237,7 @@ void hashKey_isStableForEqualKeys() { @Test void collision_probesPastOccupiedSlots_andResolvesEach() { // 8 slots; COLLIDING sends all to slot 0 - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry[] table = FlatHashtable.create(4, TestEntry[]::new); TestEntry a = FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); // slot 0 taken -> 1 TestEntry b = FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); @@ -251,7 +262,7 @@ void collision_probesPastOccupiedSlots_andResolvesEach() { @Test void collision_probeWrapsAroundToFront() { // 2 slots (0,1), mask=1; LAST_SLOT starts at 1 - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); + TestEntry[] table = FlatHashtable.create(1, TestEntry[]::new); // -> slot 1 TestEntry k0 = FlatHashtable.tryGetOrCreate(table, "k0", TestLastSlotStrategy.INSTANCE, CREATE); // taken -> wraps to 0 @@ -265,7 +276,7 @@ void collision_probeWrapsAroundToFront() { @Test void get_returnsNullWhenTableFullAndKeyAbsent() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots + TestEntry[] table = FlatHashtable.create(1, TestEntry[]::new); // 2 slots FlatHashtable.tryGetOrCreate(table, "k0", TestCollidingStrategy.INSTANCE, CREATE); // fills slots 0 and 1 FlatHashtable.tryGetOrCreate(table, "k1", TestCollidingStrategy.INSTANCE, CREATE); @@ -276,7 +287,7 @@ void get_returnsNullWhenTableFullAndKeyAbsent() { @Test void insert_generalFlavor_placesViaHashOfAndResolves() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + TestEntry[] table = FlatHashtable.create(8, TestEntry[]::new); TestEntry e = new TestEntry("a"); // flavor 2: the home comes from TestEntryStrategy.INSTANCE.hashOf(e) assertTrue(FlatHashtable.insert(table, e, TestEntryStrategy.INSTANCE)); @@ -285,7 +296,7 @@ void insert_generalFlavor_placesViaHashOfAndResolves() { @Test void insert_entryFlavor_placesViaCachedHashAndResolves() { - TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 8); + TestHashedEntry[] table = FlatHashtable.create(8, TestHashedEntry[]::new); TestHashedEntry e = new TestHashedEntry("a"); // flavor 1: the home comes from the Entry's own cached hash, no strategy needed assertTrue(FlatHashtable.insert(table, e)); @@ -294,7 +305,7 @@ void insert_entryFlavor_placesViaCachedHashAndResolves() { @Test void insert_returnsFalseWhenFull() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots + TestEntry[] table = FlatHashtable.create(1, TestEntry[]::new); // 2 slots assertTrue(FlatHashtable.insert(table, new TestEntry("k0"), TestEntryStrategy.INSTANCE)); assertTrue(FlatHashtable.insert(table, new TestEntry("k1"), TestEntryStrategy.INSTANCE)); // no room @@ -303,7 +314,7 @@ void insert_returnsFalseWhenFull() { @Test void forEach_visitsEveryEntry() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + TestEntry[] table = FlatHashtable.create(8, TestEntry[]::new); FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); FlatHashtable.tryGetOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); FlatHashtable.tryGetOrCreate(table, "c", TestEntryStrategy.INSTANCE, CREATE); @@ -315,7 +326,7 @@ void forEach_visitsEveryEntry() { @Test void forEach_contextVariant_passesContextWithoutCapture() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + TestEntry[] table = FlatHashtable.create(8, TestEntry[]::new); FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); FlatHashtable.tryGetOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); @@ -326,7 +337,7 @@ void forEach_contextVariant_passesContextWithoutCapture() { @Test void iterator_yieldsEveryEntrySharingTheHash() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // COLLIDING sends all to slot 0 + TestEntry[] table = FlatHashtable.create(4, TestEntry[]::new); // COLLIDING sends all to slot 0 TestEntry a = FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); TestEntry b = FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); TestEntry c = FlatHashtable.tryGetOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); @@ -341,7 +352,7 @@ void iterator_yieldsEveryEntrySharingTheHash() { @Test void iterator_filtersOutEntriesWithADifferentHash() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // entries at slot 0, hashOf == 0 + TestEntry[] table = FlatHashtable.create(4, TestEntry[]::new); // entries at slot 0, hashOf == 0 FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); @@ -354,7 +365,7 @@ void iterator_filtersOutEntriesWithADifferentHash() { @Test void iterator_emptyRunHasNoNext() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry[] table = FlatHashtable.create(4, TestEntry[]::new); Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); assertFalse(it.hasNext()); assertThrows(NoSuchElementException.class, it::next); @@ -364,7 +375,7 @@ void iterator_emptyRunHasNoNext() { void iterator_fullTable_yieldsMatchesIncludingTheWrappingSlot() { // 2 slots, both filled by colliding (hash 0) entries -> the probe has no empty slot to stop at, // so the traversal must yield the entry on the wrapping slot and then terminate on wrap. - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); + TestEntry[] table = FlatHashtable.create(1, TestEntry[]::new); TestEntry a = FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); TestEntry b = FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); @@ -380,7 +391,7 @@ void iterator_fullTable_yieldsMatchesIncludingTheWrappingSlot() { void iterator_fullTable_absentHash_terminatesOnWrap() { // Full table, iterating a hash no stored entry has (all hashOf == 0) -> the traversal walks // every slot and wraps without ever hitting an empty one, then reports no elements. - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); + TestEntry[] table = FlatHashtable.create(1, TestEntry[]::new); FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); @@ -431,18 +442,18 @@ void grownLength_rejectsOverflowingCurrentLength() { @Test void create_honorsLoadFactor() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4, FlatHashtable.LOW_LOAD_FACTOR); + TestEntry[] table = FlatHashtable.create(4, FlatHashtable.LOW_LOAD_FACTOR, TestEntry[]::new); assertEquals(16, table.length); } @Test void resize_generalFlavor_growsAndKeepsEveryEntryFindable() { // 8 slots with only 2 entries -> the rehash loop skips empty slots as well as copying entries. - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry[] table = FlatHashtable.create(4, TestEntry[]::new); FlatHashtable.insert(table, new TestEntry("a"), TestEntryStrategy.INSTANCE); FlatHashtable.insert(table, new TestEntry("b"), TestEntryStrategy.INSTANCE); - TestEntry[] grown = FlatHashtable.resize(table, TestEntryStrategy.INSTANCE); + TestEntry[] grown = FlatHashtable.resize(table, TestEntryStrategy.INSTANCE, TestEntry[]::new); assertEquals(16, grown.length); assertNotSame(table, grown); assertEquals("a", FlatHashtable.get(grown, "a", TestEntryStrategy.INSTANCE).key); @@ -451,11 +462,11 @@ void resize_generalFlavor_growsAndKeepsEveryEntryFindable() { @Test void resize_entryFlavor_growsAndKeepsEveryEntryFindable() { - TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 4); // 8 slots, 2 entries + TestHashedEntry[] table = FlatHashtable.create(4, TestHashedEntry[]::new); // 8 slots, 2 entries FlatHashtable.insert(table, new TestHashedEntry("a")); FlatHashtable.insert(table, new TestHashedEntry("b")); - TestHashedEntry[] grown = FlatHashtable.resize(table); + TestHashedEntry[] grown = FlatHashtable.resize(table, TestHashedEntry[]::new); assertEquals(16, grown.length); assertEquals("a", FlatHashtable.get(grown, "a", TestHashedStrategy.INSTANCE).key); assertEquals("b", FlatHashtable.get(grown, "b", TestHashedStrategy.INSTANCE).key); @@ -463,10 +474,11 @@ void resize_entryFlavor_growsAndKeepsEveryEntryFindable() { @Test void resizingInsert_generalFlavor_growsPastCapacityAndReturnsTheTable() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots + TestEntry[] table = FlatHashtable.create(1, TestEntry[]::new); // 2 slots for (int i = 0; i < 10; ++i) { table = - FlatHashtable.resizingInsert(table, new TestEntry("k" + i), TestEntryStrategy.INSTANCE); + FlatHashtable.resizingInsert( + table, new TestEntry("k" + i), TestEntryStrategy.INSTANCE, TestEntry[]::new); } assertTrue(table.length >= 16); // grew from 2 to hold 10 at load factor <= 0.5 for (int i = 0; i < 10; ++i) { @@ -476,9 +488,10 @@ void resizingInsert_generalFlavor_growsPastCapacityAndReturnsTheTable() { @Test void resizingInsert_entryFlavor_growsPastCapacityAndReturnsTheTable() { - TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 1); // 2 slots + TestHashedEntry[] table = FlatHashtable.create(1, TestHashedEntry[]::new); // 2 slots for (int i = 0; i < 10; ++i) { - table = FlatHashtable.resizingInsert(table, new TestHashedEntry("k" + i)); + table = + FlatHashtable.resizingInsert(table, new TestHashedEntry("k" + i), TestHashedEntry[]::new); } assertTrue(table.length >= 16); for (int i = 0; i < 10; ++i) { @@ -498,7 +511,7 @@ static final class TestHashEntry extends FlatHashtable.Entry { @Test void entryIterator_yieldsEveryEntrySharingTheHash() { - TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4); + TestHashEntry[] table = FlatHashtable.create(4, TestHashEntry[]::new); TestHashEntry a = new TestHashEntry("a", 0); TestHashEntry b = new TestHashEntry("b", 0); TestHashEntry c = new TestHashEntry("c", 0); @@ -517,7 +530,7 @@ void entryIterator_yieldsEveryEntrySharingTheHash() { @Test void entryIterator_filtersOutEntriesWithADifferentHash() { - TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4); // 8 slots, mask 7 + TestHashEntry[] table = FlatHashtable.create(4, TestHashEntry[]::new); // 8 slots, mask 7 FlatHashtable.insert(table, new TestHashEntry("a", 0)); FlatHashtable.insert(table, new TestHashEntry("b", 0)); // shares the home slot (0) but a different hash -> must be filtered out @@ -535,7 +548,7 @@ void entryIterator_filtersOutEntriesWithADifferentHash() { @Test void entryIterator_emptyRunHasNoNext() { - TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4); + TestHashEntry[] table = FlatHashtable.create(4, TestHashEntry[]::new); Iterator it = FlatHashtable.iterator(table, 0); assertFalse(it.hasNext()); assertThrows(NoSuchElementException.class, it::next); @@ -543,7 +556,7 @@ void entryIterator_emptyRunHasNoNext() { @Test void caseInsensitiveStrategy_matchesRegardlessOfCase() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry[] table = FlatHashtable.create(4, TestEntry[]::new); TestEntry stored = FlatHashtable.tryGetOrCreate( table, "Content-Type", TestCaseInsensitiveStrategy.INSTANCE, CREATE); @@ -575,7 +588,7 @@ void caseInsensitiveStrategy_matchesRegardlessOfCase() { void caseInsensitiveStrategy_doesNotFalseMissOnSupplementaryCasePair() { String s1 = new String(Character.toChars(0x10400)); // DESERET CAPITAL LETTER LONG I String s2 = new String(Character.toChars(0x10428)); // DESERET SMALL LETTER LONG I - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry[] table = FlatHashtable.create(4, TestEntry[]::new); TestEntry stored = FlatHashtable.tryGetOrCreate(table, s1, TestCaseInsensitiveStrategy.INSTANCE, CREATE); diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 4b6663c7fe0..316b513400c 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -5860,6 +5860,14 @@ "aliases": [] } ], + "DD_TRACE_EXPERIMENTAL_DENSE_TAGS_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "true", + "aliases": [] + } + ], "DD_TRACE_EXPERIMENTAL_FEATURES_ENABLED": [ { "version": "A", diff --git a/tag-conventions.yaml b/tag-conventions.yaml index cef70e92c0c..9b925e13dd0 100644 --- a/tag-conventions.yaml +++ b/tag-conventions.yaml @@ -88,6 +88,7 @@ trace_level: - { dd-name: _dd.trace_span_attribute_schema, type: int, required: required } - { dd-name: _dd.profiling.ctx, type: string, required: optional } # once the profiling context integration is available - { dd-name: process_id, type: long, required: required } + - { dd-name: _dd.agent_psr, type: double, required: optional } # the agent-provided sampling rate the sampler applied - { dd-name: ai_guard.event, type: boolean, required: optional } # when the trace holds an AI Guard evaluation span span_types: