From eabce403e8e17a8a3ea6022339e57b55a401971b Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 22 Oct 2026 09:31:13 -0400 Subject: [PATCH 01/39] Bucket known tags by slot in TagMap TagMap bucketed a known tag by folding its serial, so which tags shared one of the 16 buckets was luck, and each shared bucket costs a BucketGroup (48 B). Slot coloring already gives tags that appear together on a span type distinct slots, so bucketing a known tag by its slot keeps them in distinct buckets more often. Trace-level tags reuse span-level slot numbers, so the level bit moves them to the other half; known tags without a slot and custom tags keep their hashes. Co-Authored-By: Claude Opus 5.5 --- .../main/java/datadog/trace/api/TagMap.java | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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..0b42cb3f9d2 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -497,14 +497,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: + 8 + return ((int) (tagHash >>> 48) << 16) | (slot ^ level); + } int hash = (int) (tagHash >>> 48) ^ (int) tagHash; return hash == 0 ? 0xDD06 : hash; } From 19b2e886c3774ab874b8b07c7ac45925519ceb86 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 22 Oct 2026 09:43:10 -0400 Subject: [PATCH 02/39] Add whole-span and whole-trace allocation benchmarks SpanScenarioBenchmark builds spans and traces the way today's decorators do (prototype in afterStart, then request or connection tags by name): bare spans, a server span, the server span via the builder, a jdbc span, and a server trace with 1, 5 or 20 jdbc children. TraceShapeBenchmark builds one web-request-shaped trace three ways: every tag by name, the decorators' calls, and the newest span APIs. Both finish what they start, so allocation per op covers whole spans and traces. Single-span results can hide per-child costs that only the trace scenarios show. TraceShapeBenchmark is the same file #12918 adds. Co-Authored-By: Claude Opus 5.5 --- .../trace/core/SpanScenarioBenchmark.java | 219 ++++++++++++++ .../trace/core/TraceShapeBenchmark.java | 276 ++++++++++++++++++ 2 files changed, 495 insertions(+) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/SpanScenarioBenchmark.java create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanScenarioBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanScenarioBenchmark.java new file mode 100644 index 00000000000..5030e61a15c --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanScenarioBenchmark.java @@ -0,0 +1,219 @@ +package datadog.trace.core; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.context.ContextScope; +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 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; + +/** + * Span and trace scenarios built the way today's decorators build them: a {@link SpanPrototype} + * applied in {@code afterStart}, then the request or connection tags by name. Each op finishes what + * it starts, so allocation per op covers the whole span or trace. + * + *

+ * + *

Single-span and multi-span results can move in opposite directions: a change can make a + * trace's root cheaper while charging every child, so read the {@code webServerTrace} rows too. + * Values such as the client port and the status fall outside {@code Integer}'s box cache on + * purpose. + * + *

Passing {@code -jvmArgsAppend} on the command line replaces the {@code @Fork} arguments below, + * including {@code -DTEST_LOG_LEVEL=warn}; keep it, or debug logging dominates the allocation. + */ +@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 SpanScenarioBenchmark { + 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 String SERVER_RESOURCE_NAME = "GET /owners/{ownerId}"; + static final CharSequence SERVER_RESOURCE = UTF8BytesString.create(SERVER_RESOURCE_NAME); + static final int STATUS = 200; // outside the box cache + + 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 CharSequence STATEMENT = UTF8BytesString.create("SELECT * FROM owners WHERE id = ?"); + + static final String BARE_INSTRUMENTATION = "bench"; + static final CharSequence BARE_OPERATION = UTF8BytesString.create("bare.op"); + + /** The trace size, taken only by {@code webServerTrace} so the single-span scenarios run once. */ + @State(Scope.Benchmark) + public static class Shape { + @Param({"1", "5", "20"}) + int children; + } + + CoreTracer tracer; + SpanPrototype serverProto; + SpanPrototype jdbcProto; + + @Setup + public void setup(Blackhole blackhole) { + this.tracer = CoreTracer.builder().writer(new DropWriter(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(); + } + + @TearDown + public void tearDown() { + this.tracer.close(); + } + + @Benchmark + public void bareStartSpan() { + tracer.startSpan(BARE_INSTRUMENTATION, BARE_OPERATION).finish(); + } + + @Benchmark + public void bareBuildSpan() { + tracer.buildSpan(BARE_INSTRUMENTATION, BARE_OPERATION).start().finish(); + } + + @Benchmark + public void webServerSpan() { + AgentSpan server = startServer(); + server.setHttpStatusCode(STATUS); + server.finish(); + } + + @Benchmark + public void webServerSpanViaBuilder() { + AgentSpan server = + tracer + .buildSpan(SERVER_INSTRUMENTATION, SERVER_OPERATION) + .withSpanType(WEB) + .withTag(Tags.COMPONENT, SERVER_COMPONENT) + .withTag(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER) + .withTag("language", "jvm") + .withTag(Tags.HTTP_USER_AGENT, USER_AGENT) + .withTag(Tags.HTTP_METHOD, METHOD) + .withTag(Tags.HTTP_URL, URL) + .withTag(Tags.HTTP_HOSTNAME, HOSTNAME) + .withTag(Tags.PEER_HOST_IPV4, CLIENT_IP) + .withTag(Tags.PEER_PORT, CLIENT_PORT) + .withTag(Tags.HTTP_ROUTE, ROUTE) + .withResourceName(SERVER_RESOURCE_NAME) + .start(); + server.setHttpStatusCode(STATUS); + server.finish(); + } + + @Benchmark + public void jdbcClientSpan() { + jdbc().finish(); + } + + @Benchmark + public void webServerTrace(Shape shape) { + AgentSpan server = startServer(); + try (ContextScope serverScope = tracer.activateSpan(server)) { + for (int i = 0; i < shape.children; ++i) { + AgentSpan jdbc = jdbc(); + ContextScope jdbcScope = tracer.activateSpan(jdbc); + jdbcScope.close(); + jdbc.finish(); + } + } + server.setHttpStatusCode(STATUS); + server.finish(); + } + + /** HttpServerDecorator: startSpan, afterStart (prototype), onRequest, then the route. */ + AgentSpan startServer() { + AgentSpan server = tracer.startSpan(SERVER_INSTRUMENTATION, SERVER_OPERATION); + server.applyOverwriting(serverProto); + 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 + return server; + } + + /** + * JDBCDecorator over a postgres prepared statement: startSpan, afterStart (prototype), + * onConnection, onPreparedStatement. + */ + AgentSpan jdbc() { + AgentSpan jdbc = tracer.startSpan(JDBC_INSTRUMENTATION, JDBC_OPERATION); + jdbc.applyOverwriting(jdbcProto); + jdbc.setTag(Tags.DB_TYPE, "postgresql"); // processDatabaseType + jdbc.setServiceName("postgresql", JAVA_JDBC); // postProcessServiceAndOperationName + jdbc.setOperationName(JDBC_RENAMED_OPERATION); + jdbc.setTag(Tags.DB_POOL_NAME, "HikariPool-1"); + jdbc.setTag(Tags.DB_USER, "app"); // DatabaseClientDecorator.onConnection + jdbc.setTag(Tags.DB_INSTANCE, "petclinic"); + jdbc.setTag(Tags.PEER_HOSTNAME, "db.internal"); + jdbc.setResourceName(STATEMENT); // withQueryInfo + jdbc.setTag(Tags.DB_OPERATION, "SELECT"); + jdbc.spanContext().setIntegrationName(JDBC_PREPARED_STATEMENT); + jdbc.setTag(Tags.COMPONENT, JDBC_PREPARED_STATEMENT); + return jdbc; + } +} 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..5556fa26139 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java @@ -0,0 +1,276 @@ +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 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.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, five jdbc prepared-statement + * children and one {@code @Trace} internal span, all finished. Divide by 7 for per-span figures. + * + *

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

+ * + *

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). + */ +@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 { + static final int JDBC_SPANS = 5; + + // 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 DropWriter(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 < JDBC_SPANS; ++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 < JDBC_SPANS; ++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 < JDBC_SPANS; ++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(); + } + + /** 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(); + } +} From 6e65a3119af1bfc6b0f61355847ee5960499ae29 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 22 Oct 2026 11:40:30 -0400 Subject: [PATCH 03/39] Vary TraceShapeBenchmark's trace size; test bucketHash TraceShapeBenchmark takes the number of jdbc children (1, 5, 20), so each arm shows how a trace's cost grows with its span count: single-span and small-trace results can hide costs that land on every child span. It covers what SpanScenarioBenchmark's trace scenario added, and the rest of that benchmark overlapped SpanCreationBenchmark (same scenario names, different semantics), so SpanScenarioBenchmark goes. TagMapBucketHashTest pins down the bucket hash: known tags get distinct, nonzero hashes, a slotted tag lands in its slot's bucket (trace-level in the other half), and tags without a slot and custom tags keep their hashes. The inline comment now says the level bit flips bit 3. Co-Authored-By: Claude Opus 5.5 --- .../trace/core/SpanScenarioBenchmark.java | 219 ------------------ .../trace/core/TraceShapeBenchmark.java | 20 +- .../main/java/datadog/trace/api/TagMap.java | 2 +- .../trace/api/TagMapBucketHashTest.java | 67 ++++++ 4 files changed, 82 insertions(+), 226 deletions(-) delete mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/SpanScenarioBenchmark.java create mode 100644 internal-api/src/test/java/datadog/trace/api/TagMapBucketHashTest.java diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanScenarioBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanScenarioBenchmark.java deleted file mode 100644 index 5030e61a15c..00000000000 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanScenarioBenchmark.java +++ /dev/null @@ -1,219 +0,0 @@ -package datadog.trace.core; - -import static java.util.concurrent.TimeUnit.MICROSECONDS; - -import datadog.context.ContextScope; -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 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; - -/** - * Span and trace scenarios built the way today's decorators build them: a {@link SpanPrototype} - * applied in {@code afterStart}, then the request or connection tags by name. Each op finishes what - * it starts, so allocation per op covers the whole span or trace. - * - *

- * - *

Single-span and multi-span results can move in opposite directions: a change can make a - * trace's root cheaper while charging every child, so read the {@code webServerTrace} rows too. - * Values such as the client port and the status fall outside {@code Integer}'s box cache on - * purpose. - * - *

Passing {@code -jvmArgsAppend} on the command line replaces the {@code @Fork} arguments below, - * including {@code -DTEST_LOG_LEVEL=warn}; keep it, or debug logging dominates the allocation. - */ -@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 SpanScenarioBenchmark { - 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 String SERVER_RESOURCE_NAME = "GET /owners/{ownerId}"; - static final CharSequence SERVER_RESOURCE = UTF8BytesString.create(SERVER_RESOURCE_NAME); - static final int STATUS = 200; // outside the box cache - - 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 CharSequence STATEMENT = UTF8BytesString.create("SELECT * FROM owners WHERE id = ?"); - - static final String BARE_INSTRUMENTATION = "bench"; - static final CharSequence BARE_OPERATION = UTF8BytesString.create("bare.op"); - - /** The trace size, taken only by {@code webServerTrace} so the single-span scenarios run once. */ - @State(Scope.Benchmark) - public static class Shape { - @Param({"1", "5", "20"}) - int children; - } - - CoreTracer tracer; - SpanPrototype serverProto; - SpanPrototype jdbcProto; - - @Setup - public void setup(Blackhole blackhole) { - this.tracer = CoreTracer.builder().writer(new DropWriter(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(); - } - - @TearDown - public void tearDown() { - this.tracer.close(); - } - - @Benchmark - public void bareStartSpan() { - tracer.startSpan(BARE_INSTRUMENTATION, BARE_OPERATION).finish(); - } - - @Benchmark - public void bareBuildSpan() { - tracer.buildSpan(BARE_INSTRUMENTATION, BARE_OPERATION).start().finish(); - } - - @Benchmark - public void webServerSpan() { - AgentSpan server = startServer(); - server.setHttpStatusCode(STATUS); - server.finish(); - } - - @Benchmark - public void webServerSpanViaBuilder() { - AgentSpan server = - tracer - .buildSpan(SERVER_INSTRUMENTATION, SERVER_OPERATION) - .withSpanType(WEB) - .withTag(Tags.COMPONENT, SERVER_COMPONENT) - .withTag(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER) - .withTag("language", "jvm") - .withTag(Tags.HTTP_USER_AGENT, USER_AGENT) - .withTag(Tags.HTTP_METHOD, METHOD) - .withTag(Tags.HTTP_URL, URL) - .withTag(Tags.HTTP_HOSTNAME, HOSTNAME) - .withTag(Tags.PEER_HOST_IPV4, CLIENT_IP) - .withTag(Tags.PEER_PORT, CLIENT_PORT) - .withTag(Tags.HTTP_ROUTE, ROUTE) - .withResourceName(SERVER_RESOURCE_NAME) - .start(); - server.setHttpStatusCode(STATUS); - server.finish(); - } - - @Benchmark - public void jdbcClientSpan() { - jdbc().finish(); - } - - @Benchmark - public void webServerTrace(Shape shape) { - AgentSpan server = startServer(); - try (ContextScope serverScope = tracer.activateSpan(server)) { - for (int i = 0; i < shape.children; ++i) { - AgentSpan jdbc = jdbc(); - ContextScope jdbcScope = tracer.activateSpan(jdbc); - jdbcScope.close(); - jdbc.finish(); - } - } - server.setHttpStatusCode(STATUS); - server.finish(); - } - - /** HttpServerDecorator: startSpan, afterStart (prototype), onRequest, then the route. */ - AgentSpan startServer() { - AgentSpan server = tracer.startSpan(SERVER_INSTRUMENTATION, SERVER_OPERATION); - server.applyOverwriting(serverProto); - 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 - return server; - } - - /** - * JDBCDecorator over a postgres prepared statement: startSpan, afterStart (prototype), - * onConnection, onPreparedStatement. - */ - AgentSpan jdbc() { - AgentSpan jdbc = tracer.startSpan(JDBC_INSTRUMENTATION, JDBC_OPERATION); - jdbc.applyOverwriting(jdbcProto); - jdbc.setTag(Tags.DB_TYPE, "postgresql"); // processDatabaseType - jdbc.setServiceName("postgresql", JAVA_JDBC); // postProcessServiceAndOperationName - jdbc.setOperationName(JDBC_RENAMED_OPERATION); - jdbc.setTag(Tags.DB_POOL_NAME, "HikariPool-1"); - jdbc.setTag(Tags.DB_USER, "app"); // DatabaseClientDecorator.onConnection - jdbc.setTag(Tags.DB_INSTANCE, "petclinic"); - jdbc.setTag(Tags.PEER_HOSTNAME, "db.internal"); - jdbc.setResourceName(STATEMENT); // withQueryInfo - jdbc.setTag(Tags.DB_OPERATION, "SELECT"); - jdbc.spanContext().setIntegrationName(JDBC_PREPARED_STATEMENT); - jdbc.setTag(Tags.COMPONENT, JDBC_PREPARED_STATEMENT); - return jdbc; - } -} 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 index 5556fa26139..46e2887084e 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java @@ -14,6 +14,7 @@ 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; @@ -23,8 +24,13 @@ import org.openjdk.jmh.infra.Blackhole; /** - * One whole trace per op, shaped like a web request: a server root, five jdbc prepared-statement - * children and one {@code @Trace} internal span, all finished. Divide by 7 for per-span figures. + * 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: @@ -54,7 +60,9 @@ @OutputTimeUnit(MICROSECONDS) @Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn") public class TraceShapeBenchmark { - static final int JDBC_SPANS = 5; + /** 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"; @@ -146,7 +154,7 @@ public void legacy() { server.setTag("language", "jvm"); legacyRequest(server); try (ContextScope scope = tracer.activateSpan(server)) { - for (int i = 0; i < JDBC_SPANS; ++i) { + for (int i = 0; i < children; ++i) { AgentSpan jdbc = tracer.startSpan(JDBC_INSTRUMENTATION, JDBC_OPERATION); jdbc.setSpanType(SQL); jdbc.setTag(Tags.COMPONENT, JAVA_JDBC); @@ -203,7 +211,7 @@ public void normal() { 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 < JDBC_SPANS; ++i) { + for (int i = 0; i < children; ++i) { // AbstractPreparedStatementInstrumentation: startSpan, afterStart, onConnection, // onPreparedStatement AgentSpan jdbc = tracer.startSpan(JDBC_INSTRUMENTATION, JDBC_OPERATION); @@ -244,7 +252,7 @@ public void newApi() { 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 < JDBC_SPANS; ++i) { + 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); 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 0b42cb3f9d2..3e9fe756d88 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -515,7 +515,7 @@ static long customHash(String tag) { 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: + 8 + 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; 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)); + } +} From 2271e4f4fd011b0b56aa5fc70a1c99b673d53fbd Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 22 Oct 2026 17:50:26 -0400 Subject: [PATCH 04/39] Add a whole-request benchmark for the header code paths HeaderPathBenchmark runs one request per op through the propagation paths: extract the inbound headers with the default propagator, start the server span from what was extracted, run a few children, inject into an outbound call, finish everything. Inbound headers vary: none, Datadog, W3C (with dd, OpenTelemetry and third-party tracestate members), both, and both plus baggage. The existing propagation benchmarks each measure one step, so they can't see costs the extracted state causes later on every span; this one can. A setup check fails the run if nothing is extracted or injected. Co-Authored-By: Claude Opus 5.5 --- .../trace/core/HeaderPathBenchmark.java | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/HeaderPathBenchmark.java 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..d73d130d069 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/HeaderPathBenchmark.java @@ -0,0 +1,226 @@ +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, which would measure nothing. + */ + 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); + if (!outbound.headers.containsKey("x-datadog-trace-id")) { + throw new IllegalStateException("nothing injected: " + 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 = parent.with(server).attach()) { + 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(Context.current().with(client), outbound.headers, SETTER); + client.setHttpStatusCode(200); + client.finish(); + } + server.setHttpStatusCode(200); + server.finish(); + } +} From fa9b44184088a634d93cddab3f59bab28501f933 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 22 Oct 2026 23:17:19 -0400 Subject: [PATCH 05/39] Make a null tag value remove the tag in TagMap setTag(tag, null) already removes a tag from a span, but TagMap's own setters stored a null-valued entry. Internal writers that go straight to a TagMap could therefore leave null tags on a span, and the serializer sent them as a msgpack nil under meta. TagMap.put / set / getAndSet (by name or id, and putAll / fromMap through set) now remove the tag on a null value and return its prior value. A null Entry or reader is still a no-op. The one writer that relied on null tags was payload tagging: a JSON null in a request or response body produced a tag with a null value. It now produces no tag, so such a value also no longer counts toward the max-tags limit. Co-Authored-By: Claude Opus 5.5 --- .../tagprocessor/PayloadTagsProcessor.java | 2 +- .../PayloadTagsProcessorTest.java | 12 +--- .../main/java/datadog/trace/api/TagMap.java | 36 +++++++++--- .../trace/api/TagMapNullToleranceTest.java | 57 ++++++++++++++++++- 4 files changed, 88 insertions(+), 19 deletions(-) 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..860cff77d68 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,7 +245,7 @@ public void doubleValue(PathCursor path, double value) { @Override public void nullValue(PathCursor path) { - collectedTags.set(path.toString(tagPrefix), null); + // a JSON null sets no tag: on a span, a null value means remove } @Override 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..85e3dad0ebc 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", @@ -410,8 +406,6 @@ void applyRedactionRules() { 2, "p.j3.2", 3.14d, - "p.j3.3", - null, "p.j3.4", true, "p.j4.foo", @@ -444,8 +438,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 +468,6 @@ void respectMaxDepthLimit() { 2, "p.j3.2", 3.14d, - "p.j3.3", - null, "p.j3.4", true, "p.j3.5.0", 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..0fde50c117f 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1502,7 +1502,9 @@ boolean parentEntryVisible(Entry parentEntry, TagMap fromAncestor) { @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(); } @@ -1556,7 +1558,12 @@ static Entry doubleEntryFor(String tag, double value) { return tagId != 0 ? Entry.newDoubleEntry(tagId, value) : Entry.newDoubleEntry(tag, value); } - public void set(@Nonnull String tag, @Nonnull Object 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 tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { this.set(tagId, value); @@ -1565,7 +1572,12 @@ public void set(@Nonnull String tag, @Nonnull Object value) { } } - public void set(@Nonnull String tag, @Nonnull CharSequence 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 tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { this.set(tagId, value); @@ -1680,11 +1692,21 @@ public void setKnown(long tagId, double value) { } } - public void set(long tagId, @Nonnull Object value) { + /** 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.putEntry(Entry.newAnyEntry(tagId, value)); } - public void set(long tagId, @Nonnull CharSequence 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.putEntry(Entry.newObjectEntry(tagId, value)); } @@ -1797,11 +1819,11 @@ Entry putEntry(@Nonnull Entry newEntry) { } 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) { 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)); + } } From 8e57e8754adc99ddc8ef22940ea6acda8424e3f0 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 22 Oct 2026 22:26:36 -0400 Subject: [PATCH 06/39] Introduce SpanStorage for eager tag post-processors Eager post-processors run on the application thread as a span finishes. They now take a SpanStorage (a span's tags as stored) instead of a TagMap, and DDSpanContext implements SpanStorage directly, so no TagMap view needs to be built for them once span storage stops being a single TagMap. SpanStorage has its own verbs, load and store, apart from TagMap's get/set and the span's intercepted getTag/setTag, so DDSpanContext can offer the raw and the intercepted operations without a clash. Stores bypass interception; thread safety is left to each implementation (DDSpanContext's are unsynchronized, called under the tag lock). TagMap implements it too, through its own get and set, for standalone maps. This is the first slice: load, loadString and two stores. Co-Authored-By: Claude Opus 5.5 --- .../datadog/trace/core/DDSpanContext.java | 30 +++++++++++++++++-- .../tagprocessor/EagerPostProcessorChain.java | 24 +++++++++++++++ .../tagprocessor/EagerTagsPostProcessor.java | 21 +++++++++++++ .../HttpEndpointPostProcessor.java | 12 ++++---- .../core/tagprocessor/InternalTagsAdder.java | 11 +++---- .../tagprocessor/PeerServiceCalculator.java | 16 +++++----- .../TagsPostProcessorFactory.java | 10 +++---- .../java/datadog/trace/api/SpanStorage.java | 27 +++++++++++++++++ .../main/java/datadog/trace/api/TagMap.java | 25 +++++++++++++++- .../trace/api/endpoint/EndpointResolver.java | 9 +++--- .../trace/api/naming/NamingSchema.java | 4 +-- .../api/naming/v0/PeerServiceNamingV0.java | 4 +-- .../api/naming/v1/PeerServiceNamingV1.java | 21 ++++++------- .../api/endpoint/EndpointResolverTest.java | 23 +++++++------- 14 files changed, 179 insertions(+), 58 deletions(-) create mode 100644 dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerPostProcessorChain.java create mode 100644 dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerTagsPostProcessor.java create mode 100644 internal-api/src/main/java/datadog/trace/api/SpanStorage.java 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 1e236d3a120..8fdf11b8a85 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 @@ -18,6 +18,7 @@ import datadog.trace.api.KnownTagCodec; import datadog.trace.api.KnownTags; import datadog.trace.api.ProcessTags; +import datadog.trace.api.SpanStorage; import datadog.trace.api.TagMap; import datadog.trace.api.cache.DDCache; import datadog.trace.api.cache.DDCaches; @@ -68,7 +69,7 @@ * the associated Span instance */ public class DDSpanContext - implements AgentSpanContext, RequestContext, TraceSegment, ProfilerContext { + implements AgentSpanContext, RequestContext, TraceSegment, ProfilerContext, SpanStorage { private static final Logger log = LoggerFactory.getLogger(DDSpanContext.class); public static final String PRIORITY_SAMPLING_KEY = "_sampling_priority_v1"; @@ -1395,6 +1396,31 @@ Object getTag(final String key) { } } + /* + * SpanStorage: the span's tags, loaded and stored with no interception. + * Unsynchronized, like unsafeGetTag: callers hold the span's tag lock, as post-processing does. + */ + + @Override + public Object load(String tag) { + return unsafeTags.getObject(tag); + } + + @Override + public String loadString(String tag) { + return unsafeTags.getString(tag); + } + + @Override + public void store(String tag, Object value) { + unsafeTags.set(tag, value); + } + + @Override + public void store(TagMap.EntryReader entry) { + unsafeTags.set(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. @@ -1500,7 +1526,7 @@ public void setMetaStruct(final String field, final T value) { void earlyProcessTags(AppendableSpanLinks links) { synchronized (unsafeTags) { - TagsPostProcessorFactory.eagerProcessor().processTags(unsafeTags, this, links); + TagsPostProcessorFactory.eagerProcessor().processTags(this, this, links); } } 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..f5ac6765293 --- /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.SpanStorage; +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( + SpanStorage storage, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + for (final EagerTagsPostProcessor processor : chain) { + processor.processTags(storage, 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..54ace639c80 --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerTagsPostProcessor.java @@ -0,0 +1,21 @@ +package datadog.trace.core.tagprocessor; + +import datadog.trace.api.SpanStorage; +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 + * {@link SpanStorage} rather than a {@code TagMap}, so the span needn't build one for it. + */ +public abstract class EagerTagsPostProcessor { + /** + * Processes the span's tags. + * + * @param contents the span's contents 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( + SpanStorage storage, 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 c2e0dd72761..6e6f9cfd829 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 @@ -4,7 +4,7 @@ import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ROUTE; import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_URL; -import datadog.trace.api.TagMap; +import datadog.trace.api.SpanStorage; 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,19 +55,19 @@ public HttpEndpointPostProcessor() { @Override public void processTags( - TagMap unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + SpanStorage 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) == null) { return; } try { - String httpRoute = unsafeTags.getString(HTTP_ROUTE); - String httpUrl = unsafeTags.getString(HTTP_URL); + String httpRoute = unsafeTags.loadString(HTTP_ROUTE); + String httpUrl = unsafeTags.loadString(HTTP_URL); endpointResolver.resolveEndpoint(unsafeTags, httpRoute, httpUrl); } catch (Throwable t) { log.debug("Error processing HTTP endpoint for span {}", spanContext.getSpanId(), t); 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..afcf8c30438 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 @@ -3,6 +3,7 @@ import static datadog.trace.bootstrap.instrumentation.api.Tags.VERSION; import datadog.trace.api.DDTags; +import datadog.trace.api.SpanStorage; import datadog.trace.api.TagMap; import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; @@ -10,7 +11,7 @@ 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. @@ -28,7 +29,7 @@ public InternalTagsAdder(@Nonnull final String ddService, @Nullable final String @Override public void processTags( - TagMap unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + SpanStorage 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) == null) { + unsafeTags.store(versionEntry); } } } 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..8d5d0513962 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 @@ -2,7 +2,7 @@ import datadog.trace.api.Config; import datadog.trace.api.DDTags; -import datadog.trace.api.TagMap; +import datadog.trace.api.SpanStorage; import datadog.trace.api.internal.VisibleForTesting; import datadog.trace.api.naming.NamingSchema; import datadog.trace.api.naming.SpanNaming; @@ -12,7 +12,7 @@ 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 +34,8 @@ public PeerServiceCalculator() { @Override public void processTags( - TagMap unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { - Object peerService = unsafeTags.getObject(Tags.PEER_SERVICE); + SpanStorage unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + Object peerService = unsafeTags.load(Tags.PEER_SERVICE); // the user set it if (peerService != null) { if (canRemap) { @@ -46,18 +46,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(Tags.PEER_SERVICE) : 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(SpanStorage 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(Tags.PEER_SERVICE, mapped); + unsafeTags.store(DDTags.PEER_SERVICE_REMAPPED_FROM, 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/internal-api/src/main/java/datadog/trace/api/SpanStorage.java b/internal-api/src/main/java/datadog/trace/api/SpanStorage.java new file mode 100644 index 00000000000..09b2fab946d --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SpanStorage.java @@ -0,0 +1,27 @@ +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. 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. + * + *

    This is the first slice: only what the eager tag post-processors need. Id variants, typed + * loads and primitive stores come as more of the pipeline moves to it. + */ +public interface SpanStorage { + /** 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); + + /** Stores the tag; a null value removes it. */ + void store(String tag, 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/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index 296d52399a6..b4cc9997222 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -48,7 +48,8 @@ *

  • adaptive collision * */ -public final class TagMap implements Map, Iterable { +public final class TagMap + implements Map, Iterable, SpanStorage { /** 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 @@ -1346,6 +1347,28 @@ public String getString(String tag) { return entry == null ? null : entry.stringValue(); } + /* SpanStorage, 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 void store(String tag, Object value) { + this.set(tag, value); + } + + @Override + public void store(EntryReader entry) { + this.set(entry); + } + public boolean getBoolean(String tag) { return this.getBooleanOrDefault(tag, false); } 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..30114ca2372 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 @@ -2,6 +2,7 @@ import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ENDPOINT; +import datadog.trace.api.SpanStorage; 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) { + SpanStorage 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, 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, 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..b17b52bc58b 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.SpanStorage; 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 SpanStorage 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..d802622205a 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.SpanStorage; 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 SpanStorage 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..021dbda7aee 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,7 @@ package datadog.trace.api.naming.v1; import datadog.trace.api.DDTags; -import datadog.trace.api.TagMap; +import datadog.trace.api.SpanStorage; import datadog.trace.api.naming.NamingSchema; import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags; import datadog.trace.bootstrap.instrumentation.api.Tags; @@ -53,8 +53,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 SpanStorage unsafeTags) { + final Object component = unsafeTags.load(Tags.COMPONENT); // avoid issues with UTF8ByteString or others final String componentString = component == null ? null : component.toString(); final String override = overridesByComponent.get(componentString); @@ -71,14 +71,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 SpanStorage 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 +91,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 SpanStorage unsafeTags, Object value, String source) { if (value != null) { - unsafeTags.put(Tags.PEER_SERVICE, value); - unsafeTags.put(DDTags.PEER_SERVICE_SOURCE, source); + unsafeTags.store(Tags.PEER_SERVICE, value); + unsafeTags.store(DDTags.PEER_SERVICE_SOURCE, source); } } @Nonnull @Override - public void tags(@Nonnull final TagMap unsafeTags) { + public void tags(@Nonnull final SpanStorage unsafeTags) { // check span.kind eligibility - final Object kind = unsafeTags.getObject(Tags.SPAN_KIND); + final Object kind = unsafeTags.load(Tags.SPAN_KIND); 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/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); From d5a7f6422a6bc341dd3cd70d1ce7fa9e029c56fb Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 22 Oct 2026 18:56:11 -0400 Subject: [PATCH 07/39] Extract TagMap's bucket mechanics into HashSupport A pure move: the loops over one bucket array (find, put, remove, copy, merge, the forEach variants, fillMap / fillStringMap, count, isEmpty, the bucket-position check, toInternalString) become static methods of HashSupport, moved verbatim. Each reports what it did through its return value (the entry put replaced, the entry remove removed, how much merge grew the map), and TagMap keeps its fields, its size counter, and the read-through and tombstone handling around them. No new objects or fields, and no behavior change: allocation per trace in TraceShapeBenchmark matches the base within fork-to-fork noise, and the TagMap and core span suites pass unchanged. This prepares the split of known and custom tags into separate stores, which reuse these mechanics. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/api/HashSupport.java | 461 ++++++++++++++++++ .../main/java/datadog/trace/api/TagMap.java | 399 +-------------- 2 files changed, 481 insertions(+), 379 deletions(-) create mode 100644 internal-api/src/main/java/datadog/trace/api/HashSupport.java 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..1c7874433ab --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java @@ -0,0 +1,461 @@ +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 of {@link TagMap}, over one fixed-size {@code Object[]} of buckets of + * shareable, immutable {@link Entry} objects. A bucket holds a lone Entry directly; colliding + * entries share a fixed-size {@link BucketGroup}, chained when one fills. + * + *

    Stateless: each operation reports what it did through its return value -- put returns the + * entry it replaced, remove the entry it removed, merge how much the map grew -- and the calling + * map keeps its own count. Read-through to a parent map and its tombstones stay with the map. + */ +final class HashSupport { + private HashSupport() {} + + /** A fresh bucket array; its length must be a power of 2 for bucket masking. */ + static Object[] newBuckets() { + // needs to be a power of 2 for bucket masking calculation to work as intended + return new Object[1 << 4]; + } + + /** The entry for {@code (hash, tag)} in {@code buckets}, or null. */ + static Entry find(Object[] buckets, int hash, String tag) { + return findInBucket(buckets[hash & (buckets.length - 1)], hash, tag); + } + + /** + * Finds an entry by hash/tag within a single bucket object (Entry | BucketGroup chain | null). + */ + 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); + } + 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.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.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 much the map grew. + */ + static int merge(Object[] thisBuckets, Object[] thatBuckets) { + int added = 0; + + // 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; + 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 try, 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 accounted for in this.size + added += 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 + 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}. */ + static void forEach(Object[] buckets, Consumer consumer) { + Object[] thisBuckets = 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); + } + } + } + + /** {@link #forEach(Object[], Consumer)} with a context object for a non-capturing consumer. */ + static void forEach( + Object[] buckets, T thisObj, BiConsumer consumer) { + Object[] thisBuckets = 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); + } + } + } + + /** {@link #forEach(Object[], Consumer)} with two context objects. */ + static void forEach( + Object[] buckets, T thisObj, U otherObj, TriConsumer consumer) { + Object[] thisBuckets = 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); + } + } + } + + /** Puts every entry in {@code buckets} into {@code map}, by tag, as its object value. */ + static void fillMap(Object[] buckets, Map map) { + Object[] thisBuckets = 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); + } + } + } + + /** Puts every entry in {@code buckets} into {@code stringMap}, by tag, as its string value. */ + static void fillStringMap(Object[] buckets, Map stringMap) { + Object[] thisBuckets = 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); + } + } + } + + /** The number of entries in {@code buckets}, counted. */ + static int count(Object[] buckets) { + Object[] thisBuckets = 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; + } + + /** Whether {@code buckets} holds no entry, checked bucket by bucket. */ + static boolean isEmpty(Object[] buckets) { + Object[] thisBuckets = 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; + } + + /** Checks that every entry in {@code buckets} sits in the bucket its hash selects. */ + static void checkBuckets(Object[] buckets) { + 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(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"); + } + } + } + } + } + } + + /** 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(); + } +} 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 b4cc9997222..8cbc4358c9c 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1238,8 +1238,7 @@ 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.buckets = HashSupport.newBuckets(); this.size = 0; this.frozen = false; this.parent = parent; @@ -1488,22 +1487,7 @@ private Entry getEntry(String canonicalTag, long 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); - } - - /** - * Finds an entry by hash/tag within a single bucket object (Entry | BucketGroup chain | null). - */ - 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); - } - return null; + return HashSupport.find(this.buckets, Entry.bucketHash(tagHash), tag); } /** @@ -1528,7 +1512,7 @@ boolean parentEntryVisible(Entry parentEntry, TagMap fromAncestor) { // 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) { + if (HashSupport.findInBucket(nearerBucket, hash, tag) != null) { return false; } } @@ -1809,49 +1793,11 @@ Entry putEntry(@Nonnull Entry newEntry) { 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); - - this.size += 1; - 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); - } + Entry replaced = HashSupport.put(this.buckets, newEntry); + if (replaced == null) { this.size += 1; - return null; } - - // unreachable - return null; + return replaced; } public Entry getAndSet(@Nonnull String tag, Object value) { @@ -1928,143 +1874,14 @@ private void putAllOptimizedMap(TagMap 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); - } - } - } + this.size += HashSupport.merge(this.buckets, that.buckets); } /* * 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; - } - } + HashSupport.copyInto(this.buckets, that.buckets); this.size = that.size; } @@ -2077,21 +1894,7 @@ public void fillMap(Map map) { 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); - } - } + HashSupport.fillMap(this.buckets, map); } public void fillStringMap(Map stringMap) { @@ -2102,21 +1905,7 @@ public void fillStringMap(Map stringMap) { 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); - } - } + HashSupport.fillStringMap(this.buckets, stringMap); } @Override @@ -2177,40 +1966,11 @@ private Entry getAndRemove(String canonicalTag, long tagHash) { /** 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); - } - + Entry removed = HashSupport.remove(this.buckets, Entry.bucketHash(tagHash), tag); + if (removed != null) { this.size -= 1; - return existingEntry; } - return null; + return removed; } public TagMap copy() { @@ -2244,21 +2004,7 @@ public Stream stream() { @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); - } - } + HashSupport.forEach(this.buckets, 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). @@ -2293,21 +2039,7 @@ private void forEachParent(Consumer 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); - } - } + HashSupport.forEach(this.buckets, thisObj, consumer); // read-through: parent entries not shadowed locally or tombstoned (kept out of line). if (this.parent != null) { @@ -2341,21 +2073,7 @@ private void forEachParent(T thisObj, BiConsumer 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); - } - } + HashSupport.forEach(this.buckets, thisObj, otherObj, consumer); // read-through: parent entries not shadowed locally or tombstoned (kept out of line). if (this.parent != null) { @@ -2420,38 +2138,7 @@ void checkIntegrity() { // 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"); - } - } - } - } - } + HashSupport.checkBuckets(this.buckets); if (this.size != this.computeSize()) { throw new IllegalStateException("incorrect size"); @@ -2464,37 +2151,11 @@ void checkIntegrity() { } 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; + return HashSupport.count(this.buckets); } 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 HashSupport.isEmpty(this.buckets); } @Override @@ -2552,27 +2213,7 @@ 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(); + return HashSupport.toInternalString(this.buckets); } abstract static class IteratorBase { From 96c49d08cec75edd79993137325f7392f4a8a6e6 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 23 Oct 2026 00:00:06 -0400 Subject: [PATCH 08/39] Add id and name tag stores, not yet used IdStore keeps known tags by id; its one implementation today is HashIdStore, built through IdStoreFactory so a JVM loads a single IdStore subclass and calls on it stay devirtualized. HashStore keeps custom tags by name. Both reuse HashSupport's bucket mechanics and read through to a frozen parent's store, hiding an inherited tag with a tombstone: one shared, immutable Entry per known tag (Entry.tombstoneOf) in the id store, and a removed-name set in the name store. The hash id store's buckets are its exact state, so it needs no slot masks. Nothing uses the stores yet: TagMap and the span keep their current storage. IdStoreContractTest pins the stores' behavior directly. The only TagMap changes are to its nested Entry, EntryReader and BucketGroup, which the stores share: the tombstone flag and a lookup of a bucket chain by id. HashSupport.checkBuckets now indexes a group's entries by entry rather than by bucket, so the integrity check really inspects groups. The tag registry gains _dd.agent_psr as a trace-level known tag. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/api/HashIdStore.java | 417 ++++++++++++++ .../java/datadog/trace/api/HashStore.java | 364 ++++++++++++ .../java/datadog/trace/api/HashSupport.java | 281 ++++----- .../main/java/datadog/trace/api/IdStore.java | 159 +++++ .../datadog/trace/api/IdStoreFactory.java | 42 ++ .../main/java/datadog/trace/api/TagMap.java | 161 +++--- .../trace/api/IdStoreContractTest.java | 541 ++++++++++++++++++ tag-conventions.yaml | 1 + 8 files changed, 1751 insertions(+), 215 deletions(-) create mode 100644 internal-api/src/main/java/datadog/trace/api/HashIdStore.java create mode 100644 internal-api/src/main/java/datadog/trace/api/HashStore.java create mode 100644 internal-api/src/main/java/datadog/trace/api/IdStore.java create mode 100644 internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java create mode 100644 internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java 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..557dc730687 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -0,0 +1,417 @@ +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. */ + private static final class HashCursor implements Cursor { + private final HashIdStore leaf; + private HashIdStore level; + private final HashSupport.BucketWalker walker = new HashSupport.BucketWalker(); + + HashCursor(HashIdStore leaf) { + this.leaf = leaf; + this.level = leaf; + this.walker.reset(leaf.buckets); + } + + @Override + public EntryReader next() { + HashIdStore level = this.level; + while (level != null) { + Entry entry = this.walker.next(); + 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.walker.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 getValue(long tagId) { + Entry entry = this.getEntry(tagId); + return entry == null ? null : entry.objectValue(); + } + + @Override + public String getString(long tagId) { + Entry entry = this.getEntry(tagId); + return entry == null ? null : entry.stringValue(); + } + + @Override + public void set(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 set(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 set(long tagId, boolean value) { + this.put(Entry.newBooleanEntry(tagId, value)); + } + + @Override + public void set(long tagId, int value) { + this.put(Entry.newIntEntry(tagId, value)); + } + + @Override + public void set(long tagId, long value) { + this.put(Entry.newLongEntry(tagId, value)); + } + + @Override + public void set(long tagId, float value) { + this.put(Entry.newFloatEntry(tagId, value)); + } + + @Override + public void set(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 + void forEach(Consumer consumer) { + HashSupport.forEach(this.buckets, consumer); + if (this.parent != null) { + this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); + } + } + + @Override + 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)); + } + } + + @Override + 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 (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.forEach(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..3b746cb5244 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java @@ -0,0 +1,364 @@ +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}. + * + *

    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; + + /** + * 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 get(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); + } + + /* + * Sets the custom tag {@code tag}, which must not be a known tag's name. + */ + + public void set(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 set(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 set(String tag, boolean value) { + this.put(Entry.newBooleanEntry(tag, value)); + } + + public void set(String tag, int value) { + this.put(Entry.newIntEntry(tag, value)); + } + + public void set(String tag, long value) { + this.put(Entry.newLongEntry(tag, value)); + } + + public void set(String tag, float value) { + this.put(Entry.newFloatEntry(tag, value)); + } + + public void set(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. */ + void forEach(Consumer consumer) { + HashSupport.forEach(this.buckets, consumer); + if (this.parent != null) { + this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); + } + } + + 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)); + } + } + + 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 index 1c7874433ab..368244f3264 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashSupport.java +++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java @@ -9,35 +9,50 @@ import java.util.function.Consumer; /** - * The raw bucket mechanics of {@link TagMap}, over one fixed-size {@code Object[]} of buckets of - * shareable, immutable {@link Entry} objects. A bucket holds a lone Entry directly; colliding - * entries share a fixed-size {@link BucketGroup}, chained when one fills. + * 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, merge how much the map grew -- and the calling - * map keeps its own count. Read-through to a parent map and its tombstones stay with the map. + * 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() { - // needs to be a power of 2 for bucket masking calculation to work as intended return new Object[1 << 4]; } - /** The entry for {@code (hash, tag)} in {@code buckets}, or null. */ + /** 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); } /** - * Finds an entry by hash/tag within a single bucket object (Entry | BucketGroup chain | null). + * {@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. */ static Entry findInBucket(Object bucket, int hash, String tag) { if (bucket instanceof Entry) { Entry tagEntry = (Entry) bucket; - return tagEntry.matches(tag) ? tagEntry : null; + return tagEntry.hash() == hash && tagEntry.matches(tag) ? tagEntry : null; } else if (bucket instanceof BucketGroup) { return ((BucketGroup) bucket).findInChain(hash, tag); } @@ -58,7 +73,7 @@ static Entry put(Object[] buckets, Entry newEntry) { return null; } else if (bucket instanceof Entry) { Entry existingEntry = (Entry) bucket; - if (existingEntry.matches(newEntry.tag)) { + if (existingEntry.hash() == newHash && existingEntry.matches(newEntry.tag)) { thisBuckets[bucketIndex] = newEntry; // replaced existing entry - no size change @@ -91,14 +106,13 @@ static Entry put(Object[] buckets, Entry newEntry) { /** 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.matches(tag)) { + if (existingEntry.hash() == hash && existingEntry.matches(tag)) { thisBuckets[bucketIndex] = null; return existingEntry; @@ -128,6 +142,7 @@ static Entry remove(Object[] buckets, int hash, String tag) { * 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) { @@ -149,12 +164,13 @@ static void copyInto(Object[] thisBuckets, Object[] thatBuckets) { /** * Merges {@code thatBuckets} into {@code thisBuckets}, sharing Entry objects and cloning only - * BucketGroup chains; incoming entries replace existing ones. Returns how much the map grew. + * 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; - // Since TagMap-s don't support expansion, buckets are perfectly aligned + // 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) { @@ -211,7 +227,7 @@ static int merge(Object[] thisBuckets, Object[] thatBuckets) { 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 + // incoming entry clobbers the existing entry, so we're done thisBuckets[i] = thisNewGroup; // overlapping group - subtract one for clobbered existing entry @@ -220,13 +236,13 @@ static int merge(Object[] thisBuckets, Object[] thatBuckets) { // able to add thisEntry into the existing groups thisBuckets[i] = thisNewGroup; - // non overlapping group - existing entry already accounted for in this.size + // 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 accounted for in this.size + // non overlapping group - existing entry already counted added += thisNewGroupSize; } } @@ -265,141 +281,81 @@ static int merge(Object[] thisBuckets, Object[] thatBuckets) { return added; } - /** Visits every entry in {@code buckets}. */ + /** Visits every entry, skipping tombstones. */ static void forEach(Object[] buckets, Consumer consumer) { - Object[] thisBuckets = 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); + 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 #forEach(Object[], Consumer)} with a context object for a non-capturing consumer. */ static void forEach( Object[] buckets, T thisObj, BiConsumer consumer) { - Object[] thisBuckets = 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); + 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 #forEach(Object[], Consumer)} with two context objects. */ static void forEach( Object[] buckets, T thisObj, U otherObj, TriConsumer consumer) { - Object[] thisBuckets = 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); + 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 entry in {@code buckets} into {@code map}, by tag, as its object value. */ static void fillMap(Object[] buckets, Map map) { - Object[] thisBuckets = 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); - } - } + forEach(buckets, map, (m, entry) -> m.put(entry.tag(), entry.objectValue())); } - /** Puts every entry in {@code buckets} into {@code stringMap}, by tag, as its string value. */ - static void fillStringMap(Object[] buckets, Map stringMap) { - Object[] thisBuckets = 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); - } - } + static void fillStringMap(Object[] buckets, Map map) { + forEach(buckets, map, (m, entry) -> m.put(entry.tag(), entry.stringValue())); } - /** The number of entries in {@code buckets}, counted. */ + /** The number of live entries, not counting tombstones. Tests only. */ static int count(Object[] buckets) { - Object[] thisBuckets = 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; + int[] count = new int[1]; + forEach(buckets, count, (c, entry) -> c[0]++); + return count[0]; } - /** Whether {@code buckets} holds no entry, checked bucket by bucket. */ - static boolean isEmpty(Object[] buckets) { - Object[] thisBuckets = 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; - } - - /** Checks that every entry in {@code buckets} sits in the bucket its hash selects. */ + /** 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) { @@ -418,11 +374,11 @@ static void checkBuckets(Object[] buckets) { for (BucketGroup curGroup = thisGroup; curGroup != null; curGroup = curGroup.prev) { for (int j = 0; j < BucketGroup.LEN; ++j) { - Entry thisEntry = curGroup._entryAt(i); + Entry thisEntry = curGroup._entryAt(j); if (thisEntry == null) continue; int thisHash = thisEntry.hash(); - assert curGroup._hashAt(i) == thisHash; + assert curGroup._hashAt(j) == thisHash; int expectedBucket = thisHash & (thisBuckets.length - 1); if (expectedBucket != i) { @@ -434,7 +390,6 @@ static void checkBuckets(Object[] buckets) { } } - /** The buckets' internal structure, for deep debugging. */ static String toInternalString(Object[] buckets) { Object[] thisBuckets = buckets; @@ -458,4 +413,72 @@ static String toInternalString(Object[] buckets) { } return ledger.toString(); } + + /** Whether {@code buckets} holds no entry, checked bucket by bucket. */ + static boolean isEmpty(Object[] buckets) { + Object[] thisBuckets = 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; + } + + /** Walks a bucket array's raw entries, tombstones included, for an iterator. */ + static final 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. */ + Entry next() { + 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..d9e2f1bdf86 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -0,0 +1,159 @@ +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; + +/** + * 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 #create}); a {@link TagMap} owns + * one too. + * + *

    {@link IdStoreFactory} builds every store, so the JVM loads one implementation 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(); + } + + /** 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 getValue(long tagId); + + /** + * {@link #getValue} 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 getString(long tagId); + + /* + * Sets a known tag without validating its id: the caller has checked KnownTagCodec.isKnown. + */ + + public abstract void set(long tagId, Object value); + + public abstract void set(long tagId, CharSequence value); + + public abstract void set(long tagId, boolean value); + + public abstract void set(long tagId, int value); + + public abstract void set(long tagId, long value); + + public abstract void set(long tagId, float value); + + public abstract void set(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. */ + abstract void forEach(Consumer consumer); + + abstract void forEach(T thisObj, BiConsumer consumer); + + 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..bd7385b9d6c --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java @@ -0,0 +1,42 @@ +package datadog.trace.api; + +/** + * Builds the {@link IdStore}s of the one implementation this JVM uses, today {@link HashIdStore}. + * + *

    Every store is built through {@link Default#INSTANCE}, never with {@code new}, so a JVM loads + * one {@code IdStore} subclass and 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}: {@link TagMap#EMPTY} is built with {@link + * #createEmpty}, so class initialization works whichever of the two starts first. + */ +abstract class IdStoreFactory { + /** The JVM's store. */ + static final class Default { + static final IdStoreFactory INSTANCE = HashIdStoreFactory.INSTANCE; + + private Default() {} + } + + /** A fresh store reading through to {@code parent} (may be null). */ + abstract IdStore create(IdStore parent); + + /** 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) { + return new HashIdStore(parent); + } + + @Override + IdStore createEmpty() { + return HashIdStore.createEmpty(); + } +} 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 8cbc4358c9c..2a476c41c4c 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -159,6 +159,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; @@ -448,6 +455,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 @@ -462,6 +504,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; @@ -472,6 +515,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; @@ -2498,6 +2542,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; @@ -2687,86 +2756,6 @@ Entry _remove(int hash, String tag) { return existingEntry; } - void forEachInChain(Consumer consumer) { - for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { - curGroup._forEach(consumer); - } - } - - void _forEach(Consumer consumer) { - if (this.entry0 != null) consumer.accept(this.entry0); - if (this.entry1 != null) consumer.accept(this.entry1); - if (this.entry2 != null) consumer.accept(this.entry2); - if (this.entry3 != null) consumer.accept(this.entry3); - } - - void forEachInChain(T thisObj, BiConsumer consumer) { - for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { - curGroup._forEach(thisObj, consumer); - } - } - - void _forEach(T thisObj, BiConsumer consumer) { - if (this.entry0 != null) consumer.accept(thisObj, this.entry0); - if (this.entry1 != null) consumer.accept(thisObj, this.entry1); - if (this.entry2 != null) consumer.accept(thisObj, this.entry2); - if (this.entry3 != null) consumer.accept(thisObj, this.entry3); - } - - void forEachInChain( - T thisObj, U otherObj, TriConsumer consumer) { - for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { - curGroup._forEach(thisObj, otherObj, consumer); - } - } - - void _forEach(T thisObj, U otherObj, TriConsumer consumer) { - if (this.entry0 != null) consumer.accept(thisObj, otherObj, this.entry0); - if (this.entry1 != null) consumer.accept(thisObj, otherObj, this.entry1); - if (this.entry2 != null) consumer.accept(thisObj, otherObj, this.entry2); - if (this.entry3 != null) consumer.accept(thisObj, otherObj, this.entry3); - } - - void fillMapFromChain(Map 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(); @@ -2791,15 +2780,15 @@ BucketGroup _cloneEntries() { @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(); } } 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..9662c577240 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java @@ -0,0 +1,541 @@ +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.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))); + } + + /** 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.set(KnownTags.HTTP_ROUTE_ID, (Object) "/users"); + store.set(KnownTags.HTTP_METHOD_ID, (CharSequence) "GET"); + store.set(KnownTags.ERROR_ID, true); + store.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + store.set(KnownTags.PEER_PORT_ID, 5432L); + store.set(KnownTags.DD_AGENT_PSR_ID, 0.5f); + store.set(KnownTags.DB_TYPE_ID, (Object) "postgres"); + store.set(KnownTags.COMPONENT_ID, (CharSequence) "jdbc"); + store.set(KnownTags.SPAN_KIND_ID, (Object) "client"); + store.set(KnownTags.DB_POOL_NAME_ID, (Object) "pool"); + store.set(KnownTags.HTTP_URL_ID, (Object) "http://x"); + store.set(KnownTags.DB_INSTANCE_ID, 2.5d); + + assertEquals("/users", store.getValue(KnownTags.HTTP_ROUTE_ID)); + assertEquals("GET", store.getValue(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.set(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.getValue(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.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.set(KnownTags.COMPONENT_ID, "okhttp"); + IdStore store = newStore.apply(parent); + store.set(KnownTags.HTTP_METHOD_ID, "GET"); + assertTrue(store.hasParent()); + assertEquals(3, store.size()); + + assertEquals("/inherited", store.remove(KnownTags.HTTP_ROUTE_ID).objectValue()); + assertNull(store.getValue(KnownTags.HTTP_ROUTE_ID)); + assertNull(store.remove(KnownTags.HTTP_ROUTE_ID), "already hidden"); + assertEquals(2, store.size()); + assertEquals("/inherited", parent.getValue(KnownTags.HTTP_ROUTE_ID), "parent untouched"); + + // a local value that also hides an inherited one is removed with its tombstone in place + store.set(KnownTags.COMPONENT_ID, "local"); + assertEquals("local", store.remove(KnownTags.COMPONENT_ID).objectValue()); + assertNull(store.getValue(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.set(KnownTags.HTTP_ROUTE_ID, "/revived"); + assertEquals("/revived", store.getValue(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.set(KnownTags.HTTP_ROUTE_ID, "/a"); + parent.set(KnownTags.HTTP_METHOD_ID, "GET"); + IdStore child = newStore.apply(parent); + assertFalse(child.isEmpty(), "inherits visible tags"); + assertFalse(child.isDefinitelyEmpty()); + + child.set(KnownTags.HTTP_METHOD_ID, "POST"); // shadows + child.set(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.set(KnownTags.HTTP_ROUTE_ID, "/a"); + parent.set(KnownTags.HTTP_METHOD_ID, "GET"); + parent.set(KnownTags.COMPONENT_ID, "okhttp"); + IdStore store = newStore.apply(parent); + store.set(KnownTags.HTTP_METHOD_ID, "POST"); // shadows the parent's + store.set(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.set(KnownTags.HTTP_ROUTE_ID, "/a"); + IdStore store = newStore.apply(parent); + store.set(KnownTags.HTTP_METHOD_ID, "GET"); + store.remove(KnownTags.HTTP_ROUTE_ID); + + IdStore copy = store.copy(); + copy.set(KnownTags.HTTP_METHOD_ID, "POST"); + copy.set(KnownTags.HTTP_ROUTE_ID, "/copy"); + + assertEquals("GET", store.getValue(KnownTags.HTTP_METHOD_ID)); + assertNull(store.getValue(KnownTags.HTTP_ROUTE_ID), "the original keeps its tombstone"); + assertEquals("POST", copy.getValue(KnownTags.HTTP_METHOD_ID)); + assertEquals("/copy", copy.getValue(KnownTags.HTTP_ROUTE_ID)); + copy.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void putAllLocalMergesAnotherStoresTags(Function newStore) { + IdStore source = newStore.apply(null); + source.set(KnownTags.HTTP_ROUTE_ID, "/a"); + source.set(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.set(KnownTags.HTTP_METHOD_ID, "POST"); + merged.set(KnownTags.COMPONENT_ID, "okhttp"); + merged.putAllLocal(source); + assertEquals("GET", merged.getValue(KnownTags.HTTP_METHOD_ID), "the source wins"); + assertEquals("okhttp", merged.getValue(KnownTags.COMPONENT_ID)); + assertEquals(3, merged.size()); + + IdStore parent = newStore.apply(null); + parent.set(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.getValue(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.set(KnownTags.HTTP_ROUTE_ID, "/a"); + store.set(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.set(ids.get(i), "v" + i); + } + for (int i = 0; i < n; ++i) { + assertEquals("v" + i, store.getValue(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.set(ids.get(i), "target" + i); + } + for (int i = 20; i < 50; ++i) { + source.set(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.getValue(ids.get(i))); + } + target.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void getAndPutRevivesARemovedInheritedTag(Function newStore) { + IdStore parent = newStore.apply(null); + parent.set(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.getValue(KnownTags.HTTP_ROUTE_ID)); + assertEquals(1, store.size()); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void readsGoThroughEveryAncestor(Function newStore) { + IdStore grandparent = newStore.apply(null); + grandparent.set(KnownTags.HTTP_ROUTE_ID, "/grandparent"); + grandparent.set(KnownTags.COMPONENT_ID, "okhttp"); + IdStore parent = newStore.apply(grandparent); + parent.set(KnownTags.HTTP_METHOD_ID, "GET"); + parent.remove(KnownTags.COMPONENT_ID); + IdStore store = newStore.apply(parent); + + assertEquals("/grandparent", store.getValue(KnownTags.HTTP_ROUTE_ID)); + assertEquals("GET", store.getValue(KnownTags.HTTP_METHOD_ID)); + assertNull(store.getValue(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.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.set(KnownTags.COMPONENT_ID, "hidden"); + IdStore store = newStore.apply(parent); + store.set(KnownTags.PEER_PORT_ID, 5432); + store.set(KnownTags.DB_INSTANCE_ID, 1.5d); + store.remove(KnownTags.COMPONENT_ID); + + assertEquals("5432", store.getString(KnownTags.PEER_PORT_ID)); + assertEquals("1.5", store.getString(KnownTags.DB_INSTANCE_ID)); + assertEquals("/inherited", store.getString(KnownTags.HTTP_ROUTE_ID)); + assertNull(store.getString(KnownTags.COMPONENT_ID), "hidden by the removal"); + assertNull(store.getString(KnownTags.HTTP_METHOD_ID)); + assertThrows( + IllegalArgumentException.class, () -> store.getString(KnownTagCodec.makeTagId(9999))); + } + + @ParameterizedTest + @MethodSource("stores") + void settingANullValueRemovesTheTag(Function newStore) { + IdStore parent = newStore.apply(null); + parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore store = newStore.apply(parent); + store.set(KnownTags.HTTP_METHOD_ID, "GET"); + + store.set(KnownTags.HTTP_METHOD_ID, (Object) null); + store.set(KnownTags.HTTP_ROUTE_ID, (CharSequence) null); // hides the inherited tag + assertNull(store.getValue(KnownTags.HTTP_METHOD_ID)); + assertNull(store.getValue(KnownTags.HTTP_ROUTE_ID)); + assertEquals(0, store.size()); + assertEquals("/inherited", parent.getValue(KnownTags.HTTP_ROUTE_ID), "the parent keeps it"); + } + + @ParameterizedTest + @MethodSource("stores") + void aReaderWithNoEntryIsANoOp(Function newStore) { + IdStore store = newStore.apply(null); + store.set(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.getValue(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.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore store = newStore.apply(parent); + store.set(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.getValue(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; + } + } +} diff --git a/tag-conventions.yaml b/tag-conventions.yaml index 3f13f0fbd49..0f38f733745 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: From 8a02c1dc9f1176dff58f3c4e39fe128f426d1698 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 23 Oct 2026 00:08:11 -0400 Subject: [PATCH 09/39] Make TagMap a facade over an id store and a name store TagMap keeps known tags in an IdStore (by id) and custom tags in a HashStore (by name), instead of one bucket array. A name is classified once, at the boundary, and routed to its store; known and custom tags never share a key, so read-through and precedence work per store and never cross between them. The name store is created on the first custom write; until then a map borrows its read-through parent's store read-only. Behavior is unchanged: the TagMap suites pass as they are, and Entry objects are still shared across copies. The span still holds a single TagMap; it starts owning the stores directly in the next change. TagMap.Internals stays for now, writing straight to the id store, and goes away with that change. Reads by id go straight to the id store. stream() and spliterator() yield stable Entries, and getEntryReader gives a momentary reader for typed reads. TagStoresTest covers the stores through TagMap, and IdStoreContractTest gains the view cases. Co-Authored-By: Claude Opus 5.5 --- .../forbiddenApiFilters/instrumentation.txt | 11 + .../java/datadog/trace/api/HashStore.java | 8 + .../java/datadog/trace/api/HashSupport.java | 18 - .../main/java/datadog/trace/api/IdStore.java | 5 + .../main/java/datadog/trace/api/TagMap.java | 1075 +++++++---------- .../trace/api/EntryReadingHelperTest.java | 169 --- .../trace/api/IdStoreContractTest.java | 102 ++ .../java/datadog/trace/api/TagStoresTest.java | 458 +++++++ 8 files changed, 1012 insertions(+), 834 deletions(-) delete mode 100644 internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java create mode 100644 internal-api/src/test/java/datadog/trace/api/TagStoresTest.java diff --git a/gradle/forbiddenApiFilters/instrumentation.txt b/gradle/forbiddenApiFilters/instrumentation.txt index 5ffb1f7868d..385cb45b0b2 100644 --- a/gradle/forbiddenApiFilters/instrumentation.txt +++ b/gradle/forbiddenApiFilters/instrumentation.txt @@ -7,3 +7,14 @@ datadog.trace.bootstrap.instrumentation.api.AgentSpan#setServiceName(java.lang.S # (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/main/java/datadog/trace/api/HashStore.java b/internal-api/src/main/java/datadog/trace/api/HashStore.java index 3b746cb5244..1b0a38df6e1 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java @@ -38,6 +38,14 @@ public final class HashStore { */ 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). diff --git a/internal-api/src/main/java/datadog/trace/api/HashSupport.java b/internal-api/src/main/java/datadog/trace/api/HashSupport.java index 368244f3264..ee6749e1e4e 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashSupport.java +++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java @@ -414,24 +414,6 @@ static String toInternalString(Object[] buckets) { return ledger.toString(); } - /** Whether {@code buckets} holds no entry, checked bucket by bucket. */ - static boolean isEmpty(Object[] buckets) { - Object[] thisBuckets = 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; - } - /** Walks a bucket array's raw entries, tombstones included, for an iterator. */ static final class BucketWalker { private Object[] buckets; diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index d9e2f1bdf86..7681edef9e9 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -27,6 +27,11 @@ interface Cursor { 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 IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore); + } + /** 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)); 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 2a476c41c4c..27873667bf0 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -9,11 +9,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; @@ -51,14 +52,9 @@ public final class TagMap implements Map, Iterable, SpanStorage { /** 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) { @@ -99,19 +95,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 */ @@ -1228,49 +1212,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); @@ -1282,92 +1255,149 @@ public TagMap() { * optimizations can treat it as fixed. */ private TagMap(TagMap parent) { - this.buckets = HashSupport.newBuckets(); - 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 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.get(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 @@ -1378,15 +1408,31 @@ 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.getValue(key); + } + HashStore nameStore = this.nameStore; + Entry entry = nameStore == null ? null : nameStore.get(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.getString(key); + } + HashStore nameStore = this.nameStore; + Entry entry = nameStore == null ? null : nameStore.get(tag); return entry == null ? null : entry.stringValue(); } @@ -1417,8 +1463,8 @@ public boolean getBoolean(String tag) { } 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) { @@ -1426,8 +1472,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) { @@ -1435,8 +1481,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) { @@ -1444,8 +1490,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) { @@ -1453,15 +1499,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 @@ -1498,69 +1544,37 @@ 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.get(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) { - return HashSupport.find(this.buckets, Entry.bucketHash(tagHash), tag); + return this.knownStore.getEntry(tagId); } /** - * 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). + * 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. */ - 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 (HashSupport.findInBucket(nearerBucket, hash, tag) != null) { - return false; - } + public EntryReader getEntryReader(String tag) { + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return this.knownStore.getEntryReader(key); } - return true; + HashStore nameStore = this.nameStore; + return nameStore == null ? null : nameStore.get(tag); + } + + /** {@link #getEntryReader(String)} for a known tag id. */ + public EntryReader getEntryReader(long tagId) { + return this.knownStore.getEntryReader(tagId); } @Deprecated @@ -1588,38 +1602,38 @@ 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); } /** A null value removes the tag, as {@code setTag(tag, null)} does on a span. */ @@ -1628,11 +1642,11 @@ public void set(@Nonnull String tag, @Nullable Object value) { this.getAndRemove(tag); return; } - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + 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)); } } @@ -1642,56 +1656,56 @@ public void set(@Nonnull String tag, @Nullable CharSequence value) { this.getAndRemove(tag); return; } - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + 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)); } } @@ -1700,9 +1714,11 @@ 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 to the core; instrumentation uses the checked methods. Goes away once the span owns + * its stores and writes to them directly. */ @Restricted(allowedIn = {"datadog.trace.core"}) public Internals internals() { @@ -1726,33 +1742,33 @@ public static final class Internals { * already checked KnownTagCodec.isKnown(tagId). */ public void setKnown(long tagId, @Nonnull Object value) { - this.map.putEntry(new Entry(tagId, null, Entry.ANY, 0L, value)); + this.map.putKnown(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)); + this.map.putKnown(new Entry(tagId, null, EntryReader.OBJECT, 0L, value)); } public void setKnown(long tagId, boolean value) { - this.map.putEntry( + this.map.putKnown( 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)); + this.map.putKnown(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)); + this.map.putKnown(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)); + this.map.putKnown(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)); + this.map.putKnown(new Entry(tagId, null, EntryReader.DOUBLE, Entry.double2Prim(value), null)); } } @@ -1762,7 +1778,7 @@ public void set(long tagId, @Nullable Object value) { this.getAndRemove(tagId); return; } - this.putEntry(Entry.newAnyEntry(tagId, value)); + this.putKnown(Entry.newAnyEntry(tagId, value)); } /** A null value removes the tag, as {@code setTag(tag, null)} does on a span. */ @@ -1771,27 +1787,27 @@ public void set(long tagId, @Nullable CharSequence value) { this.getAndRemove(tagId); return; } - this.putEntry(Entry.newObjectEntry(tagId, value)); + 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)); } /** @@ -1804,22 +1820,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); } /** @@ -1831,17 +1836,23 @@ 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); - } + return newEntry.tagId() != 0 + ? entryOf(this.knownStore.put(newEntry)) + : this.nameStoreForWrite().put(newEntry); + } - Entry replaced = HashSupport.put(this.buckets, newEntry); - if (replaced == null) { - this.size += 1; - } - return replaced; + /** {@link #putEntry} for an entry known to be a known tag's. */ + void putKnown(Entry knownEntry) { + this.checkWriteAccess(); + + this.knownStore.put(knownEntry); + } + + /** {@link #putEntry} for an entry known to be a custom tag's. */ + void putCustom(Entry customEntry) { + this.checkWriteAccess(); + + this.nameStoreForWrite().put(customEntry); } public Entry getAndSet(@Nonnull String tag, Object value) { @@ -1903,53 +1914,45 @@ 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); + this.knownStore.putAllLocal(that.knownStore); + HashStore thatNames = that.nameStore; + if (thatNames != null && thatNames.count != 0) { + this.nameStoreForWrite().putAllLocal(thatNames); } } - private void putAllMerge(TagMap that) { - this.size += HashSupport.merge(this.buckets, that.buckets); - } - - /* - * Specially optimized version of putAll for the common case of destination map being empty - */ - private void putAllIntoEmptyMap(TagMap that) { - HashSupport.copyInto(this.buckets, that.buckets); - 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; } - - HashSupport.fillMap(this.buckets, 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; } - - HashSupport.fillStringMap(this.buckets, stringMap); + this.knownStore.fillStringMapLocal(stringMap); + if (this.nameStore != null) { + this.nameStore.fillStringMapLocal(stringMap); + } } @Override @@ -1968,65 +1971,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) { - Entry removed = HashSupport.remove(this.buckets, Entry.bucketHash(tagHash), tag); - if (removed != null) { - this.size -= 1; - } - return removed; + 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() { @@ -2037,130 +2004,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) { - HashSupport.forEach(this.buckets, 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) { - HashSupport.forEach(this.buckets, 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) { - HashSupport.forEach(this.buckets, 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() { @@ -2178,28 +2095,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 - - HashSupport.checkBuckets(this.buckets); - - 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() { - return HashSupport.count(this.buckets); + HashStore nameStore = this.nameStore; + return this.knownStore.computeSize() + + (nameStore == null || this.nameStoreBorrowed ? 0 : nameStore.computeSize()); } boolean checkIfEmpty() { - return HashSupport.isEmpty(this.buckets); + return this.computeSize() == 0; } @Override @@ -2237,19 +2147,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(); } /** @@ -2257,28 +2167,26 @@ String toPrettyString() { * debugging */ String toInternalString() { - return HashSupport.toInternalString(this.buckets); + 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; + // 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; - // 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; - - 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() { @@ -2288,9 +2196,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; } @@ -2302,72 +2210,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.next(); + 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; } } @@ -2811,9 +2685,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(); } } @@ -2896,113 +2784,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/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/IdStoreContractTest.java b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java index 9662c577240..f22d64c0b63 100644 --- a/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java @@ -15,6 +15,7 @@ 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; @@ -386,6 +387,82 @@ void getStringFormatsTheVisibleValue(Function newStore) { IllegalArgumentException.class, () -> store.getString(KnownTagCodec.makeTagId(9999))); } + @ParameterizedTest + @MethodSource("stores") + void aViewReadsValuesFromEitherStore(Function newStore) { + IdStore store = newStore.apply(null); + store.set(KnownTags.HTTP_ROUTE_ID, "/a"); + store.set(KnownTags.PEER_PORT_ID, 5432); + HashStore names = HashStore.create(null); + names.set("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.set(KnownTags.PEER_PORT_ID, 5432); + store.set(KnownTags.DB_INSTANCE_ID, 1.5d); + store.set(KnownTags.DD_MEASURED_ID, true); + HashStore names = HashStore.create(null); + names.set("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.set(KnownTags.HTTP_ROUTE_ID, "/a"); + store.set(KnownTags.HTTP_METHOD_ID, "GET"); + store.set(KnownTags.PEER_PORT_ID, 5432); + HashStore names = HashStore.create(null); + names.set("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) { @@ -402,6 +479,31 @@ void settingANullValueRemovesTheTag(Function newStore) { assertEquals("/inherited", parent.getValue(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) { 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..1d71f09aa56 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java @@ -0,0 +1,458 @@ +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()); + assertTrue(((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.set(KnownTags.RUNTIME_ID, "abc"); + HashIdStore span = new HashIdStore(traceLevel); + long other = + spanLevelId == KnownTags.HTTP_ROUTE_ID ? KnownTags.COMPONENT_ID : KnownTags.HTTP_ROUTE_ID; + span.set(other, "/a"); + + assertNull(span.getEntryReader(spanLevelId)); + assertEquals("abc", span.getValue(KnownTags.RUNTIME_ID)); + } + + @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.set(KnownTags.COMPONENT_ID, (Object) new StringBuilder("jdbc")); + ids.set(KnownTags.HTTP_ROUTE_ID, "/a"); + ids.set(KnownTags.DD_PROFILING_ENABLED_ID, true); + ids.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + ids.set(KnownTags.HTTP_RESEND_COUNT_ID, 2L); + ids.set(KnownTags.DB_POOL_NAME_ID, 1.5f); + ids.set(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.set(KnownTags.HTTP_ROUTE_ID, "/a"); + names.set("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.getValue(KnownTags.HTTP_ROUTE_ID)); + assertEquals("c", borrowed.get("inherited.custom").stringValue()); + + HashStore own = HashStore.create(borrowed); + own.set("own", "o"); + assertEquals("c", own.get("inherited.custom").stringValue()); + assertNull(borrowed.get("own")); + } + + @Test + void theCustomStoreSetsEveryValueShape() { + HashStore names = HashStore.create(null); + names.set("o", (Object) "obj"); + names.set("s", "v"); + names.set("b", true); + names.set("i", 1); + names.set("l", 2L); + names.set("f", 3f); + names.set("d", 4d); + assertNull(names.getAndPut(TagMap.Entry.create("p", "x"))); + assertEquals("x", names.put(TagMap.Entry.create("p", "y")).stringValue()); + + assertEquals(true, names.get("b").objectValue()); + assertEquals(4d, names.get("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.getValue(KnownTags.HTTP_METHOD_ID)); + assertFalse(view.isCustomStoreBorrowed()); + assertNotSame(borrowed, view.customStore()); + assertEquals("o", view.customStore().get("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.get(KnownTags.HTTP_ROUTE_NAME)); + assertThrows(AssertionError.class, () -> names.remove(KnownTags.HTTP_ROUTE_NAME)); + } +} From 0d8077c236e82b972015d869e8ad9db33a348ea7 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 23 Oct 2026 00:09:31 -0400 Subject: [PATCH 10/39] Have the span own its tag stores directly DDSpanContext holds its IdStore (also its tag lock) and its HashStore instead of one TagMap, and routes each write to the store that holds the tag: a name is resolved once and routed, and a write by id goes straight to the id store. The custom store is borrowed read-only from the read-through parent until the span's first custom write. The eager post-processors already read and write the span through SpanContents. The lazy post-processors and the serializer still take a TagMap: the span builds a view over its stores once, at serialization, and caches it (about 32 B per span until they move to SpanContents). TagMap.Internals, the core's trusted side door, is gone: the stores are the direct path. The constructors drop the unused tagsSize parameter. Allocation, 7-span web-request trace: +104 B (+1.3%), from the new DDSpanContext fields (8 B per span) and the first custom store. Co-Authored-By: Claude Opus 5.5 --- .../src/test/groovy/TypeConverterTest.groovy | 1 - .../src/test/groovy/TypeConverterTest.groovy | 1 - .../src/test/groovy/TypeConverterTest.groovy | 1 - .../writer/ddagent/TraceMapperBenchmark.java | 1 - .../datadog/trace/core/GetTagBenchmark.java | 116 +++++ .../datadog/trace/core/PendingTraceWrite.java | 2 - .../datadog/trace/core/TracerMapperMap.java | 1 - .../core/propagation/InjectorBenchmark.java | 1 - .../java/datadog/trace/core/CoreTracer.java | 66 ++- .../datadog/trace/core/DDSpanContext.java | 468 +++++++++++++----- .../writer/PayloadDispatcherImplTest.java | 1 - .../trace/core/DDCoreJavaSpecification.java | 1 - .../trace/core/DDSpanContextStoresTest.java | 297 +++++++++++ .../trace/core/DDSpanContextTagViewTest.java | 60 +++ .../trace/core/DDSpanSerializationTest.java | 17 +- .../java/datadog/trace/core/DDSpanTest.java | 1 - .../core/LongRunningTracesTrackerTest.java | 1 - .../trace/core/PendingTraceBufferTest.java | 2 - .../datadog/trace/core/PendingTraceTest.java | 1 - .../core/RootSpanTagsReadThroughTest.java | 111 +++++ .../propagation/AbstractHttpInjectorTest.java | 1 - .../taginterceptor/TagInterceptorTest.java | 3 +- .../forbiddenApiFilters/instrumentation.txt | 5 - .../main/java/datadog/trace/api/TagMap.java | 58 --- .../datadog/trace/api/TagMapSetByIdTest.java | 36 -- 25 files changed, 1002 insertions(+), 251 deletions(-) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/GetTagBenchmark.java create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextStoresTest.java create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTagViewTest.java create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/RootSpanTagsReadThroughTest.java 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-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/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/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..dda914cc6d7 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 @@ -207,10 +207,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; @@ -2130,6 +2163,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 +2193,7 @@ protected static final DDSpanContext buildSpanContext( origin = null; coreTags = null; coreTagsNeedsIntercept = false; + localRootSpanTags = null; rootSpanTags = null; rootSpanTagsNeedsIntercept = false; parentServiceName = ddsc.getServiceName(); @@ -2233,7 +2268,7 @@ protected static final DDSpanContext buildSpanContext( ciVisibilityContextData = null; } - LocalRootSpanTags localRootSpanTags = tracer.localRootSpanTags; + localRootSpanTags = tracer.localRootSpanTags; rootSpanTags = localRootSpanTags.tags; rootSpanTagsNeedsIntercept = localRootSpanTags.needsIntercept; @@ -2312,12 +2347,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; @@ -2346,7 +2391,6 @@ protected static final DDSpanContext buildSpanContext( w3cBaggage, errorFlag, spanType, - tagsSize, parentTraceCollector, requestContextDataAppSec, requestContextDataIast, @@ -2357,7 +2401,7 @@ protected static final DDSpanContext buildSpanContext( tracer.profilingContextIntegration, tracer.injectBaggageAsTags, tracer.injectLinksAsTags, - mergedTracerTagsNeedsIntercept ? null : mergedTracerTags); + tagParent); // 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 +2426,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/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 8fdf11b8a85..845b3f3430e 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,6 +15,8 @@ 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; @@ -45,6 +47,7 @@ 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; @@ -57,6 +60,7 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.function.Function; import javax.annotation.Nonnull; +import javax.annotation.concurrent.GuardedBy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -139,15 +143,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; @@ -217,7 +257,6 @@ public DDSpanContext( final Map baggageItems, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -239,7 +278,6 @@ public DDSpanContext( null, errorFlag, spanType, - tagsSize, traceCollector, requestContextDataAppSec, requestContextDataIast, @@ -266,7 +304,6 @@ public DDSpanContext( final Map baggageItems, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -290,7 +327,6 @@ public DDSpanContext( null, errorFlag, spanType, - tagsSize, traceCollector, requestContextDataAppSec, requestContextDataIast, @@ -320,7 +356,6 @@ public DDSpanContext( final Baggage w3cBaggage, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -346,7 +381,6 @@ public DDSpanContext( w3cBaggage, errorFlag, spanType, - tagsSize, traceCollector, requestContextDataAppSec, requestContextDataIast, @@ -375,7 +409,6 @@ public DDSpanContext( final Baggage w3cBaggage, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -412,13 +445,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); + this.customTags = HashStore.inheritedFrom(parentTags); + this.customTagsBorrowed = this.customTags != null; // must set this before setting the service and resource names below this.profilingContextIntegration = profilingContextIntegration; @@ -667,16 +698,15 @@ private void seedAbsentTags(final TagMap map) { if (map == null) { return; } - synchronized (unsafeTags) { + synchronized (knownTags) { map.forEach( this, (ctx, tagEntry) -> { - final String tag = tagEntry.tag(); - if (ctx.unsafeTags.containsKey(tag)) { + if (ctx.isPresent(tagEntry)) { return; } if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.unsafeTags.set(tagEntry); + ctx.routePut(tagEntry); } }); } @@ -801,7 +831,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) { @@ -930,6 +960,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.get(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.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + + private void routeSet(long key, String tag, CharSequence value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + + private void routeSet(long key, String tag, int value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + + private void routeSet(long key, String tag, long value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + + private void routeSet(long key, String tag, float value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + + private void routeSet(long key, String tag, double value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + static byte spanKindOrdinalOf(String kind) { if (kind == null) { return SPAN_KIND_UNSET; @@ -961,8 +1124,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(); } } @@ -973,32 +1137,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); } } @@ -1007,24 +1171,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 */ @@ -1035,9 +1239,19 @@ 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.getValue(key) != null; } + HashStore custom = customTags; + return custom != null && custom.get(entry.tag()) != null; } /** @@ -1062,8 +1276,8 @@ 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().set(tag, value); // already known to be custom } } } @@ -1078,8 +1292,8 @@ 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().set(tag, value); // already known to be custom } } } @@ -1095,10 +1309,11 @@ public void setTag(final long tagId, final Object value) { } if (null == value) { removeTag(tagId); - } else if (!tagInterceptor.needsIntercept(tagId) - || !tagInterceptor.interceptTag(this, tagId, value)) { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + return; + } + if (!tagInterceptor.needsIntercept(tagId) || !tagInterceptor.interceptTag(this, tagId, value)) { + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1109,10 +1324,11 @@ public void setTag(final long tagId, final CharSequence value) { } if (null == value) { removeTag(tagId); - } else if (!tagInterceptor.needsIntercept(tagId) - || !tagInterceptor.interceptTag(this, tagId, value)) { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + return; + } + if (!tagInterceptor.needsIntercept(tagId) || !tagInterceptor.interceptTag(this, tagId, value)) { + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1124,8 +1340,8 @@ public void setTag(final long tagId, final boolean value) { if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1137,8 +1353,8 @@ public void setTag(final long tagId, final int value) { if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1150,8 +1366,8 @@ public void setTag(final long tagId, final long value) { if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1163,8 +1379,8 @@ public void setTag(final long tagId, final float value) { if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1176,8 +1392,8 @@ public void setTag(final long tagId, final double value) { if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1189,8 +1405,8 @@ public void setTag(TagMap.EntryReader entry) { // the interceptor prechecks the entry, to avoid boxing if (!tagInterceptor.interceptTag(this, entry)) { - synchronized (unsafeTags) { - unsafeTags.set(entry); + synchronized (knownTags) { + routePut(entry); } } } @@ -1209,8 +1425,8 @@ 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.set(tagId, box); } } } @@ -1224,8 +1440,8 @@ public void setTag(final String tag, final boolean value) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1239,8 +1455,8 @@ public void setTag(final String tag, final int value) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1254,8 +1470,8 @@ public void setTag(final String tag, final long value) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1269,8 +1485,8 @@ public void setTag(final String tag, final float value) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1284,8 +1500,8 @@ public void setTag(final String tag, final double value) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1299,7 +1515,7 @@ void setAllTags(final TagMap map, boolean needsIntercept) { return; } - synchronized (unsafeTags) { + synchronized (knownTags) { if (needsIntercept) { // forEach out-performs the iterator of TagMap // Taking advantage of ability to pass through other context arguments @@ -1308,11 +1524,11 @@ void setAllTags(final TagMap map, boolean needsIntercept) { this, (ctx, tagEntry) -> { if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.unsafeTags.set(tagEntry); + ctx.routePut(tagEntry); } }); } else { - unsafeTags.putAll(map); + routePutAll(map); } } } @@ -1322,20 +1538,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); } } } @@ -1348,9 +1564,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()); } } @@ -1359,19 +1580,19 @@ 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) { @@ -1388,7 +1609,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 :| @@ -1398,27 +1619,45 @@ Object getTag(final String key) { /* * SpanStorage: the span's tags, loaded and stored with no interception. - * Unsynchronized, like unsafeGetTag: callers hold the span's tag lock, as post-processing does. + * Unsynchronized, like unsafeGetTag: callers hold the knownTags lock, as post-processing does. */ @Override public Object load(String tag) { - return unsafeTags.getObject(tag); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return knownTags.getValue(key); + } + HashStore custom = customTags; + TagMap.Entry entry = custom == null ? null : custom.get(tag); + return entry == null ? null : entry.objectValue(); } @Override public String loadString(String tag) { - return unsafeTags.getString(tag); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return knownTags.getString(key); + } + HashStore custom = customTags; + TagMap.Entry entry = custom == null ? null : custom.get(tag); + return entry == null ? null : entry.stringValue(); } @Override public void store(String tag, Object value) { - unsafeTags.set(tag, value); + if (value == null) { + routeRemoval(tag); + } else { + routeSet(tag, value); + } } @Override public void store(TagMap.EntryReader entry) { - unsafeTags.set(entry); + if (entry != null) { + routePut(entry); + } } /** @@ -1429,7 +1668,7 @@ public void store(TagMap.EntryReader entry) { * @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); } @@ -1448,8 +1687,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.getValue(tagId); } /** @@ -1472,8 +1710,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 :| @@ -1525,7 +1763,7 @@ public void setMetaStruct(final String field, final T value) { } void earlyProcessTags(AppendableSpanLinks links) { - synchronized (unsafeTags) { + synchronized (knownTags) { TagsPostProcessorFactory.eagerProcessor().processTags(this, this, links); } } @@ -1555,15 +1793,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); } } @@ -1572,7 +1813,7 @@ void processTagsAndBaggage( if (injectEventsAsTags) { String eventsTag = spanEventsToTag(events); if (eventsTag != null) { - unsafeTags.set(SPAN_EVENTS, eventsTag); + tags.set(SPAN_EVENTS, eventsTag); } } @@ -1597,7 +1838,7 @@ void processTagsAndBaggage( new Metadata( threadId, threadName, - unsafeTags, + tags, baggageItemsWithPropagationTags, getSamplingPriority(), measured, @@ -1610,6 +1851,7 @@ void processTagsAndBaggage( Config.get().isOtlpTracesExportEnabled() ? OTLP_EXPORT_TRUE : OTLP_EXPORT_FALSE, restrictedSpan.getLinks(), events)); + adoptViewStores(tags); } } @@ -1760,7 +2002,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/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java index cf756003e7c..41b53b7ab5d 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 @@ -217,7 +217,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/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/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/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/gradle/forbiddenApiFilters/instrumentation.txt b/gradle/forbiddenApiFilters/instrumentation.txt index 385cb45b0b2..102a57de753 100644 --- a/gradle/forbiddenApiFilters/instrumentation.txt +++ b/gradle/forbiddenApiFilters/instrumentation.txt @@ -3,11 +3,6 @@ @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. 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 27873667bf0..4d485e86612 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; @@ -1715,63 +1714,6 @@ public void set(@Nonnull String tag, double value) { * 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. Goes away once the span owns - * its stores and writes to them directly. - */ - @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.putKnown(new Entry(tagId, null, Entry.ANY, 0L, value)); - } - - public void setKnown(long tagId, @Nonnull CharSequence value) { - this.map.putKnown(new Entry(tagId, null, EntryReader.OBJECT, 0L, value)); - } - - public void setKnown(long tagId, boolean value) { - this.map.putKnown( - new Entry( - tagId, null, EntryReader.BOOLEAN, Entry.boolean2Prim(value), Boolean.valueOf(value))); - } - - public void setKnown(long tagId, int value) { - this.map.putKnown(new Entry(tagId, null, EntryReader.INT, Entry.int2Prim(value), null)); - } - - public void setKnown(long tagId, long value) { - this.map.putKnown(new Entry(tagId, null, EntryReader.LONG, Entry.long2Prim(value), null)); - } - - public void setKnown(long tagId, float value) { - this.map.putKnown(new Entry(tagId, null, EntryReader.FLOAT, Entry.float2Prim(value), null)); - } - - public void setKnown(long tagId, double value) { - this.map.putKnown(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) { 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()); - } } From ec5c75228f1c8e72dbb4e036ca799a2d85754f13 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 22 Oct 2026 23:00:35 -0400 Subject: [PATCH 11/39] Add the dense id store behind an experimental flag DenseIdStore keeps a span's known tags in two parallel arrays, each tag's 16-bit serial and its value, with no Entry per tag. It reads through to the frozen parent's store like HashIdStore. A lookup scans the serials and is skipped when the slot and level masks rule the tag out; the full id is rebuilt from the serial (KnownTagCodec.idOf, backed by the generated IDS_BY_SERIAL table) only where a reader asks for it. The masks live in DenseIdStore, which needs them to index its arrays. HashIdStore's buckets are the exact state, so it stays without them and dense-off spans keep their current size. IdStoreFactory picks the store once per JVM from trace.experimental.dense.tags.enabled (off by default), so only one IdStore subclass is loaded and calls on it stay devirtualized. TraceShape probe, 7-span trace: 8,040 B with the flag off (unchanged), 5,504 B with it on (-30% vs master). Co-Authored-By: Claude Opus 5.5 --- .../tagRegistry/KnownTagsEmitter.kt | 20 + .../TagRegistryGeneratorPluginTest.kt | 1 + .../trace/api/config/TracerConfig.java | 6 + .../main/java/datadog/trace/api/Config.java | 7 + .../java/datadog/trace/api/DenseIdStore.java | 794 ++++++++++++++++++ .../main/java/datadog/trace/api/IdStore.java | 7 +- .../datadog/trace/api/IdStoreFactory.java | 34 +- .../java/datadog/trace/api/KnownTagCodec.java | 39 +- .../api/DenseIdStoreConcurrentReadTest.java | 73 ++ .../trace/api/DenseIdStoreForkedTest.java | 50 ++ .../datadog/trace/api/DenseReaderTest.java | 107 +++ .../trace/api/IdStoreContractTest.java | 3 +- .../trace/api/KnownTagCodecSlotTest.java | 10 + .../java/datadog/trace/api/KnownTagsTest.java | 15 + .../java/datadog/trace/api/TagStoresTest.java | 70 +- metadata/supported-configurations.json | 8 + 16 files changed, 1221 insertions(+), 23 deletions(-) create mode 100644 internal-api/src/main/java/datadog/trace/api/DenseIdStore.java create mode 100644 internal-api/src/test/java/datadog/trace/api/DenseIdStoreConcurrentReadTest.java create mode 100644 internal-api/src/test/java/datadog/trace/api/DenseIdStoreForkedTest.java create mode 100644 internal-api/src/test/java/datadog/trace/api/DenseReaderTest.java 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-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/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 0b388f0c1b0..c5e7111568a 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; @@ -1462,6 +1463,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; @@ -3455,6 +3457,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) this.spanBuilderReuseEnabled = configProvider.getBoolean(GeneralConfig.SPAN_BUILDER_REUSE_ENABLED, true); + this.traceDenseTagsEnabled = configProvider.getBoolean(TRACE_DENSE_TAGS_ENABLED, false); this.tagNameUtf8CacheSize = Math.max(configProvider.getInteger(GeneralConfig.TAG_NAME_UTF8_CACHE_SIZE, 128), 0); this.tagValueUtf8CacheSize = @@ -5432,6 +5435,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..d0aa1fee4cc --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -0,0 +1,794 @@ +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; + +/** + * 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 #getValue}) 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 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) { + 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); + } + } + + /** {@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; + } + + /** + * 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.getValue(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.getValue(tagId); + return value == null ? null : TagMap.Entry.newAnyEntry(tagId, value); + } + + @Override + public Object getValue(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + return this.getVisible((char) KnownTagCodec.serialNum(tagId), slotBit(tagId), levelOf(tagId)); + } + + @Override + public String getString(long tagId) { + Object value = this.getValue(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 set(long tagId, Object value) { + if (value == null) { + this.remove(tagId); // a null value removes the tag + return; + } + this.store(tagId, value); + } + + @Override + public void set(long tagId, CharSequence value) { + if (value == null) { + this.remove(tagId); // a null value removes the tag + return; + } + this.store(tagId, value); + } + + @Override + public void set(long tagId, boolean value) { + this.store(tagId, Boolean.valueOf(value)); + } + + @Override + public void set(long tagId, int value) { + this.store(tagId, Integer.valueOf(value)); + } + + @Override + public void set(long tagId, long value) { + this.store(tagId, Long.valueOf(value)); + } + + @Override + public void set(long tagId, float value) { + this.store(tagId, Float.valueOf(value)); + } + + @Override + public void set(long tagId, double value) { + this.store(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.store(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++; + } + this.values[i] = entryReader.objectValue(); + } else { + prior = this.mayInherit(bit, level) ? this.parent.getVisible(serial, bit, level) : null; + this.append(serial, bit, level, entryReader.objectValue()); + } + 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 { + this.appendSlot(serial, 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); + } + + /** Sets a known tag's value, returning the prior local value, or null. Builds no Entry. */ + private Object store(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; + if (i >= 0) { + Object prior = this.values[i]; + this.values[i] = value; + if (prior == TOMBSTONE) { + this.markLocal(bit, level); + this.count++; // a tombstone replaced is a removal undone: no prior local value + return null; + } + return prior; + } + this.append(serial, bit, level, value); + return null; + } + + /** Appends a live local tag. */ + private void append(char serial, long bit, int level, Object value) { + this.appendSlot(serial, value); + this.markLocal(bit, level); + this.count++; + } + + private void appendSlot(char serial, Object value) { + int i = this.used; + if (i == this.serials.length) { + this.grow(); + } + this.serials[i] = serial; + this.values[i] = value; + this.used = i + 1; + } + + /** 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]; + return; + } + int newCap = Math.max(Math.min(length << 1, KnownTagCodec.serialLimit()), length + 1); + this.serials = Arrays.copyOf(this.serials, newCap); + this.values = Arrays.copyOf(this.values, newCap); + } + + /** 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 + 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 + 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 + 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.store(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/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index 7681edef9e9..9bba5d64153 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -15,9 +15,10 @@ *

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

    {@link IdStoreFactory} builds every store, so the JVM loads one implementation 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. + *

    {@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 { diff --git a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java index bd7385b9d6c..42556d6537e 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java @@ -1,19 +1,25 @@ package datadog.trace.api; /** - * Builds the {@link IdStore}s of the one implementation this JVM uses, today {@link HashIdStore}. + * 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}, so a JVM loads - * one {@code IdStore} subclass and class hierarchy analysis devirtualizes every call on an {@code - * IdStore} without a guard. A store builds its own copies. + *

    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}: {@link TagMap#EMPTY} is built with {@link * #createEmpty}, so class initialization works whichever of the two starts first. */ abstract class IdStoreFactory { - /** The JVM's store. */ + /** The JVM's store, chosen once by the dense-store flag. */ static final class Default { - static final IdStoreFactory INSTANCE = HashIdStoreFactory.INSTANCE; + static final IdStoreFactory INSTANCE = + Config.get().isTraceDenseTagsEnabled() + ? DenseIdStoreFactory.INSTANCE + : HashIdStoreFactory.INSTANCE; private Default() {} } @@ -40,3 +46,19 @@ IdStore createEmpty() { return HashIdStore.createEmpty(); } } + +final class DenseIdStoreFactory extends IdStoreFactory { + static final DenseIdStoreFactory INSTANCE = new DenseIdStoreFactory(); + + private DenseIdStoreFactory() {} + + @Override + IdStore create(IdStore parent) { + return new DenseIdStore(parent); + } + + @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/test/java/datadog/trace/api/DenseIdStoreConcurrentReadTest.java b/internal-api/src/test/java/datadog/trace/api/DenseIdStoreConcurrentReadTest.java new file mode 100644 index 00000000000..c076bf34725 --- /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.set(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/IdStoreContractTest.java b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java index f22d64c0b63..eef7b0c502f 100644 --- a/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java @@ -30,7 +30,8 @@ class IdStoreContractTest { static Stream stores() { return Stream.of( - arguments(named("HashIdStore", (Function) HashIdStore::new))); + arguments(named("HashIdStore", (Function) HashIdStore::new)), + arguments(named("DenseIdStore", (Function) DenseIdStore::new))); } /** Every known tag id, in declaration order. */ 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 29c7fa1ac17..11258ba3722 100644 --- a/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java @@ -285,6 +285,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/TagStoresTest.java b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java index 1d71f09aa56..906d93c4d9b 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java @@ -92,7 +92,11 @@ void removingAnInheritedKnownTagTombstonesItWithoutCountingIt() { } IdStore ids = child.knownStore; assertEquals(0, ids.computeSize()); - assertTrue(((HashIdStore) ids).hasTombstones); + // 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 @@ -142,6 +146,70 @@ void aSpanLevelReadMissesAParentHoldingATraceLevelTagInItsSlot() throws Exceptio assertEquals("abc", span.getValue(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.set(KnownTags.RUNTIME_ID, "abc"); + DenseIdStore span = new DenseIdStore(traceLevel); + long other = + spanLevelId == KnownTags.HTTP_ROUTE_ID ? KnownTags.COMPONENT_ID : KnownTags.HTTP_ROUTE_ID; + span.set(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.getValue(KnownTags.RUNTIME_ID)); + } + + @Test + void aClearDenseSlotBitIsADefiniteMiss() { + DenseIdStore ids = new DenseIdStore(null); + assertEquals(0L, ids.spanOccupancy); + assertNull(ids.getEntryReader(KnownTags.HTTP_ROUTE_ID)); + + ids.set(KnownTags.HTTP_ROUTE_ID, "/a"); + assertNotEquals(0L, ids.spanOccupancy & DenseIdStore.slotBit(KnownTags.HTTP_ROUTE_ID)); + assertEquals("/a", ids.getValue(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.getValue(KnownTags.HTTP_ROUTE_ID)); + } + + @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.set(KnownTags.RUNTIME_ID, "abc"); + ids.set(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.set(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.getValue(KnownTags.HTTP_ROUTE_ID)); + assertEquals(0, child.computeSize()); + } + @Test void anUnknownIdIsRejected() { HashIdStore ids = new HashIdStore(null); diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 6cf8b38afe7..6a6c8b951b2 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": "false", + "aliases": [] + } + ], "DD_TRACE_EXPERIMENTAL_FEATURES_ENABLED": [ { "version": "A", From 14275995ce3d87a375768a8f5ec3c1a0d9222587 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 11:17:23 -0400 Subject: [PATCH 12/39] Allocate FlatHashtable tables with an array constructor, not reflection FlatHashtable minted its tables with Array.newInstance(entryClass, n), and grew them with Array.newInstance(table.getClass().getComponentType(), n), to keep a precise component type: typed reads, real array-store checks and a precise element type for the JIT. GraalVM 17's native-image rejects that call unless each entry array type is registered for reflection; the failure surfaces in a class initializer and poisons the class for the life of the process. The factories now take an array constructor instead of a Class, last per the functional-parameter convention, e.g. FlatHashtable.D1.createFixed(capacity, MyEntry[]::new). An array constructor reference compiles to a plain new MyEntry[n], so the table keeps the same precise runtime type with no reflection. D1 and D2 keep the constructor to grow, and the raw core's resize / resizingInsert take it too, so growth stays precisely typed as well. Co-Authored-By: Claude Opus 5.5 --- .../util/CaseInsensitiveMapBenchmark.java | 2 +- .../util/FlatHashtableIteratorBenchmark.java | 2 +- .../util/SingleThreadedMapBenchmark.java | 2 +- .../datadog/trace/util/FlatHashtable.java | 141 ++++++++++-------- .../trace/util/FlatHashtableD1Test.java | 6 +- .../trace/util/FlatHashtableD2Test.java | 6 +- .../datadog/trace/util/FlatHashtableTest.java | 75 ++++++---- 7 files changed, 131 insertions(+), 103 deletions(-) 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/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/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); From 3c91e7f27b93c1c756e976e58f5693c9ccdd325d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 11:49:11 -0400 Subject: [PATCH 13/39] Attach HeaderPathBenchmark's server span through the local tracer Context.attach() and Context.current() go to the global context manager, which a standalone JMH run never wires to the benchmark's CoreTracer. The JDBC and client spans became separate root traces instead of children of the extracted server span. Use the tracer's attach/currentContext, and check at setup that the outbound call continues the extracted trace. Co-Authored-By: Claude Opus 5.5 --- .../datadog/trace/core/HeaderPathBenchmark.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 index d73d130d069..e9e2d73b517 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/HeaderPathBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/HeaderPathBenchmark.java @@ -134,7 +134,8 @@ public void setup(Blackhole blackhole) { } /** - * Fails fast if the headers aren't actually extracted and injected, which would measure nothing. + * 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); @@ -143,9 +144,14 @@ void checkPropagation() { } Outbound outbound = new Outbound(); request(outbound); - if (!outbound.headers.containsKey("x-datadog-trace-id")) { + 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 @@ -194,7 +200,7 @@ public void request(Outbound outbound) { server.setTag(Tags.HTTP_ROUTE, "/owners/{ownerId}"); server.setTag(Tags.PEER_PORT, 54321); - try (ContextScope serverScope = parent.with(server).attach()) { + 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); @@ -216,7 +222,7 @@ public void request(Outbound outbound) { client.setTag(Tags.PEER_HOSTNAME, "inventory.internal"); client.setTag(Tags.PEER_PORT, 8080); Propagators.defaultPropagator() - .inject(Context.current().with(client), outbound.headers, SETTER); + .inject(tracer.currentContext().with(client), outbound.headers, SETTER); client.setHttpStatusCode(200); client.finish(); } From 162124def28d1cb9f0ec1b5c200265fefae5e91d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 23 Oct 2026 00:12:56 -0400 Subject: [PATCH 14/39] Presize dense tag stores and seed span prototypes in bulk Turns the dense tag store on by default (trace.experimental.dense.tags.enabled=false switches back to the hash store) and makes it cheaper: - Each span's dense store is presized from a per-operation sizing hint (SizingHint / SizingHintTable: a bounded, racy table keyed by operation name, with separate lanes for entry and child spans). The hint lives on the DenseIdStore, so flag-off spans carry no extra field. A span records its size when it's serialized, or, in a dropped trace, in CoreTracer.write after the early post-processors and the root's sampling decision have added their tags. - The dense store's write paths resolve the slot index first and store the value once, so C2 expands one reference-store barrier per path. - A SpanPrototype's known tags are seeded in bulk (IdStore.seedAbsent) when the source's occupancy words share no bit with the target's; otherwise seeding falls back to tag by tag. The decision is cached on the prototype, along with its sizing hint. - singleSpanBuilder(SpanPrototype, ...) mirrors the plain entry points. - HashStore and IdStore get a public forEach, for the prototype paths. TraceShapeBenchmark records each span's size before dropping the trace, as serialization would, so the hints converge in the benchmark too. Co-Authored-By: Claude Opus 5.5 --- .../trace/core/SpanCreationBenchmark.java | 51 ++++ .../trace/core/TraceShapeBenchmark.java | 42 +++- .../java/datadog/trace/core/CoreTracer.java | 51 +++- .../datadog/trace/core/DDSpanContext.java | 237 +++++++++++++++--- .../core/taginterceptor/TagInterceptor.java | 19 ++ ...nPrototypeConstructionDenseForkedTest.java | 109 ++++++++ .../core/SpanPrototypeConstructionTest.java | 93 +++++++ .../main/java/datadog/trace/api/Config.java | 2 +- .../java/datadog/trace/api/DenseIdStore.java | 142 +++++++++-- .../java/datadog/trace/api/HashIdStore.java | 7 +- .../java/datadog/trace/api/HashStore.java | 7 +- .../main/java/datadog/trace/api/IdStore.java | 42 +++- .../datadog/trace/api/IdStoreFactory.java | 27 +- .../java/datadog/trace/api/SizingHint.java | 56 +++++ .../datadog/trace/api/SizingHintTable.java | 101 ++++++++ .../main/java/datadog/trace/api/TagMap.java | 6 + .../instrumentation/api/AgentTracer.java | 15 ++ .../instrumentation/api/SpanPrototype.java | 65 +++++ .../trace/api/SizingHintTableForkedTest.java | 218 ++++++++++++++++ .../SizingHintTableHashOnlyForkedTest.java | 59 +++++ .../java/datadog/trace/api/TagStoresTest.java | 67 +++++ .../api/SpanPrototypeTest.java | 35 +++ metadata/supported-configurations.json | 2 +- 23 files changed, 1374 insertions(+), 79 deletions(-) create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java create mode 100644 internal-api/src/main/java/datadog/trace/api/SizingHint.java create mode 100644 internal-api/src/main/java/datadog/trace/api/SizingHintTable.java create mode 100644 internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java create mode 100644 internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java 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 index 46e2887084e..12a8583a61c 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java @@ -8,6 +8,8 @@ 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; @@ -50,7 +52,8 @@ * 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). + * 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) @@ -111,7 +114,7 @@ public class TraceShapeBenchmark { @Setup public void setup(Blackhole blackhole) { - this.tracer = CoreTracer.builder().writer(new DropWriter(blackhole)).build(); + this.tracer = CoreTracer.builder().writer(new SizeRecordingWriter(blackhole)).build(); // the decorators' prototype chains: Base (type, component) -> Server / Client (kind, language) this.serverProto = SpanPrototype.builder() @@ -275,6 +278,41 @@ public void newApi() { 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); 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 dda914cc6d7..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; @@ -1161,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(); @@ -1443,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 @@ -1456,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); } @@ -2374,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( @@ -2401,7 +2443,8 @@ protected static final DDSpanContext buildSpanContext( tracer.profilingContextIntegration, tracer.injectBaggageAsTags, tracer.injectLinksAsTags, - tagParent); + 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: 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 845b3f3430e..5bae7c1eef6 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 @@ -20,6 +20,7 @@ import datadog.trace.api.KnownTagCodec; import datadog.trace.api.KnownTags; import datadog.trace.api.ProcessTags; +import datadog.trace.api.SizingHint; import datadog.trace.api.SpanStorage; import datadog.trace.api.TagMap; import datadog.trace.api.cache.DDCache; @@ -60,6 +61,7 @@ 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; @@ -394,6 +396,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, @@ -420,6 +426,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; @@ -447,7 +509,7 @@ public DDSpanContext( // Reads through to the frozen tracer-level tags, if any. TagMap parentTags = TagMap.readThroughParent(readThroughParent); - this.knownTags = IdStore.create(parentTags); + this.knownTags = IdStore.create(parentTags, sizingHint); this.customTags = HashStore.inheritedFrom(parentTags); this.customTagsBorrowed = this.customTags != null; @@ -647,11 +709,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) { @@ -660,7 +719,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) { @@ -681,7 +740,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) { @@ -690,25 +751,131 @@ 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 tags are seeded into a span, cached on the prototype for the + * interceptor it was checked against ({@link SpanPrototype#seedPlan}). */ - 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 (cached instanceof SeedPlan && ((SeedPlan) cached).interceptor == tagInterceptor) { + return (SeedPlan) cached; } + TagMap tags = prototype.tags(); + TagMap.Entry kind = tags.getEntry(KnownTags.SPAN_KIND_ID); + SeedPlan plan = + new SeedPlan( + tagInterceptor, + tags.knownStore().supportsBulkSeed() && tagInterceptor.canSeedInBulk(tags), + kind == null ? SPAN_KIND_UNSET : spanKindOrdinalOf(kind.stringValue())); + prototype.seedPlan(plan); + return plan; + } + + /** + * 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) { - map.forEach( - this, - (ctx, tagEntry) -> { - if (ctx.isPresent(tagEntry)) { - return; - } - if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.routePut(tagEntry); - } - }); + 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 + } + 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); } } @@ -1520,13 +1687,7 @@ void setAllTags(final TagMap map, boolean 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.routePut(tagEntry); - } - }); + map.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); } else { routePutAll(map); } @@ -1762,6 +1923,15 @@ 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 (knownTags) { TagsPostProcessorFactory.eagerProcessor().processTags(this, this, links); @@ -1817,6 +1987,11 @@ void processTagsAndBaggage( } } + // 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) { 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/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/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index c5e7111568a..3faf1a2ce6a 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -3457,7 +3457,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) this.spanBuilderReuseEnabled = configProvider.getBoolean(GeneralConfig.SPAN_BUILDER_REUSE_ENABLED, true); - this.traceDenseTagsEnabled = configProvider.getBoolean(TRACE_DENSE_TAGS_ENABLED, false); + 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 = diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java index d0aa1fee4cc..6d610d4dac9 100644 --- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -6,6 +6,7 @@ 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 @@ -223,6 +224,13 @@ public EntryReader next() { /** 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 @@ -231,6 +239,16 @@ public EntryReader next() { 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) { @@ -240,6 +258,14 @@ public EntryReader next() { 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. */ @@ -290,6 +316,70 @@ void markTombstone(long bit, int level) { 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; + } + + /** 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. @@ -410,11 +500,11 @@ public EntryReader getAndPut(EntryReader entryReader) { this.markLocal(bit, level); this.count++; } - this.values[i] = entryReader.objectValue(); } else { prior = this.mayInherit(bit, level) ? this.parent.getVisible(serial, bit, level) : null; - this.append(serial, bit, level, entryReader.objectValue()); + i = this.appendSlot(serial, bit, level); } + this.values[i] = entryReader.objectValue(); // the one reference store; see store return this.readerOf(tagId, prior); } @@ -444,7 +534,8 @@ public EntryReader remove(long tagId) { this.values[i] = TOMBSTONE; this.count--; } else { - this.appendSlot(serial, TOMBSTONE); + 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); @@ -463,35 +554,43 @@ private Object store(long tagId, Object value) { long bit = slotBit(tagId); int level = levelOf(tagId); int i = this.mayHaveLocal(bit, level) ? this.indexOf(serial) : -1; + Object prior; if (i >= 0) { - Object prior = this.values[i]; - this.values[i] = value; + 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++; // a tombstone replaced is a removal undone: no prior local value - return null; + this.count++; } - return prior; + } else { + prior = null; + i = this.appendSlot(serial, bit, level); } - this.append(serial, bit, level, value); - return null; + // 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; } - /** Appends a live local tag. */ - private void append(char serial, long bit, int level, Object value) { - this.appendSlot(serial, value); + /** 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; } - private void appendSlot(char serial, Object value) { + /** 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.values[i] = value; this.used = i + 1; + return i; } /** Removes slot {@code i}, swapping the last slot into its place. The masks stay as they were. */ @@ -522,11 +621,9 @@ private void grow() { if (length == 0) { this.serials = new char[INIT_CAP]; this.values = new Object[INIT_CAP]; - return; + } else { + this.growTo(length + 1); // doubles, bounded by the serial limit } - int newCap = Math.max(Math.min(length << 1, KnownTagCodec.serialLimit()), length + 1); - this.serials = Arrays.copyOf(this.serials, newCap); - this.values = Arrays.copyOf(this.values, newCap); } /** The reused flyweight over a write's prior {@code value}, or null when there is none. */ @@ -629,7 +726,7 @@ private boolean readIfVisible(DenseIdStore level, int i, DenseReader reader) { } @Override - void forEach(Consumer consumer) { + 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) { @@ -641,7 +738,7 @@ void forEach(Consumer consumer) { } @Override - void forEach(T thisObj, BiConsumer consumer) { + 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) { @@ -653,7 +750,8 @@ void forEach(T thisObj, BiConsumer consumer) { } @Override - void forEach(T thisObj, U otherObj, TriConsumer consumer) { + 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) { diff --git a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java index 557dc730687..8dbbb1694d5 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -306,7 +306,7 @@ boolean parentEntryVisible(Entry parentEntry, HashIdStore fromAncestor) { } @Override - void forEach(Consumer consumer) { + public void forEach(Consumer consumer) { HashSupport.forEach(this.buckets, consumer); if (this.parent != null) { this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); @@ -314,7 +314,7 @@ void forEach(Consumer consumer) { } @Override - void forEach(T thisObj, BiConsumer consumer) { + 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)); @@ -322,7 +322,8 @@ void forEach(T thisObj, BiConsumer consumer) { } @Override - void forEach(T thisObj, U otherObj, TriConsumer consumer) { + 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); diff --git a/internal-api/src/main/java/datadog/trace/api/HashStore.java b/internal-api/src/main/java/datadog/trace/api/HashStore.java index 1b0a38df6e1..aa8e0536865 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java @@ -271,21 +271,22 @@ boolean parentEntryVisible(Entry parentEntry, HashStore fromAncestor) { } /** Visits the local entries, then the visible inherited ones. */ - void forEach(Consumer consumer) { + public void forEach(Consumer consumer) { HashSupport.forEach(this.buckets, consumer); if (this.parent != null) { this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); } } - void forEach(T thisObj, BiConsumer consumer) { + 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)); } } - void forEach(T thisObj, U otherObj, TriConsumer consumer) { + 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); diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index 9bba5d64153..c1a79d80149 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -6,6 +6,7 @@ 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 @@ -30,7 +31,40 @@ interface Cursor { /** A fresh id store reading through to {@code parent}'s known tags (parent may be null). */ public static IdStore create(TagMap parent) { - return IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore); + 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; + } + + /** + * 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. */ @@ -130,11 +164,11 @@ static IllegalArgumentException notKnown(long tagId) { abstract boolean hasParent(); /** Visits the local entries, then the visible inherited ones. */ - abstract void forEach(Consumer consumer); + public abstract void forEach(Consumer consumer); - abstract void forEach(T thisObj, BiConsumer consumer); + public abstract void forEach(T thisObj, BiConsumer consumer); - abstract void forEach( + public abstract void forEach( T thisObj, U otherObj, TriConsumer consumer); /** diff --git a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java index 42556d6537e..e4a4a29dca8 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java @@ -1,5 +1,7 @@ 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 @@ -16,16 +18,25 @@ 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 = - Config.get().isTraceDenseTagsEnabled() - ? DenseIdStoreFactory.INSTANCE - : HashIdStoreFactory.INSTANCE; + DENSE ? DenseIdStoreFactory.INSTANCE : HashIdStoreFactory.INSTANCE; private Default() {} } /** A fresh store reading through to {@code parent} (may be null). */ - abstract IdStore create(IdStore parent); + 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(); @@ -37,8 +48,8 @@ final class HashIdStoreFactory extends IdStoreFactory { private HashIdStoreFactory() {} @Override - IdStore create(IdStore parent) { - return new HashIdStore(parent); + IdStore create(IdStore parent, @Nullable SizingHint hint) { + return new HashIdStore(parent); // buckets aren't sized per span; a hint is only for dense } @Override @@ -53,8 +64,8 @@ final class DenseIdStoreFactory extends IdStoreFactory { private DenseIdStoreFactory() {} @Override - IdStore create(IdStore parent) { - return new DenseIdStore(parent); + IdStore create(IdStore parent, @Nullable SizingHint hint) { + return new DenseIdStore(parent, hint); } @Override 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 4d485e86612..6d5338881ab 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1325,6 +1325,12 @@ 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() { 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/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/test/java/datadog/trace/api/SizingHintTableForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java new file mode 100644 index 00000000000..cd62bad0c2b --- /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.set(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..c32823b740c --- /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.set(KnownTags.COMPONENT_ID, "okhttp"); + store.set(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/TagStoresTest.java b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java index 906d93c4d9b..bd9ba9707ca 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java @@ -183,6 +183,73 @@ void aClearDenseSlotBitIsADefiniteMiss() { assertEquals("/a", child.getValue(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.set(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.set(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.getValue(KnownTags.COMPONENT_ID)); + assertEquals("span.kind", target.getValue(KnownTags.SPAN_KIND_ID)); + assertEquals("/a", target.getValue(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.set(KnownTags.COMPONENT_ID, "explicit"); + + assertFalse(target.seedAbsent(source)); + assertEquals(1, target.size(), "nothing copied"); + assertEquals("explicit", target.getValue(KnownTags.COMPONENT_ID)); + } + + @Test + void seedAbsentRefusesASourceWithAParentOrTombstones() { + DenseIdStore parent = denseSource(KnownTags.COMPONENT_ID); + DenseIdStore child = new DenseIdStore(parent); + child.set(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.getValue(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))); + } + @Test void aTraceLevelTagDoesNotDefeatADenseSpanLevelMissInTheSameSlot() { // A local root span holds trace-level and span-level tags in one store, and the two levels are 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/metadata/supported-configurations.json b/metadata/supported-configurations.json index 6a6c8b951b2..3861b8aa5c9 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -5864,7 +5864,7 @@ { "version": "A", "type": "boolean", - "default": "false", + "default": "true", "aliases": [] } ], From 44cd41fa0c51f572822971dd3de0c2b5f3f8f654 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 23 Oct 2026 15:06:57 -0400 Subject: [PATCH 15/39] Take the span's tag lock once in setTagsFrom SpanData is what a TagContributor or TagExtractor may read and set on a span: the setTag overloads (by name and by id) and getTag, with the span's tag semantics (a null value removes the tag, the interceptor runs), plus setResourceName, setError and setMeasured. AgentSpan extends it with covariant returns, and the contributor and extractor now take a SpanData. DDSpanContext is a SpanData too: its setTag family returns this. Its setTagsFrom takes the tag lock once and hands the contributor a HeldSpanData that lives only for that call: it forwards to new package-private *HoldingLock variants of setTag, removeTag and getTag (the same bodies without the synchronized), and asserts the lock is still held. Contributor call sites are monomorphic by design, so the wrapper should be scalar-replaced. The public setters are unchanged. SetTagsFromBenchmark compares a jdbc span's nine tags one by one, through a contributor and through an extractor, on a reused and a fresh span. Co-Authored-By: Claude Opus 5.5 --- .../trace/core/SetTagsFromBenchmark.java | 147 +++++ .../main/java/datadog/trace/core/DDSpan.java | 26 + .../datadog/trace/core/DDSpanContext.java | 515 ++++++++++++++++-- .../trace/core/DDSpanSetTagsFromTest.java | 147 +++++ .../instrumentation/api/AgentSpan.java | 3 +- .../instrumentation/api/SpanData.java | 59 ++ .../instrumentation/api/TagContributor.java | 11 +- .../instrumentation/api/TagExtractor.java | 9 +- 8 files changed, 872 insertions(+), 45 deletions(-) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/SetTagsFromBenchmark.java create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/DDSpanSetTagsFromTest.java create mode 100644 internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanData.java 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/main/java/datadog/trace/core/DDSpan.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java index e15a7ed49cb..df38b71aed7 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; @@ -37,6 +38,8 @@ 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 +592,29 @@ 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; + } + @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 5bae7c1eef6..e8020084cba 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 @@ -27,6 +27,7 @@ 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; @@ -38,10 +39,14 @@ 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.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; @@ -75,7 +80,12 @@ * the associated Span instance */ public class DDSpanContext - implements AgentSpanContext, RequestContext, TraceSegment, ProfilerContext, SpanStorage { + implements AgentSpanContext, + RequestContext, + TraceSegment, + ProfilerContext, + SpanStorage, + SpanData { private static final Logger log = LoggerFactory.getLogger(DDSpanContext.class); public static final String PRIORITY_SAMPLING_KEY = "_sampling_priority_v1"; @@ -676,10 +686,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() { @@ -1433,9 +1445,10 @@ private boolean isPresent(TagMap.EntryReader entry) { * @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) { @@ -1447,11 +1460,13 @@ public void setTag(final String tag, final Object value) { customTagsForWrite().set(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) { @@ -1463,6 +1478,7 @@ public void setTag(final String tag, final String value) { customTagsForWrite().set(tag, value); // already known to be custom } } + return this; } /* @@ -1470,39 +1486,44 @@ 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); - return; + return this; } if (!tagInterceptor.needsIntercept(tagId) || !tagInterceptor.interceptTag(this, tagId, value)) { synchronized (knownTags) { knownTags.set(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); - return; + return this; } if (!tagInterceptor.needsIntercept(tagId) || !tagInterceptor.interceptTag(this, tagId, value)) { synchronized (knownTags) { knownTags.set(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); @@ -1511,11 +1532,13 @@ public void setTag(final long tagId, final boolean value) { knownTags.set(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); @@ -1524,11 +1547,13 @@ public void setTag(final long tagId, final int value) { knownTags.set(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); @@ -1537,11 +1562,13 @@ public void setTag(final long tagId, final long value) { knownTags.set(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); @@ -1550,11 +1577,13 @@ public void setTag(final long tagId, final float value) { knownTags.set(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); @@ -1563,11 +1592,13 @@ public void setTag(final long tagId, final double value) { knownTags.set(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 @@ -1576,6 +1607,7 @@ public void setTag(TagMap.EntryReader entry) { routePut(entry); } } + return this; } /* @@ -1598,9 +1630,10 @@ private void setBox(long tagId, Object 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) { @@ -1611,11 +1644,13 @@ public void setTag(final String tag, final boolean value) { customTagsForWrite().set(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) { @@ -1626,11 +1661,13 @@ public void setTag(final String tag, final int value) { customTagsForWrite().set(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) { @@ -1641,11 +1678,13 @@ public void setTag(final String tag, final long value) { customTagsForWrite().set(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) { @@ -1656,11 +1695,13 @@ public void setTag(final String tag, final float value) { customTagsForWrite().set(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) { @@ -1671,6 +1712,409 @@ public void setTag(final String tag, final double value) { customTagsForWrite().set(tag, value); // already known to be custom } } + return this; + } + + /* + * 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 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().set(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().set(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.set(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.set(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.set(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.set(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.set(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.set(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.set(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().set(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().set(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().set(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().set(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().set(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.set(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)); + } + } + + /** 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) { @@ -1756,7 +2200,8 @@ void unsafeSetTag(final String tag, final double value) { routeSet(tag, value); } - Object getTag(final String key) { + @Override + public Object getTag(final String key) { switch (key) { case DDTags.THREAD_ID: return threadId; 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/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..388d311c7b2 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}. 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..df5d10c93a4 --- /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.SpanStorage}, 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/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); } From f1b60e47b3cb64b6f948939c1ba46898a0a83ef2 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 11:56:51 -0400 Subject: [PATCH 16/39] Remove an earlier payload tag when a later value at its path is null With null now meaning remove, a JSON null set no tag at all, so a duplicate key such as {"x":"old","x":null} left the stale x=old tag. Setting null again removes it, as overwriting with null did before. Co-Authored-By: Claude Opus 5.5 --- .../core/tagprocessor/PayloadTagsProcessor.java | 3 ++- .../tagprocessor/PayloadTagsProcessorTest.java | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 860cff77d68..c927e4f6a54 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,7 +245,8 @@ public void doubleValue(PathCursor path, double value) { @Override public void nullValue(PathCursor path) { - // a JSON null sets no tag: on a span, a null value means remove + // a JSON null sets no tag, and removes one an earlier value set at the same path + collectedTags.set(path.toString(tagPrefix), null); } @Override 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 85e3dad0ebc..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 @@ -253,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(); From 884c508dacf68901f7f4c3ec7103ee81940de20a Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 12:56:12 -0400 Subject: [PATCH 17/39] Load and store known tags by id through SpanStorage The eager post-processors only touch known tags, yet loaded and stored them by name, so every call classified the name first. SpanStorage gains id variants of load, loadString and store, and the processors, the peer service naming and the endpoint resolver use KnownTags ids. A known tag's id and name address the same tag. The peer service precursor lists stay names: they include tags outside the registry. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/core/DDSpanContext.java | 15 +++++++++++++++ .../tagprocessor/HttpEndpointPostProcessor.java | 12 ++++++------ .../core/tagprocessor/InternalTagsAdder.java | 10 +++++----- .../tagprocessor/PeerServiceCalculator.java | 13 +++++++------ .../java/datadog/trace/api/SpanStorage.java | 16 ++++++++++++++-- .../src/main/java/datadog/trace/api/TagMap.java | 17 +++++++++++++++++ .../trace/api/endpoint/EndpointResolver.java | 6 +++--- .../api/naming/v1/PeerServiceNamingV1.java | 14 +++++++++----- 8 files changed, 76 insertions(+), 27 deletions(-) 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 8fdf11b8a85..044314cecf4 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 @@ -1411,11 +1411,26 @@ public String loadString(String tag) { return unsafeTags.getString(tag); } + @Override + public Object load(long tagId) { + return unsafeTags.load(tagId); + } + + @Override + public String loadString(long tagId) { + return unsafeTags.loadString(tagId); + } + @Override public void store(String tag, Object value) { unsafeTags.set(tag, value); } + @Override + public void store(long tagId, Object value) { + unsafeTags.set(tagId, value); + } + @Override public void store(TagMap.EntryReader entry) { unsafeTags.set(entry); 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 6e6f9cfd829..9f1a9b8a51e 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,8 +1,8 @@ package datadog.trace.core.tagprocessor; -import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_METHOD; -import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ROUTE; -import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_URL; +import static datadog.trace.api.KnownTags.HTTP_METHOD_ID; +import static datadog.trace.api.KnownTags.HTTP_ROUTE_ID; +import static datadog.trace.api.KnownTags.HTTP_URL_ID; import datadog.trace.api.SpanStorage; import datadog.trace.api.endpoint.EndpointResolver; @@ -61,13 +61,13 @@ public void processTags( return; } - if (unsafeTags.load(HTTP_METHOD) == null) { + if (unsafeTags.load(HTTP_METHOD_ID) == null) { return; } try { - String httpRoute = unsafeTags.loadString(HTTP_ROUTE); - String httpUrl = unsafeTags.loadString(HTTP_URL); + String httpRoute = unsafeTags.loadString(HTTP_ROUTE_ID); + String httpUrl = unsafeTags.loadString(HTTP_URL_ID); endpointResolver.resolveEndpoint(unsafeTags, httpRoute, httpUrl); } catch (Throwable t) { log.debug("Error processing HTTP endpoint for span {}", spanContext.getSpanId(), t); 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 afcf8c30438..8847b833c44 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,8 +1,8 @@ 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.SpanStorage; import datadog.trace.api.TagMap; import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; @@ -20,10 +20,10 @@ public final class InternalTagsAdder extends EagerTagsPostProcessor { 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; } @@ -42,7 +42,7 @@ public void processTags( } 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.load(VERSION) == null) { + if (versionEntry != null && unsafeTags.load(VERSION_ID) == null) { unsafeTags.store(versionEntry); } } 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 8d5d0513962..83a39c52002 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,13 +1,14 @@ 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.SpanStorage; 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; @@ -35,7 +36,7 @@ public PeerServiceCalculator() { @Override public void processTags( SpanStorage unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { - Object peerService = unsafeTags.load(Tags.PEER_SERVICE); + Object peerService = unsafeTags.load(PEER_SERVICE_ID); // the user set it if (peerService != null) { if (canRemap) { @@ -46,7 +47,7 @@ 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.load(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 @@ -56,8 +57,8 @@ private void remapPeerService(SpanStorage unsafeTags, Object value) { if (value != null) { String mapped = peerServiceMapping.get(value); if (mapped != null) { - unsafeTags.store(Tags.PEER_SERVICE, mapped); - unsafeTags.store(DDTags.PEER_SERVICE_REMAPPED_FROM, value); + unsafeTags.store(PEER_SERVICE_ID, mapped); + unsafeTags.store(DD_PEER_SERVICE_REMAPPED_FROM_ID, value); } } } diff --git a/internal-api/src/main/java/datadog/trace/api/SpanStorage.java b/internal-api/src/main/java/datadog/trace/api/SpanStorage.java index 09b2fab946d..8a5f18b1b99 100644 --- a/internal-api/src/main/java/datadog/trace/api/SpanStorage.java +++ b/internal-api/src/main/java/datadog/trace/api/SpanStorage.java @@ -9,8 +9,11 @@ * get}/{@code set} and the span's intercepted {@code getTag}/{@code setTag}, so a type can offer * several of these without a clash. * - *

    This is the first slice: only what the eager tag post-processors need. Id variants, typed - * loads and primitive stores come as more of the pipeline moves to it. + *

    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. + * + *

    This is the first slice: only what the eager tag post-processors need. Typed loads and + * primitive stores come as more of the pipeline moves to it. */ public interface SpanStorage { /** The tag's value, or null when it's absent. */ @@ -19,9 +22,18 @@ public interface SpanStorage { /** 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/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index b4cc9997222..7de3856b002 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1359,11 +1359,28 @@ public String loadString(String tag) { return this.getString(tag); } + @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(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); 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 30114ca2372..f9877d708bf 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,6 +1,6 @@ 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.SpanStorage; import javax.annotation.Nullable; @@ -60,7 +60,7 @@ public String resolveEndpoint( if (alwaysSimplifiedEndpoint) { String endpoint = computeEndpoint(httpUrl); if (endpoint != null) { - unsafeTags.store(HTTP_ENDPOINT, endpoint); + unsafeTags.store(HTTP_ENDPOINT_ID, endpoint); } return endpoint; } @@ -73,7 +73,7 @@ public String resolveEndpoint( // Compute endpoint from URL and tag the span String endpoint = computeEndpoint(httpUrl); if (endpoint != null) { - unsafeTags.store(HTTP_ENDPOINT, endpoint); + unsafeTags.store(HTTP_ENDPOINT_ID, endpoint); } return endpoint; } 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 021dbda7aee..ae1e7d6ed19 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,6 +1,10 @@ package datadog.trace.api.naming.v1; -import datadog.trace.api.DDTags; +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.SpanStorage; import datadog.trace.api.naming.NamingSchema; import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags; @@ -54,7 +58,7 @@ public boolean supports() { } private void resolve(@Nonnull final SpanStorage unsafeTags) { - final Object component = unsafeTags.load(Tags.COMPONENT); + 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); @@ -93,8 +97,8 @@ private boolean resolveBy( private void set(@Nonnull final SpanStorage unsafeTags, Object value, String source) { if (value != null) { - unsafeTags.store(Tags.PEER_SERVICE, value); - unsafeTags.store(DDTags.PEER_SERVICE_SOURCE, source); + unsafeTags.store(PEER_SERVICE_ID, value); + unsafeTags.store(DD_PEER_SERVICE_SOURCE_ID, source); } } @@ -102,7 +106,7 @@ private void set(@Nonnull final SpanStorage unsafeTags, Object value, String sou @Override public void tags(@Nonnull final SpanStorage unsafeTags) { // check span.kind eligibility - final Object kind = unsafeTags.load(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); From 590f6e7e1bf653b30081edd3e2b74ad997b18257 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 12:56:14 -0400 Subject: [PATCH 18/39] Test that a known tag's id and name address the same tag in SpanStorage Co-Authored-By: Claude Opus 5.5 --- .../datadog/trace/core/SpanStorageTest.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/SpanStorageTest.java diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanStorageTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanStorageTest.java new file mode 100644 index 00000000000..3c6c24277d1 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanStorageTest.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.SpanStorage; +import datadog.trace.api.TagMap; +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 SpanStorage}. */ +class SpanStorageTest extends DDCoreJavaSpecification { + private CoreTracer tracer; + + @BeforeEach + void setup() { + tracer = tracerBuilder().writer(new ListWriter()).build(); + } + + @AfterEach + void cleanup() { + tracer.close(); + } + + @Test + void storedByIdLoadsByName() { + forEachStorage( + storage -> { + storage.store(HTTP_ROUTE_ID, "/users/{id}"); + + assertEquals("/users/{id}", storage.load(HTTP_ROUTE)); + assertEquals("/users/{id}", storage.load(HTTP_ROUTE_ID)); + }); + } + + @Test + void storedByNameLoadsById() { + forEachStorage( + storage -> { + storage.store(HTTP_ROUTE, "/users/{id}"); + + assertEquals("/users/{id}", storage.load(HTTP_ROUTE_ID)); + }); + } + + @Test + void loadStringByIdConverts() { + forEachStorage( + storage -> { + storage.store(HTTP_STATUS_CODE_ID, 404); + + assertEquals("404", storage.loadString(HTTP_STATUS_CODE_ID)); + assertEquals("404", storage.loadString(HTTP_STATUS)); + }); + } + + @Test + void nullStoredByIdRemoves() { + forEachStorage( + storage -> { + storage.store(HTTP_ROUTE, "/users/{id}"); + + storage.store(HTTP_ROUTE_ID, null); + + assertNull(storage.load(HTTP_ROUTE)); + assertNull(storage.loadString(HTTP_ROUTE_ID)); + }); + } + + private void forEachStorage(Consumer check) { + check.accept(TagMap.create()); + + DDSpan span = (DDSpan) tracer.buildSpan("test", "span").start(); + try { + check.accept(span.spanContext()); + } finally { + span.finish(); + } + } +} From 52a5a5b6049c37b6c2bab8bfe4f468e7ac0f0a81 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 13:01:49 -0400 Subject: [PATCH 19/39] Load known tags by id from the id store, without an entry TagMap's id loads went through getEntry, which a store without an entry per tag answers by building one. Read the value from the id store directly, as the name loads already do. Co-Authored-By: Claude Opus 5.5 --- internal-api/src/main/java/datadog/trace/api/TagMap.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 24abca79254..a7e2a7fb71e 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1450,14 +1450,12 @@ public String loadString(String tag) { @Override public Object load(long tagId) { - Entry entry = this.getEntry(tagId); - return entry == null ? null : entry.objectValue(); + return this.knownStore.getValue(tagId); } @Override public String loadString(long tagId) { - Entry entry = this.getEntry(tagId); - return entry == null ? null : entry.stringValue(); + return this.knownStore.getString(tagId); } @Override From afad98bc652a192eddc4830ae66a4816f9844173 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 14:47:49 -0400 Subject: [PATCH 20/39] Load and store values in the tag stores, as through SpanStorage The stores' value methods were get/set, the verbs of TagMap's Map surface and the span's intercepted setTag, though the stores are the raw layer SpanStorage reads and writes. Values now move by load, loadString and store, so the span's SpanStorage methods delegate verb for verb; entries keep getEntry, put and remove. HashStore.get, which answers an entry, becomes getEntry. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/api/HashIdStore.java | 18 +- .../java/datadog/trace/api/HashStore.java | 21 ++- .../main/java/datadog/trace/api/IdStore.java | 25 +-- .../trace/api/IdStoreContractTest.java | 176 +++++++++--------- 4 files changed, 123 insertions(+), 117 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java index 557dc730687..86064f688b2 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -103,19 +103,19 @@ private Entry getVisible(long tagId) { } @Override - public Object getValue(long tagId) { + public Object load(long tagId) { Entry entry = this.getEntry(tagId); return entry == null ? null : entry.objectValue(); } @Override - public String getString(long tagId) { + public String loadString(long tagId) { Entry entry = this.getEntry(tagId); return entry == null ? null : entry.stringValue(); } @Override - public void set(long tagId, Object value) { + public void store(long tagId, Object value) { if (value == null) { this.remove(tagId); // a null value removes the tag return; @@ -124,7 +124,7 @@ public void set(long tagId, Object value) { } @Override - public void set(long tagId, CharSequence value) { + public void store(long tagId, CharSequence value) { if (value == null) { this.remove(tagId); // a null value removes the tag return; @@ -133,27 +133,27 @@ public void set(long tagId, CharSequence value) { } @Override - public void set(long tagId, boolean value) { + public void store(long tagId, boolean value) { this.put(Entry.newBooleanEntry(tagId, value)); } @Override - public void set(long tagId, int value) { + public void store(long tagId, int value) { this.put(Entry.newIntEntry(tagId, value)); } @Override - public void set(long tagId, long value) { + public void store(long tagId, long value) { this.put(Entry.newLongEntry(tagId, value)); } @Override - public void set(long tagId, float value) { + public void store(long tagId, float value) { this.put(Entry.newFloatEntry(tagId, value)); } @Override - public void set(long tagId, double value) { + public void store(long tagId, double value) { this.put(Entry.newDoubleEntry(tagId, value)); } diff --git a/internal-api/src/main/java/datadog/trace/api/HashStore.java b/internal-api/src/main/java/datadog/trace/api/HashStore.java index 3b746cb5244..4629a064d18 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java @@ -17,6 +17,9 @@ * 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 SpanStorage}; 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. @@ -118,7 +121,7 @@ int estimateSize() { } /** The custom tag {@code tag}'s entry, reading through to the parent chain. */ - public Entry get(String tag) { + 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))); } @@ -139,10 +142,10 @@ private Entry get(String tag, int hash) { } /* - * Sets the custom tag {@code tag}, which must not be a known tag's name. + * Stores the custom tag {@code tag}, which must not be a known tag's name. */ - public void set(String tag, Object value) { + public void store(String tag, Object value) { if (value == null) { this.remove(tag); // a null value removes the tag return; @@ -150,7 +153,7 @@ public void set(String tag, Object value) { this.put(Entry.newAnyEntry(tag, value)); } - public void set(String tag, CharSequence value) { + public void store(String tag, CharSequence value) { if (value == null) { this.remove(tag); // a null value removes the tag return; @@ -158,23 +161,23 @@ public void set(String tag, CharSequence value) { this.put(Entry.newObjectEntry(tag, value)); } - public void set(String tag, boolean value) { + public void store(String tag, boolean value) { this.put(Entry.newBooleanEntry(tag, value)); } - public void set(String tag, int value) { + public void store(String tag, int value) { this.put(Entry.newIntEntry(tag, value)); } - public void set(String tag, long value) { + public void store(String tag, long value) { this.put(Entry.newLongEntry(tag, value)); } - public void set(String tag, float value) { + public void store(String tag, float value) { this.put(Entry.newFloatEntry(tag, value)); } - public void set(String tag, double value) { + public void store(String tag, double value) { this.put(Entry.newDoubleEntry(tag, value)); } diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index d9e2f1bdf86..c9d7fd5feff 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -15,6 +15,9 @@ *

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

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

    {@link IdStoreFactory} builds every store, so the JVM loads one implementation 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. @@ -60,32 +63,32 @@ static IllegalArgumentException notKnown(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 getValue(long tagId); + public abstract Object load(long tagId); /** - * {@link #getValue} as a String, or null. A store that keeps an Entry per tag answers with the + * {@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 getString(long tagId); + public abstract String loadString(long tagId); /* - * Sets a known tag without validating its id: the caller has checked KnownTagCodec.isKnown. + * Stores a known tag without validating its id: the caller has checked KnownTagCodec.isKnown. */ - public abstract void set(long tagId, Object value); + public abstract void store(long tagId, Object value); - public abstract void set(long tagId, CharSequence value); + public abstract void store(long tagId, CharSequence value); - public abstract void set(long tagId, boolean value); + public abstract void store(long tagId, boolean value); - public abstract void set(long tagId, int value); + public abstract void store(long tagId, int value); - public abstract void set(long tagId, long value); + public abstract void store(long tagId, long value); - public abstract void set(long tagId, float value); + public abstract void store(long tagId, float value); - public abstract void set(long tagId, double 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 diff --git a/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java index 9662c577240..0d35128b933 100644 --- a/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java @@ -56,21 +56,21 @@ private static Map visible(IdStore store) { @MethodSource("stores") void typedSettersRoundTripThroughTheReader(Function newStore) { IdStore store = newStore.apply(null); - store.set(KnownTags.HTTP_ROUTE_ID, (Object) "/users"); - store.set(KnownTags.HTTP_METHOD_ID, (CharSequence) "GET"); - store.set(KnownTags.ERROR_ID, true); - store.set(KnownTags.HTTP_STATUS_CODE_ID, 200); - store.set(KnownTags.PEER_PORT_ID, 5432L); - store.set(KnownTags.DD_AGENT_PSR_ID, 0.5f); - store.set(KnownTags.DB_TYPE_ID, (Object) "postgres"); - store.set(KnownTags.COMPONENT_ID, (CharSequence) "jdbc"); - store.set(KnownTags.SPAN_KIND_ID, (Object) "client"); - store.set(KnownTags.DB_POOL_NAME_ID, (Object) "pool"); - store.set(KnownTags.HTTP_URL_ID, (Object) "http://x"); - store.set(KnownTags.DB_INSTANCE_ID, 2.5d); - - assertEquals("/users", store.getValue(KnownTags.HTTP_ROUTE_ID)); - assertEquals("GET", store.getValue(KnownTags.HTTP_METHOD_ID)); + 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()); @@ -104,7 +104,7 @@ void typedSettersRoundTripThroughTheReader(Function newStore) @MethodSource("stores") void putAndGetAndPutReturnThePriorValue(Function newStore) { IdStore parent = newStore.apply(null); - parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); IdStore store = newStore.apply(parent); assertNull(store.put(TagMap.Entry.newAnyEntry(KnownTags.HTTP_METHOD_ID, "GET"))); @@ -115,7 +115,7 @@ void putAndGetAndPutReturnThePriorValue(Function newStore) { EntryReader inherited = store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/local")); assertEquals("/inherited", inherited.objectValue()); - assertEquals("/local", store.getValue(KnownTags.HTTP_ROUTE_ID)); + assertEquals("/local", store.load(KnownTags.HTTP_ROUTE_ID)); assertEquals( "/local", store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/again")).objectValue()); @@ -127,23 +127,23 @@ void putAndGetAndPutReturnThePriorValue(Function newStore) { @MethodSource("stores") void removingAnInheritedTagHidesItUntilItIsSetAgain(Function newStore) { IdStore parent = newStore.apply(null); - parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); - parent.set(KnownTags.COMPONENT_ID, "okhttp"); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.store(KnownTags.COMPONENT_ID, "okhttp"); IdStore store = newStore.apply(parent); - store.set(KnownTags.HTTP_METHOD_ID, "GET"); + 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.getValue(KnownTags.HTTP_ROUTE_ID)); + assertNull(store.load(KnownTags.HTTP_ROUTE_ID)); assertNull(store.remove(KnownTags.HTTP_ROUTE_ID), "already hidden"); assertEquals(2, store.size()); - assertEquals("/inherited", parent.getValue(KnownTags.HTTP_ROUTE_ID), "parent untouched"); + 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.set(KnownTags.COMPONENT_ID, "local"); + store.store(KnownTags.COMPONENT_ID, "local"); assertEquals("local", store.remove(KnownTags.COMPONENT_ID).objectValue()); - assertNull(store.getValue(KnownTags.COMPONENT_ID)); + assertNull(store.load(KnownTags.COMPONENT_ID)); assertEquals("GET", store.remove(KnownTags.HTTP_METHOD_ID).objectValue()); assertNull(store.remove(KnownTags.HTTP_METHOD_ID), "absent"); @@ -151,8 +151,8 @@ void removingAnInheritedTagHidesItUntilItIsSetAgain(Function n assertTrue(store.isDefinitelyEmpty() || store.estimateSize() >= 0); // setting a hidden tag again revives it - store.set(KnownTags.HTTP_ROUTE_ID, "/revived"); - assertEquals("/revived", store.getValue(KnownTags.HTTP_ROUTE_ID)); + store.store(KnownTags.HTTP_ROUTE_ID, "/revived"); + assertEquals("/revived", store.load(KnownTags.HTTP_ROUTE_ID)); assertEquals(1, store.size()); assertFalse(store.isEmpty()); store.checkIntegrity(); @@ -168,14 +168,14 @@ void sizeAndEmptinessCountWhatIsVisible(Function newStore) { assertFalse(empty.hasParent()); IdStore parent = newStore.apply(null); - parent.set(KnownTags.HTTP_ROUTE_ID, "/a"); - parent.set(KnownTags.HTTP_METHOD_ID, "GET"); + 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.set(KnownTags.HTTP_METHOD_ID, "POST"); // shadows - child.set(KnownTags.COMPONENT_ID, "okhttp"); + child.store(KnownTags.HTTP_METHOD_ID, "POST"); // shadows + child.store(KnownTags.COMPONENT_ID, "okhttp"); assertEquals(3, child.size()); assertTrue(child.estimateSize() >= 3); @@ -190,12 +190,12 @@ void sizeAndEmptinessCountWhatIsVisible(Function newStore) { @MethodSource("stores") void everyIterationSeesEachVisibleTagOnce(Function newStore) { IdStore parent = newStore.apply(null); - parent.set(KnownTags.HTTP_ROUTE_ID, "/a"); - parent.set(KnownTags.HTTP_METHOD_ID, "GET"); - parent.set(KnownTags.COMPONENT_ID, "okhttp"); + 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.set(KnownTags.HTTP_METHOD_ID, "POST"); // shadows the parent's - store.set(KnownTags.SPAN_KIND_ID, "client"); + 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<>(); @@ -225,19 +225,19 @@ void everyIterationSeesEachVisibleTagOnce(Function newStore) { @MethodSource("stores") void aCopyIsIndependent(Function newStore) { IdStore parent = newStore.apply(null); - parent.set(KnownTags.HTTP_ROUTE_ID, "/a"); + parent.store(KnownTags.HTTP_ROUTE_ID, "/a"); IdStore store = newStore.apply(parent); - store.set(KnownTags.HTTP_METHOD_ID, "GET"); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); store.remove(KnownTags.HTTP_ROUTE_ID); IdStore copy = store.copy(); - copy.set(KnownTags.HTTP_METHOD_ID, "POST"); - copy.set(KnownTags.HTTP_ROUTE_ID, "/copy"); + copy.store(KnownTags.HTTP_METHOD_ID, "POST"); + copy.store(KnownTags.HTTP_ROUTE_ID, "/copy"); - assertEquals("GET", store.getValue(KnownTags.HTTP_METHOD_ID)); - assertNull(store.getValue(KnownTags.HTTP_ROUTE_ID), "the original keeps its tombstone"); - assertEquals("POST", copy.getValue(KnownTags.HTTP_METHOD_ID)); - assertEquals("/copy", copy.getValue(KnownTags.HTTP_ROUTE_ID)); + 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(); } @@ -245,27 +245,27 @@ void aCopyIsIndependent(Function newStore) { @MethodSource("stores") void putAllLocalMergesAnotherStoresTags(Function newStore) { IdStore source = newStore.apply(null); - source.set(KnownTags.HTTP_ROUTE_ID, "/a"); - source.set(KnownTags.HTTP_METHOD_ID, "GET"); + 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.set(KnownTags.HTTP_METHOD_ID, "POST"); - merged.set(KnownTags.COMPONENT_ID, "okhttp"); + merged.store(KnownTags.HTTP_METHOD_ID, "POST"); + merged.store(KnownTags.COMPONENT_ID, "okhttp"); merged.putAllLocal(source); - assertEquals("GET", merged.getValue(KnownTags.HTTP_METHOD_ID), "the source wins"); - assertEquals("okhttp", merged.getValue(KnownTags.COMPONENT_ID)); + 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.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + 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.getValue(KnownTags.HTTP_ROUTE_ID)); + 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 @@ -277,8 +277,8 @@ void putAllLocalMergesAnotherStoresTags(Function newStore) { @MethodSource("stores") void fillsCopyTheLocalTags(Function newStore) { IdStore store = newStore.apply(null); - store.set(KnownTags.HTTP_ROUTE_ID, "/a"); - store.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + store.store(KnownTags.HTTP_ROUTE_ID, "/a"); + store.store(KnownTags.HTTP_STATUS_CODE_ID, 200); Map map = new HashMap<>(); store.fillMapLocal(map); @@ -297,10 +297,10 @@ void growsPastItsFirstCapacity(Function newStore) throws Excep List ids = knownIds(); int n = Math.min(40, ids.size()); for (int i = 0; i < n; ++i) { - store.set(ids.get(i), "v" + i); + store.store(ids.get(i), "v" + i); } for (int i = 0; i < n; ++i) { - assertEquals("v" + i, store.getValue(ids.get(i))); + assertEquals("v" + i, store.load(ids.get(i))); } assertEquals(n, store.size()); assertTrue(store.computeSize() > 0); @@ -317,15 +317,15 @@ void putAllLocalMergesOverlappingStoresWithCollidingTags(Function newStore) { IdStore parent = newStore.apply(null); - parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + 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.getValue(KnownTags.HTTP_ROUTE_ID)); + assertEquals("/revived", store.load(KnownTags.HTTP_ROUTE_ID)); assertEquals(1, store.size()); store.checkIntegrity(); } @@ -349,16 +349,16 @@ void getAndPutRevivesARemovedInheritedTag(Function newStore) { @MethodSource("stores") void readsGoThroughEveryAncestor(Function newStore) { IdStore grandparent = newStore.apply(null); - grandparent.set(KnownTags.HTTP_ROUTE_ID, "/grandparent"); - grandparent.set(KnownTags.COMPONENT_ID, "okhttp"); + grandparent.store(KnownTags.HTTP_ROUTE_ID, "/grandparent"); + grandparent.store(KnownTags.COMPONENT_ID, "okhttp"); IdStore parent = newStore.apply(grandparent); - parent.set(KnownTags.HTTP_METHOD_ID, "GET"); + parent.store(KnownTags.HTTP_METHOD_ID, "GET"); parent.remove(KnownTags.COMPONENT_ID); IdStore store = newStore.apply(parent); - assertEquals("/grandparent", store.getValue(KnownTags.HTTP_ROUTE_ID)); - assertEquals("GET", store.getValue(KnownTags.HTTP_METHOD_ID)); - assertNull(store.getValue(KnownTags.COMPONENT_ID), "hidden by the 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"); @@ -370,48 +370,48 @@ void readsGoThroughEveryAncestor(Function newStore) { @MethodSource("stores") void getStringFormatsTheVisibleValue(Function newStore) { IdStore parent = newStore.apply(null); - parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); - parent.set(KnownTags.COMPONENT_ID, "hidden"); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.store(KnownTags.COMPONENT_ID, "hidden"); IdStore store = newStore.apply(parent); - store.set(KnownTags.PEER_PORT_ID, 5432); - store.set(KnownTags.DB_INSTANCE_ID, 1.5d); + store.store(KnownTags.PEER_PORT_ID, 5432); + store.store(KnownTags.DB_INSTANCE_ID, 1.5d); store.remove(KnownTags.COMPONENT_ID); - assertEquals("5432", store.getString(KnownTags.PEER_PORT_ID)); - assertEquals("1.5", store.getString(KnownTags.DB_INSTANCE_ID)); - assertEquals("/inherited", store.getString(KnownTags.HTTP_ROUTE_ID)); - assertNull(store.getString(KnownTags.COMPONENT_ID), "hidden by the removal"); - assertNull(store.getString(KnownTags.HTTP_METHOD_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.getString(KnownTagCodec.makeTagId(9999))); + IllegalArgumentException.class, () -> store.loadString(KnownTagCodec.makeTagId(9999))); } @ParameterizedTest @MethodSource("stores") void settingANullValueRemovesTheTag(Function newStore) { IdStore parent = newStore.apply(null); - parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); IdStore store = newStore.apply(parent); - store.set(KnownTags.HTTP_METHOD_ID, "GET"); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); - store.set(KnownTags.HTTP_METHOD_ID, (Object) null); - store.set(KnownTags.HTTP_ROUTE_ID, (CharSequence) null); // hides the inherited tag - assertNull(store.getValue(KnownTags.HTTP_METHOD_ID)); - assertNull(store.getValue(KnownTags.HTTP_ROUTE_ID)); + 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.getValue(KnownTags.HTTP_ROUTE_ID), "the parent keeps it"); + assertEquals("/inherited", parent.load(KnownTags.HTTP_ROUTE_ID), "the parent keeps it"); } @ParameterizedTest @MethodSource("stores") void aReaderWithNoEntryIsANoOp(Function newStore) { IdStore store = newStore.apply(null); - store.set(KnownTags.HTTP_ROUTE_ID, "/a"); + 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.getValue(KnownTags.HTTP_ROUTE_ID), "left as it was"); + assertEquals("/a", store.load(KnownTags.HTTP_ROUTE_ID), "left as it was"); assertEquals(1, store.size()); } @@ -419,9 +419,9 @@ void aReaderWithNoEntryIsANoOp(Function newStore) { @MethodSource("stores") void getEntryReturnsAnEntryTheCallerMayKeep(Function newStore) { IdStore parent = newStore.apply(null); - parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); IdStore store = newStore.apply(parent); - store.set(KnownTags.HTTP_METHOD_ID, "GET"); + 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); @@ -444,7 +444,7 @@ void getEntryReturnsAnEntryTheCallerMayKeep(Function newStore) void anUnknownIdIsRejected(Function newStore) { IdStore store = newStore.apply(null); long unknown = KnownTagCodec.makeTagId(9999); - assertThrows(IllegalArgumentException.class, () -> store.getValue(unknown)); + assertThrows(IllegalArgumentException.class, () -> store.load(unknown)); assertThrows(IllegalArgumentException.class, () -> store.remove(unknown)); } From 38dc4b38cbac5ed63943db4ffa97451bbb9e3a40 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 15:42:18 -0400 Subject: [PATCH 21/39] Rename SpanStorage to TagStore Everything on it is a tag, and TagMap implements it too, so TagStore says what it is. Span-named types stay the layer with more to them: SpanData is the intercepted view, with span-level fields as well as tags. Co-Authored-By: Claude Opus 5.5 --- .../main/java/datadog/trace/core/DDSpanContext.java | 6 +++--- .../core/tagprocessor/EagerPostProcessorChain.java | 4 ++-- .../core/tagprocessor/EagerTagsPostProcessor.java | 6 +++--- .../core/tagprocessor/HttpEndpointPostProcessor.java | 4 ++-- .../trace/core/tagprocessor/InternalTagsAdder.java | 4 ++-- .../trace/core/tagprocessor/PeerServiceCalculator.java | 6 +++--- .../core/{SpanStorageTest.java => TagStoreTest.java} | 8 ++++---- .../src/main/java/datadog/trace/api/TagMap.java | 5 ++--- .../trace/api/{SpanStorage.java => TagStore.java} | 2 +- .../datadog/trace/api/endpoint/EndpointResolver.java | 4 ++-- .../java/datadog/trace/api/naming/NamingSchema.java | 4 ++-- .../trace/api/naming/v0/PeerServiceNamingV0.java | 4 ++-- .../trace/api/naming/v1/PeerServiceNamingV1.java | 10 +++++----- 13 files changed, 33 insertions(+), 34 deletions(-) rename dd-trace-core/src/test/java/datadog/trace/core/{SpanStorageTest.java => TagStoreTest.java} (92%) rename internal-api/src/main/java/datadog/trace/api/{SpanStorage.java => TagStore.java} (98%) 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 044314cecf4..8c77f2fbb39 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 @@ -18,8 +18,8 @@ import datadog.trace.api.KnownTagCodec; import datadog.trace.api.KnownTags; import datadog.trace.api.ProcessTags; -import datadog.trace.api.SpanStorage; 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; @@ -69,7 +69,7 @@ * the associated Span instance */ public class DDSpanContext - implements AgentSpanContext, RequestContext, TraceSegment, ProfilerContext, SpanStorage { + implements AgentSpanContext, RequestContext, TraceSegment, ProfilerContext, TagStore { private static final Logger log = LoggerFactory.getLogger(DDSpanContext.class); public static final String PRIORITY_SAMPLING_KEY = "_sampling_priority_v1"; @@ -1397,7 +1397,7 @@ Object getTag(final String key) { } /* - * SpanStorage: the span's tags, loaded and stored with no interception. + * TagStore: the span's tags, loaded and stored with no interception. * Unsynchronized, like unsafeGetTag: callers hold the span's tag lock, as post-processing does. */ 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 index f5ac6765293..21cc4ed9be6 100644 --- 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 @@ -1,6 +1,6 @@ package datadog.trace.core.tagprocessor; -import datadog.trace.api.SpanStorage; +import datadog.trace.api.TagStore; import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; import datadog.trace.core.DDSpanContext; import java.util.Objects; @@ -16,7 +16,7 @@ public EagerPostProcessorChain(@Nonnull final EagerTagsPostProcessor... processo @Override public void processTags( - SpanStorage storage, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + TagStore storage, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { for (final EagerTagsPostProcessor processor : chain) { processor.processTags(storage, 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 index 54ace639c80..503d922e8a9 100644 --- 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 @@ -1,12 +1,12 @@ package datadog.trace.core.tagprocessor; -import datadog.trace.api.SpanStorage; +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 - * {@link SpanStorage} rather than a {@code TagMap}, so the span needn't build one for it. + * {@link TagStore} rather than a {@code TagMap}, so the span needn't build one for it. */ public abstract class EagerTagsPostProcessor { /** @@ -17,5 +17,5 @@ public abstract class EagerTagsPostProcessor { * @param spanLinks the span's links */ public abstract void processTags( - SpanStorage storage, DDSpanContext spanContext, AppendableSpanLinks spanLinks); + TagStore storage, 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 9f1a9b8a51e..c447bc32ec6 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 @@ -4,7 +4,7 @@ import static datadog.trace.api.KnownTags.HTTP_ROUTE_ID; import static datadog.trace.api.KnownTags.HTTP_URL_ID; -import datadog.trace.api.SpanStorage; +import datadog.trace.api.TagStore; import datadog.trace.api.endpoint.EndpointResolver; import datadog.trace.api.internal.VisibleForTesting; import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; @@ -55,7 +55,7 @@ public HttpEndpointPostProcessor() { @Override public void processTags( - SpanStorage 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; 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 8847b833c44..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 @@ -3,8 +3,8 @@ import static datadog.trace.api.KnownTags.DD_BASE_SERVICE_ID; import static datadog.trace.api.KnownTags.VERSION_ID; -import datadog.trace.api.SpanStorage; 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; @@ -29,7 +29,7 @@ public InternalTagsAdder(@Nonnull final String ddService, @Nullable final String @Override public void processTags( - SpanStorage unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { if (spanContext == null) { return; } 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 83a39c52002..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 @@ -4,7 +4,7 @@ import static datadog.trace.api.KnownTags.PEER_SERVICE_ID; import datadog.trace.api.Config; -import datadog.trace.api.SpanStorage; +import datadog.trace.api.TagStore; import datadog.trace.api.internal.VisibleForTesting; import datadog.trace.api.naming.NamingSchema; import datadog.trace.api.naming.SpanNaming; @@ -35,7 +35,7 @@ public PeerServiceCalculator() { @Override public void processTags( - SpanStorage unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { Object peerService = unsafeTags.load(PEER_SERVICE_ID); // the user set it if (peerService != null) { @@ -53,7 +53,7 @@ public void processTags( // we have no peer.service and we do not compute defaults. Leave the map untouched } - private void remapPeerService(SpanStorage unsafeTags, Object value) { + private void remapPeerService(TagStore unsafeTags, Object value) { if (value != null) { String mapped = peerServiceMapping.get(value); if (mapped != null) { diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanStorageTest.java b/dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java similarity index 92% rename from dd-trace-core/src/test/java/datadog/trace/core/SpanStorageTest.java rename to dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java index 3c6c24277d1..5f12d64300a 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/SpanStorageTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java @@ -7,16 +7,16 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -import datadog.trace.api.SpanStorage; 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 SpanStorage}. */ -class SpanStorageTest extends DDCoreJavaSpecification { +/** A known tag's id and name address the same tag, in every {@link TagStore}. */ +class TagStoreTest extends DDCoreJavaSpecification { private CoreTracer tracer; @BeforeEach @@ -74,7 +74,7 @@ void nullStoredByIdRemoves() { }); } - private void forEachStorage(Consumer check) { + private void forEachStorage(Consumer check) { check.accept(TagMap.create()); DDSpan span = (DDSpan) tracer.buildSpan("test", "span").start(); 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 7de3856b002..bb18907b390 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -48,8 +48,7 @@ *

  • adaptive collision * */ -public final class TagMap - implements Map, Iterable, SpanStorage { +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 @@ -1347,7 +1346,7 @@ public String getString(String tag) { return entry == null ? null : entry.stringValue(); } - /* SpanStorage, for standalone maps handed to the pipeline: TagMap's own get and set. */ + /* TagStore, for standalone maps handed to the pipeline: TagMap's own get and set. */ @Override public Object load(String tag) { diff --git a/internal-api/src/main/java/datadog/trace/api/SpanStorage.java b/internal-api/src/main/java/datadog/trace/api/TagStore.java similarity index 98% rename from internal-api/src/main/java/datadog/trace/api/SpanStorage.java rename to internal-api/src/main/java/datadog/trace/api/TagStore.java index 8a5f18b1b99..04e81c43e67 100644 --- a/internal-api/src/main/java/datadog/trace/api/SpanStorage.java +++ b/internal-api/src/main/java/datadog/trace/api/TagStore.java @@ -15,7 +15,7 @@ *

    This is the first slice: only what the eager tag post-processors need. Typed loads and * primitive stores come as more of the pipeline moves to it. */ -public interface SpanStorage { +public interface TagStore { /** The tag's value, or null when it's absent. */ Object load(String tag); 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 f9877d708bf..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 @@ -2,7 +2,7 @@ import static datadog.trace.api.KnownTags.HTTP_ENDPOINT_ID; -import datadog.trace.api.SpanStorage; +import datadog.trace.api.TagStore; import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,7 +51,7 @@ public EndpointResolver(boolean enabled, boolean alwaysSimplifiedEndpoint) { */ @Nullable public String resolveEndpoint( - SpanStorage unsafeTags, @Nullable String httpRoute, @Nullable String httpUrl) { + TagStore unsafeTags, @Nullable String httpRoute, @Nullable String httpUrl) { if (!enabled) { return null; } 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 b17b52bc58b..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.SpanStorage; +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 SpanStorage 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 d802622205a..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.SpanStorage; +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 SpanStorage 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 ae1e7d6ed19..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 @@ -5,7 +5,7 @@ import static datadog.trace.api.KnownTags.PEER_SERVICE_ID; import static datadog.trace.api.KnownTags.SPAN_KIND_ID; -import datadog.trace.api.SpanStorage; +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; @@ -57,7 +57,7 @@ public boolean supports() { return true; } - private void resolve(@Nonnull final SpanStorage unsafeTags) { + 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(); @@ -76,7 +76,7 @@ private void resolve(@Nonnull final SpanStorage unsafeTags) { } private boolean resolveBy( - @Nonnull final SpanStorage unsafeTags, @Nullable final String[] precursors) { + @Nonnull final TagStore unsafeTags, @Nullable final String[] precursors) { if (precursors == null) { return false; } @@ -95,7 +95,7 @@ private boolean resolveBy( return true; } - private void set(@Nonnull final SpanStorage unsafeTags, Object value, String source) { + private void set(@Nonnull final TagStore unsafeTags, Object value, String source) { if (value != null) { unsafeTags.store(PEER_SERVICE_ID, value); unsafeTags.store(DD_PEER_SERVICE_SOURCE_ID, source); @@ -104,7 +104,7 @@ private void set(@Nonnull final SpanStorage unsafeTags, Object value, String sou @Nonnull @Override - public void tags(@Nonnull final SpanStorage unsafeTags) { + public void tags(@Nonnull final TagStore unsafeTags) { // check span.kind eligibility final Object kind = unsafeTags.load(SPAN_KIND_ID); if (Tags.SPAN_KIND_CLIENT.equals(kind) || Tags.SPAN_KIND_PRODUCER.equals(kind)) { From 4540183d9560bd43fb5e14eb636a3c74d23cce7c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 15:43:54 -0400 Subject: [PATCH 22/39] Apply suggestion from @sarahchen6 Co-authored-by: Sarah Chen --- .../datadog/trace/core/tagprocessor/PayloadTagsProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c927e4f6a54..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,7 +245,7 @@ public void doubleValue(PathCursor path, double value) { @Override public void nullValue(PathCursor path) { - // a JSON null sets no tag, and removes one an earlier value set at the same path + // a JSON null sets no tag, and removes an earlier value set at the same path collectedTags.set(path.toString(tagPrefix), null); } From 773906530c7b130b2a012ddb8b6841a47af7e328 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 15:46:10 -0400 Subject: [PATCH 23/39] Say why the span implements TagStore A span isn't conceptually a TagStore: the store is the storage beneath it, as a Map was beneath a TagMap. It implements one only so post-processing can be handed its storage without allocating, since the processor chain's dispatch loop keeps escape analysis from removing a wrapper. Drop the TagStore Javadoc's note about this being a first slice. Co-Authored-By: Claude Opus 5.5 --- .../src/main/java/datadog/trace/core/DDSpanContext.java | 8 ++++++-- .../trace/core/tagprocessor/EagerTagsPostProcessor.java | 6 ++++-- .../src/main/java/datadog/trace/api/TagStore.java | 7 ++----- 3 files changed, 12 insertions(+), 9 deletions(-) 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 8c77f2fbb39..cb6fa415438 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 @@ -1397,8 +1397,12 @@ Object getTag(final String key) { } /* - * TagStore: the span's tags, loaded and stored with no interception. - * Unsynchronized, like unsafeGetTag: callers hold the span's tag lock, as post-processing does. + * 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 span's tag lock, as + * post-processing does. */ @Override 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 index 503d922e8a9..55aa3579216 100644 --- 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 @@ -6,13 +6,15 @@ /** * A tag post-processor run on the application thread as a span finishes. It works on the span's - * {@link TagStore} rather than a {@code TagMap}, so the span needn't build one for it. + * 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 contents the span's contents to read and write; the caller holds the span's tag lock + * @param storage 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 */ diff --git a/internal-api/src/main/java/datadog/trace/api/TagStore.java b/internal-api/src/main/java/datadog/trace/api/TagStore.java index 04e81c43e67..b0e63cbf15f 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagStore.java +++ b/internal-api/src/main/java/datadog/trace/api/TagStore.java @@ -2,8 +2,8 @@ /** * A span's tags as stored, for the tracer core's processing pipeline: loads and stores go straight - * to storage, with no interception. Thread safety is up to the implementation; {@code - * DDSpanContext}'s expects the span's tag lock to be held. + * 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 @@ -11,9 +11,6 @@ * *

    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. - * - *

    This is the first slice: only what the eager tag post-processors need. Typed loads and - * primitive stores come as more of the pipeline moves to it. */ public interface TagStore { /** The tag's value, or null when it's absent. */ From 36bbbd276c9e1425f107de30890329b918d04f53 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 15:56:53 -0400 Subject: [PATCH 24/39] Test that urlOrPath answers the same from a TagStore as from a map Co-Authored-By: Claude Opus 5.5 --- .../api/OtelHttpSemanticsTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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"); From b9dbfa9704b1cea8310965589c20315b6b26828b Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 16:43:10 -0400 Subject: [PATCH 25/39] Name the post-processor chain's TagStore parameter unsafeTags EagerTagsPostProcessor and EagerPostProcessorChain still called it storage after the rename to TagStore, while every processor calls it unsafeTags. Co-Authored-By: Claude Opus 5.5 --- .../trace/core/tagprocessor/EagerPostProcessorChain.java | 4 ++-- .../trace/core/tagprocessor/EagerTagsPostProcessor.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 21cc4ed9be6..df1ee6ea25d 100644 --- 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 @@ -16,9 +16,9 @@ public EagerPostProcessorChain(@Nonnull final EagerTagsPostProcessor... processo @Override public void processTags( - TagStore storage, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { for (final EagerTagsPostProcessor processor : chain) { - processor.processTags(storage, spanContext, spanLinks); + 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 index 55aa3579216..15b1dcf4e1a 100644 --- 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 @@ -13,11 +13,11 @@ public abstract class EagerTagsPostProcessor { /** * Processes the span's tags. * - * @param storage the span's tags as stored, to read and write; the caller holds the span's tag + * @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 storage, DDSpanContext spanContext, AppendableSpanLinks spanLinks); + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks); } From 3eed22059a3706f0ce6b1b4b071d45fa5c763b43 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 16:43:12 -0400 Subject: [PATCH 26/39] Rename TagStoreTest's storage helper and locals Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/core/TagStoreTest.java | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) 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 index 5f12d64300a..fd6e413f4e8 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java @@ -31,50 +31,50 @@ void cleanup() { @Test void storedByIdLoadsByName() { - forEachStorage( - storage -> { - storage.store(HTTP_ROUTE_ID, "/users/{id}"); + forEachTagStore( + tags -> { + tags.store(HTTP_ROUTE_ID, "/users/{id}"); - assertEquals("/users/{id}", storage.load(HTTP_ROUTE)); - assertEquals("/users/{id}", storage.load(HTTP_ROUTE_ID)); + assertEquals("/users/{id}", tags.load(HTTP_ROUTE)); + assertEquals("/users/{id}", tags.load(HTTP_ROUTE_ID)); }); } @Test void storedByNameLoadsById() { - forEachStorage( - storage -> { - storage.store(HTTP_ROUTE, "/users/{id}"); + forEachTagStore( + tags -> { + tags.store(HTTP_ROUTE, "/users/{id}"); - assertEquals("/users/{id}", storage.load(HTTP_ROUTE_ID)); + assertEquals("/users/{id}", tags.load(HTTP_ROUTE_ID)); }); } @Test void loadStringByIdConverts() { - forEachStorage( - storage -> { - storage.store(HTTP_STATUS_CODE_ID, 404); + forEachTagStore( + tags -> { + tags.store(HTTP_STATUS_CODE_ID, 404); - assertEquals("404", storage.loadString(HTTP_STATUS_CODE_ID)); - assertEquals("404", storage.loadString(HTTP_STATUS)); + assertEquals("404", tags.loadString(HTTP_STATUS_CODE_ID)); + assertEquals("404", tags.loadString(HTTP_STATUS)); }); } @Test void nullStoredByIdRemoves() { - forEachStorage( - storage -> { - storage.store(HTTP_ROUTE, "/users/{id}"); + forEachTagStore( + tags -> { + tags.store(HTTP_ROUTE, "/users/{id}"); - storage.store(HTTP_ROUTE_ID, null); + tags.store(HTTP_ROUTE_ID, null); - assertNull(storage.load(HTTP_ROUTE)); - assertNull(storage.loadString(HTTP_ROUTE_ID)); + assertNull(tags.load(HTTP_ROUTE)); + assertNull(tags.loadString(HTTP_ROUTE_ID)); }); } - private void forEachStorage(Consumer check) { + private void forEachTagStore(Consumer check) { check.accept(TagMap.create()); DDSpan span = (DDSpan) tracer.buildSpan("test", "span").start(); From f34d3df6f037109d8b2743a0a7d412e0d661bc77 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 17:00:12 -0400 Subject: [PATCH 27/39] Find a parent's shadowing entry through HashSupport.find parentEntryVisible still masked the hash into a bucket by hand before calling findInBucket, the one place outside HashSupport that knew the bucket array's power-of-2 layout. It now calls HashSupport.find, and findInBucket becomes private. Co-Authored-By: Claude Opus 5.5 --- internal-api/src/main/java/datadog/trace/api/HashSupport.java | 2 +- internal-api/src/main/java/datadog/trace/api/TagMap.java | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/HashSupport.java b/internal-api/src/main/java/datadog/trace/api/HashSupport.java index 1c7874433ab..c42d23fe68d 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashSupport.java +++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java @@ -34,7 +34,7 @@ static Entry find(Object[] buckets, int hash, String tag) { /** * Finds an entry by hash/tag within a single bucket object (Entry | BucketGroup chain | null). */ - static Entry findInBucket(Object bucket, int hash, String tag) { + private static Entry findInBucket(Object bucket, int hash, String tag) { if (bucket instanceof Entry) { Entry tagEntry = (Entry) bucket; return tagEntry.matches(tag) ? tagEntry : null; 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 32e6439f89f..ebd0a7ce980 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1526,9 +1526,7 @@ boolean parentEntryVisible(Entry parentEntry, TagMap fromAncestor) { return false; } // shadowed by a nearer level's local entry - Object[] nearerBuckets = nearer.buckets; - Object nearerBucket = nearerBuckets[hash & (nearerBuckets.length - 1)]; - if (HashSupport.findInBucket(nearerBucket, hash, tag) != null) { + if (HashSupport.find(nearer.buckets, hash, tag) != null) { return false; } } From d46e82203e1d1cc0af1450427d91a4dc195d016e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 27 Oct 2026 16:33:11 -0400 Subject: [PATCH 28/39] Keep the serializer's tag walk free of tombstone checks HashSupport.forEach checked every entry's tombstone flag and walked bucket groups through the _entryAt switch, on the path the serializer takes for every span, though only HashIdStore ever holds a tombstone. forEach goes back to the unrolled BucketGroup walk with no tombstone check, for TagMap and HashStore. HashIdStore uses the new forEachLive, which skips tombstones, and only while its hasTombstones hint is set. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/api/HashIdStore.java | 20 +++++-- .../java/datadog/trace/api/HashSupport.java | 60 +++++++++++++++++-- .../main/java/datadog/trace/api/TagMap.java | 40 +++++++++++++ 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java index 86064f688b2..24a0cba61c3 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -307,7 +307,11 @@ boolean parentEntryVisible(Entry parentEntry, HashIdStore fromAncestor) { @Override void forEach(Consumer consumer) { - HashSupport.forEach(this.buckets, 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)); } @@ -315,7 +319,11 @@ void forEach(Consumer consumer) { @Override void forEach(T thisObj, BiConsumer consumer) { - HashSupport.forEach(this.buckets, thisObj, 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)); } @@ -323,7 +331,11 @@ void forEach(T thisObj, BiConsumer consumer) { @Override void forEach(T thisObj, U otherObj, TriConsumer consumer) { - HashSupport.forEach(this.buckets, thisObj, otherObj, 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); } @@ -376,7 +388,7 @@ void putAllLocal(IdStore other) { } if (this.hasTombstones) { // A bucket merge would count a replaced tombstone as a replacement, so put one at a time. - HashSupport.forEach(that.buckets, this, (self, entryReader) -> self.put(entryReader)); + HashSupport.forEachLive(that.buckets, this, (self, entryReader) -> self.put(entryReader)); return; } if (this.count == 0) { diff --git a/internal-api/src/main/java/datadog/trace/api/HashSupport.java b/internal-api/src/main/java/datadog/trace/api/HashSupport.java index 31008a75502..fb19e416853 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashSupport.java +++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java @@ -281,8 +281,52 @@ static int merge(Object[] thisBuckets, Object[] thatBuckets) { return added; } - /** Visits every entry, skipping tombstones. */ + /** + * 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) { @@ -299,7 +343,8 @@ static void forEach(Object[] buckets, Consumer consumer) { } } - static void forEach( + /** {@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]; @@ -317,7 +362,8 @@ static void forEach( } } - static void forEach( + /** {@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]; @@ -335,18 +381,20 @@ static void forEach( } } + /** Puts every live entry in {@code buckets} into {@code map}, by tag, as its object value. */ static void fillMap(Object[] buckets, Map map) { - forEach(buckets, map, (m, entry) -> m.put(entry.tag(), entry.objectValue())); + 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) { - forEach(buckets, map, (m, entry) -> m.put(entry.tag(), entry.stringValue())); + 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]; - forEach(buckets, count, (c, entry) -> c[0]++); + forEachLive(buckets, count, (c, entry) -> c[0]++); return count[0]; } 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 b40c26207ae..ea9354a4589 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -2792,6 +2792,46 @@ BucketGroup _cloneEntries() { this.hash3, this.entry3); } + void forEachInChain(Consumer consumer) { + for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { + curGroup._forEach(consumer); + } + } + + void _forEach(Consumer consumer) { + if (this.entry0 != null) consumer.accept(this.entry0); + if (this.entry1 != null) consumer.accept(this.entry1); + if (this.entry2 != null) consumer.accept(this.entry2); + if (this.entry3 != null) consumer.accept(this.entry3); + } + + void forEachInChain(T thisObj, BiConsumer consumer) { + for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { + curGroup._forEach(thisObj, consumer); + } + } + + void _forEach(T thisObj, BiConsumer consumer) { + if (this.entry0 != null) consumer.accept(thisObj, this.entry0); + if (this.entry1 != null) consumer.accept(thisObj, this.entry1); + if (this.entry2 != null) consumer.accept(thisObj, this.entry2); + if (this.entry3 != null) consumer.accept(thisObj, this.entry3); + } + + void forEachInChain( + T thisObj, U otherObj, TriConsumer consumer) { + for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { + curGroup._forEach(thisObj, otherObj, consumer); + } + } + + void _forEach(T thisObj, U otherObj, TriConsumer consumer) { + if (this.entry0 != null) consumer.accept(thisObj, otherObj, this.entry0); + if (this.entry1 != null) consumer.accept(thisObj, otherObj, this.entry1); + if (this.entry2 != null) consumer.accept(thisObj, otherObj, this.entry2); + if (this.entry3 != null) consumer.accept(thisObj, otherObj, this.entry3); + } + @Override public String toString() { StringBuilder builder = new StringBuilder(32); From a0e4c1063584d9da5d07483064f81211682a2f1f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 27 Oct 2026 16:33:19 -0400 Subject: [PATCH 29/39] Make an id store's cursor its own bucket walker HashCursor held a separate BucketWalker, so a cursor cost two allocations where TagMap's iterator costs one. It now extends the walker, whose step becomes nextEntry so it can't be mistaken for the cursor's filtering next, and next() returns Entry covariantly. Co-Authored-By: Claude Opus 5.5 --- .../src/main/java/datadog/trace/api/HashIdStore.java | 12 ++++++------ .../src/main/java/datadog/trace/api/HashSupport.java | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java index 24a0cba61c3..19ba7bec790 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -24,22 +24,22 @@ static HashIdStore createEmpty() { } /** Walks the leaf's buckets, then each ancestor's, skipping tombstones and shadowed entries. */ - private static final class HashCursor implements Cursor { + /** 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; - private final HashSupport.BucketWalker walker = new HashSupport.BucketWalker(); HashCursor(HashIdStore leaf) { this.leaf = leaf; this.level = leaf; - this.walker.reset(leaf.buckets); + this.reset(leaf.buckets); } @Override - public EntryReader next() { + public Entry next() { HashIdStore level = this.level; while (level != null) { - Entry entry = this.walker.next(); + Entry entry = this.nextEntry(); if (entry != null) { if (!entry.tombstone && (level == this.leaf || this.leaf.parentEntryVisible(entry, level))) { @@ -50,7 +50,7 @@ public EntryReader next() { level = level.parent; this.level = level; if (level != null) { - this.walker.reset(level.buckets); + this.reset(level.buckets); } } return null; diff --git a/internal-api/src/main/java/datadog/trace/api/HashSupport.java b/internal-api/src/main/java/datadog/trace/api/HashSupport.java index fb19e416853..f18dcbb8a67 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashSupport.java +++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java @@ -481,7 +481,7 @@ static boolean isEmpty(Object[] buckets) { } /** Walks a bucket array's raw entries, tombstones included, for an iterator. */ - static final class BucketWalker { + static class BucketWalker { private Object[] buckets; private int bucketIndex; private BucketGroup group; @@ -496,7 +496,7 @@ void reset(Object[] buckets) { } /** The next raw entry, or null when the array is done. */ - Entry next() { + final Entry nextEntry() { while (this.bucketIndex < this.buckets.length) { if (this.group != null) { for (++this.groupIndex; this.groupIndex < BucketGroup.LEN; ++this.groupIndex) { From 99ca579dc22ffa1b6e427e26b3beb7af5cd0dbe0 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 27 Oct 2026 16:33:20 -0400 Subject: [PATCH 30/39] Fix the id store Javadoc's stale links Co-Authored-By: Claude Opus 5.5 --- internal-api/src/main/java/datadog/trace/api/IdStore.java | 4 ++-- .../src/main/java/datadog/trace/api/IdStoreFactory.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index 8d117b3e76d..917a054539b 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -12,8 +12,8 @@ * 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 #create}); a {@link TagMap} owns - * one too. + *

    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}. diff --git a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java index bd7385b9d6c..ef680ba4146 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java @@ -7,8 +7,8 @@ * one {@code IdStore} subclass and 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}: {@link TagMap#EMPTY} is built with {@link - * #createEmpty}, so class initialization works whichever of the two starts first. + *

    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. */ From 966e3a93418a58299f4888c68698802ce956d857 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 27 Oct 2026 16:33:21 -0400 Subject: [PATCH 31/39] Test HashStore's read-through, removal and merge Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/api/HashStoreTest.java | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 internal-api/src/test/java/datadog/trace/api/HashStoreTest.java 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..38f84fbe0ed --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java @@ -0,0 +1,140 @@ +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(); + } + + 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; + } +} From 5f4b059fd262e9887a1b981f3047d8461720a90d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 27 Oct 2026 16:40:42 -0400 Subject: [PATCH 32/39] Restore HashSupport.toInternalString's Javadoc Co-Authored-By: Claude Opus 5.5 --- internal-api/src/main/java/datadog/trace/api/HashSupport.java | 1 + 1 file changed, 1 insertion(+) diff --git a/internal-api/src/main/java/datadog/trace/api/HashSupport.java b/internal-api/src/main/java/datadog/trace/api/HashSupport.java index f18dcbb8a67..08204326ab5 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashSupport.java +++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java @@ -438,6 +438,7 @@ static void checkBuckets(Object[] buckets) { } } + /** The buckets' internal structure, for deep debugging. */ static String toInternalString(Object[] buckets) { Object[] thisBuckets = buckets; From b6608e596e28b96e1a0dae4ba11bfa862d6d2e5c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 27 Oct 2026 18:51:06 -0400 Subject: [PATCH 33/39] Cover the id store factory, the cursor's bucket walk and HashStore The coverage check flagged IdStoreFactory, which no test built a store through, and HashSupport.BucketWalker, which only a small cursor test reached. IdStoreFactoryTest builds stores through the JVM's factory, checking behavior rather than the implementation, and a contract test walks a cursor over every bucket shape and a parent level. HashStoreTest grows to the typed setters, removal, copying, merging, sizing and a read-through over bucket groups. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/api/HashStoreTest.java | 147 ++++++++++++++++++ .../trace/api/IdStoreContractTest.java | 29 ++++ .../datadog/trace/api/IdStoreFactoryTest.java | 35 +++++ 3 files changed, 211 insertions(+) create mode 100644 internal-api/src/test/java/datadog/trace/api/IdStoreFactoryTest.java diff --git a/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java b/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java index 38f84fbe0ed..8e2d6d5f6b5 100644 --- a/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java +++ b/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java @@ -118,6 +118,153 @@ void putAllLocalMergesAnotherStoresLocalTags() { 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) { diff --git a/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java index 0d35128b933..2689d2a3f4f 100644 --- a/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java @@ -538,4 +538,33 @@ 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)); + } +} From 6c0e91c49355e34077095a1dbfd67b607d0f9916 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 30 Oct 2026 14:52:23 -0400 Subject: [PATCH 34/39] Extract peer connection tags with a TagExtractor Move BaseDecorator.onPeerConnection's InetAddress / InetSocketAddress handling into PeerAddress and PeerSocketAddress extractors, applied with setTagsFrom so the span's tag lock is taken once per call. This is the first step toward freezing the extracted tags once per long-lived address instead of rebuilding them, and the new IP string, on every span. Co-Authored-By: Claude Opus 5.5 --- .../decorator/BaseDecorator.java | 21 +--- .../decorator/PeerAddress.java | 43 +++++++++ .../decorator/PeerSocketAddress.java | 30 ++++++ .../decorator/PeerAddressTest.java | 96 +++++++++++++++++++ 4 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/PeerAddress.java create mode 100644 dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/PeerSocketAddress.java create mode 100644 dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/PeerAddressTest.java 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..e889750638e 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; @@ -196,10 +192,7 @@ protected void doOnError(final AgentSpan span, final Throwable throwable, byte e public final void onPeerConnection( final AgentSpan span, @Nullable final InetSocketAddress remoteConnection) { - if (remoteConnection != null) { - onPeerConnection(span, remoteConnection.getAddress(), !remoteConnection.isUnresolved()); - setPeerPort(span, remoteConnection.getPort()); - } + span.setTagsFrom(remoteConnection, PeerSocketAddress.INSTANCE); } public final void onPeerConnection( @@ -209,17 +202,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); - } - } + span.setTagsFrom(remoteAddress, resolved ? PeerAddress.RESOLVED : PeerAddress.UNRESOLVED); } public void setPeerPort(AgentSpan span, String port) { 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..7dac17ab4a6 --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/PeerAddress.java @@ -0,0 +1,43 @@ +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}. + */ +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/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()); + } +} From 16ecf44d3d311a5c02ccbdffd981c5b9a2f25542 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 30 Oct 2026 15:01:05 -0400 Subject: [PATCH 35/39] Add FrozenTags, a cached form of setTagsFrom FrozenTags.of(source, extractor) records the tags an extractor or contributor writes, once, into a frozen TagMap. span.setTagsFrom(frozen) then applies them as if the writes were made again, without reading the source. On DDSpan this reuses the prototype seed plan: with the dense store, known tags that need no interception and aren't already on the span are copied in at once; otherwise they go one at a time through the interceptor, as setTag does. The recorder rejects reads of the span and span-level setters, since neither can be replayed against a different span. Co-Authored-By: Claude Opus 5.5 --- .../main/java/datadog/trace/core/DDSpan.java | 10 + .../datadog/trace/core/DDSpanContext.java | 60 ++++- .../core/DDSpanFrozenTagsDenseForkedTest.java | 103 +++++++++ .../trace/core/DDSpanFrozenTagsTest.java | 200 +++++++++++++++++ .../instrumentation/api/AgentSpan.java | 12 + .../instrumentation/api/FrozenTags.java | 207 ++++++++++++++++++ .../instrumentation/api/ImmutableSpan.java | 6 + .../instrumentation/api/FrozenTagsTest.java | 107 +++++++++ 8 files changed, 695 insertions(+), 10 deletions(-) create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsDenseForkedTest.java create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsTest.java create mode 100644 internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/FrozenTags.java create mode 100644 internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/FrozenTagsTest.java 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 df38b71aed7..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 @@ -35,6 +35,7 @@ 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; @@ -615,6 +616,15 @@ public DDSpan setTagsFrom(final TagContributor 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 e1fa409f13c..a73496a77e1 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 @@ -40,6 +40,7 @@ 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; @@ -758,8 +759,9 @@ public void applyOverwriting(@Nonnull final SpanPrototype prototype) { } /** - * How a {@link SpanPrototype}'s tags are seeded into a span, cached on the prototype for the - * interceptor it was checked against ({@link SpanPrototype#seedPlan}). + * 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 static final class SeedPlan { final TagInterceptor interceptor; @@ -783,20 +785,36 @@ private static final class SeedPlan { private SeedPlan seedPlanFor(final SpanPrototype prototype) { Object cached = prototype.seedPlan(); - if (cached instanceof SeedPlan && ((SeedPlan) cached).interceptor == tagInterceptor) { + if (isCurrentPlan(cached)) { return (SeedPlan) cached; } - TagMap tags = prototype.tags(); - TagMap.Entry kind = tags.getEntry(KnownTags.SPAN_KIND_ID); - SeedPlan plan = - new SeedPlan( - tagInterceptor, - tags.knownStore().supportsBulkSeed() && tagInterceptor.canSeedInBulk(tags), - kind == null ? SPAN_KIND_UNSET : spanKindOrdinalOf(kind.stringValue())); + 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; + } + SeedPlan plan = newSeedPlan(frozen.tags()); + frozen.applyPlan(plan); + return plan; + } + + 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: @@ -1974,6 +1992,28 @@ void setTagsFrom(final TagContributor contributor) { } } + /** + * Applies frozen tags as if their recorded writes were made now, under one lock: in bulk when the + * plan allows it and the masks prove none of the known tags is already on the span ({@link + * #seedKnownInBulk}), otherwise one at a time through the interceptor, as {@code setTag} does. + * Custom tags always go one at a time. Returns whether the known tags went in bulk, for tests. + */ + boolean setTagsFrom(final FrozenTags frozen) { + final SeedPlan plan = seedPlanFor(frozen); + final TagMap tags = frozen.tags(); + synchronized (knownTags) { + if (seedKnownInBulk(plan, tags)) { + HashStore custom = tags.customStore(); + if (custom != null) { + custom.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); + } + return true; + } + tags.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); + return false; + } + } + /** Whether the current thread holds this span's tag lock; for assertions. */ boolean holdsTagLock() { return Thread.holdsLock(knownTags); 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..49c141984b7 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsDenseForkedTest.java @@ -0,0 +1,103 @@ +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 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 aTagTheSpanAlreadyHasSendsTheSetOneAtATime() { + DDSpan span = start(); + try { + span.setTag(Tags.PEER_PORT, 1); + assertFalse(span.spanContext().setTagsFrom(PEER_TAGS)); + assertEquals(5432, span.getTag(Tags.PEER_PORT)); // still overwritten + } 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..54bba7717ab --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsTest.java @@ -0,0 +1,200 @@ +package datadog.trace.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 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(); + } + } +} 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 388d311c7b2..66debb85c4f 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 @@ -178,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.tags().forEach(this, (span, tagEntry) -> span.setTag(tagEntry)); + } + return this; + } + @Override AgentSpan setTag(String key, Number value); 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..f2c1d2f8301 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/FrozenTags.java @@ -0,0 +1,207 @@ +package datadog.trace.bootstrap.instrumentation.api; + +import datadog.apicontract.Restricted; +import datadog.trace.api.TagMap; + +/** + * 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 the recorded writes, interception included. A tracer core may + * copy the tags in at once when it can show none of them needs interception or is already present. + * + *

    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. A + * null value is recorded as no write, so unlike a live {@code setTag(tag, null)} it doesn't remove + * a tag the span already has. + */ +public final class FrozenTags { + /** No tags. */ + public static final FrozenTags EMPTY = new FrozenTags(TagMap.EMPTY); + + /** 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(); + } + + private final TagMap tags; // frozen + + /** + * 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) { + this.tags = tags; + } + + /** The recorded tags, frozen. */ + public TagMap tags() { + return tags; + } + + public boolean isEmpty() { + return tags.isEmpty(); + } + + /** 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(); + + FrozenTags freeze() { + return tags.isEmpty() ? EMPTY : new FrozenTags(tags.freeze()); + } + + @Override + public SpanData setTag(String key, Object value) { + tags.set(key, value); + return this; + } + + @Override + public SpanData setTag(String key, String value) { + tags.set(key, (CharSequence) value); + return this; + } + + @Override + public SpanData setTag(String key, boolean value) { + tags.set(key, value); + return this; + } + + @Override + public SpanData setTag(String key, int value) { + tags.set(key, value); + return this; + } + + @Override + public SpanData setTag(String key, long value) { + tags.set(key, value); + return this; + } + + @Override + public SpanData setTag(String key, float value) { + tags.set(key, value); + return this; + } + + @Override + public SpanData setTag(String key, double value) { + tags.set(key, value); + return this; + } + + @Override + public SpanData setTag(long tagId, Object value) { + tags.set(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, CharSequence value) { + tags.set(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, boolean value) { + tags.set(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, int value) { + tags.set(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, long value) { + tags.set(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, float value) { + tags.set(tagId, value); + return this; + } + + @Override + public SpanData setTag(long tagId, double value) { + tags.set(tagId, value); + return this; + } + + @Override + public SpanData setTag(TagMap.EntryReader entry) { + tags.set(entry); + 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/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..cb789ad491e --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/FrozenTagsTest.java @@ -0,0 +1,107 @@ +package datadog.trace.bootstrap.instrumentation.api; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import datadog.trace.api.KnownTags; +import datadog.trace.api.TagMap; +import org.junit.jupiter.api.Test; + +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 aNullValueIsNoWrite() { + FrozenTags frozen = + FrozenTags.of( + (TagContributor) + span -> { + span.setTag(KnownTags.PEER_HOSTNAME_ID, (CharSequence) null); + span.setTag(KnownTags.PEER_IPV4_ID, "10.0.0.1"); + }); + + assertEquals(1, frozen.tags().size()); + assertNull(frozen.tags().getObject(Tags.PEER_HOSTNAME)); + } + + @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( + argThat( + (TagMap.EntryReader entry) -> + entry.tagId() == KnownTags.PEER_IPV4_ID + && "10.0.0.1".equals(entry.objectValue()))); + } +} From 7f0c4f7537ed7547b535868c1e4fa2d9da2b283c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 30 Oct 2026 15:08:42 -0400 Subject: [PATCH 36/39] Freeze netty 4.1 client peer tags once per connection The client handler rebuilt peer.hostname / peer.ipv4 / peer.port from the channel's remote address on every request, allocating a new IP string each time. The address is fixed for the connection's life, so freeze the tags on first use, keep them on the connection channel (HTTP/2 streams share their parent's), and apply them with setTagsFrom(FrozenTags). Co-Authored-By: Claude Opus 5.5 --- .../HttpClientRequestTracingHandler.java | 30 ++++++++++++++++--- .../netty41/AttributeKeys.java | 5 ++++ 2 files changed, 31 insertions(+), 4 deletions(-) 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..ea7dec2407f 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,7 @@ 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_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 +18,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 +88,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 +112,27 @@ public void write(final ChannelHandlerContext ctx, final Object msg, final Chann } } + /** + * The peer tags of the connection {@code channel} belongs to, frozen on first use and kept on the + * connection: its remote address never changes, so later requests skip rebuilding the IP string + * and resolving the hostname. HTTP/2 streams share their parent connection's tags. Not cached + * while the connection has no remote address yet. + */ + private static FrozenTags peerTags(final Channel channel) { + final Channel connection = channel.parent() != null ? channel.parent() : channel; + final Attribute attribute = connection.attr(PEER_TAGS_ATTRIBUTE_KEY); + FrozenTags tags = attribute.get(); + if (tags == null) { + final SocketAddress address = connection.remoteAddress(); + if (!(address instanceof InetSocketAddress)) { + return FrozenTags.EMPTY; + } + tags = FrozenTags.of((InetSocketAddress) address, PeerSocketAddress.INSTANCE); + attribute.set(tags); // racing requests freeze equal tags; either may win + } + 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..32d4653d8cb 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,6 +6,7 @@ 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.util.concurrent.ConcurrentHashMap; @@ -28,6 +29,10 @@ public final class AttributeKeys { public static final AttributeKey HTTP2_CONNECTION_CODEC_ATTRIBUTE_KEY = attributeKey("datadog.http2.connection.codec"); + /** A client connection's peer tags, frozen on first use: its remote address never changes. */ + public static final AttributeKey PEER_TAGS_ATTRIBUTE_KEY = + attributeKey("datadog.client.peer.tags"); + public static final AttributeKey PARENT_CONTEXT_ATTRIBUTE_KEY = attributeKey("datadog.server.parent-context"); From cf67e596b03b0eaa49518a97fa7b06c9fe769b72 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 30 Oct 2026 15:26:52 -0400 Subject: [PATCH 37/39] Copy the non-overlapping frozen tags in bulk, overwrite the rest Frozen tags often overlap tags the span already has: an HTTP client sets peer.hostname and peer.port from the URL, then the connection's peer tags overwrite them. Falling back to tag-by-tag for the whole set made that common shape lose the bulk copy. Add a partial IdStore.seedAbsent that appends the source tags whose slot bits are clear and hands the ones that may be present to the caller, which sets them through the interceptor as setTag does. The result is the same as the recorded writes; only the overlapping tags pay for it. Co-Authored-By: Claude Opus 5.5 --- .../datadog/trace/core/DDSpanContext.java | 18 ++- .../core/DDSpanFrozenTagsDenseForkedTest.java | 43 ++++++- .../java/datadog/trace/api/DenseIdStore.java | 55 +++++++++ .../main/java/datadog/trace/api/IdStore.java | 13 +++ .../java/datadog/trace/api/TagStoresTest.java | 106 ++++++++++++++++++ 5 files changed, 226 insertions(+), 9 deletions(-) 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 a73496a77e1..8b9dca66a41 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 @@ -1993,16 +1993,24 @@ void setTagsFrom(final TagContributor contributor) { } /** - * Applies frozen tags as if their recorded writes were made now, under one lock: in bulk when the - * plan allows it and the masks prove none of the known tags is already on the span ({@link - * #seedKnownInBulk}), otherwise one at a time through the interceptor, as {@code setTag} does. - * Custom tags always go one at a time. Returns whether the known tags went in bulk, for tests. + * Applies frozen tags as if their recorded writes were made now, under one lock. When the plan + * allows bulk (no known tag needs the interceptor beyond span.kind), the known tags the masks + * prove absent are copied in at once and the ones that may be present -- typically set earlier + * from another source, which these overwrite -- go one at a time through the interceptor, as + * {@code setTag} does (the partial {@code IdStore.seedAbsent}); span.kind's ordinal is then set + * directly. Otherwise every tag goes one at a time. Custom tags always do. 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 (seedKnownInBulk(plan, tags)) { + if (plan.bulk + && knownTags.seedAbsent( + 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)); 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 index 49c141984b7..432a47e7e37 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsDenseForkedTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsDenseForkedTest.java @@ -12,6 +12,7 @@ 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; /** @@ -90,12 +91,46 @@ void anInterceptedTagSendsTheSetOneAtATime() { } @Test - void aTagTheSpanAlreadyHasSendsTheSetOneAtATime() { + 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.PEER_PORT, 1); - assertFalse(span.spanContext().setTagsFrom(PEER_TAGS)); - assertEquals(5432, span.getTag(Tags.PEER_PORT)); // still overwritten + 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/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java index 352c64e572a..3b7ea7d5bd3 100644 --- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -359,6 +359,61 @@ public boolean seedAbsent(IdStore source) { 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 seedAbsent( + 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 = diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index 74736778ce5..a4805b36a72 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -62,6 +62,19 @@ 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 seedAbsent( + 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. diff --git a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java index 038d414b5e2..dc0f2242c95 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java @@ -248,6 +248,112 @@ void theHashStoreNeverSeedsInBulk() { HashIdStore target = new HashIdStore(null); assertFalse(target.seedAbsent(denseSource(KnownTags.COMPONENT_ID))); assertFalse(target.seedAbsent(new HashIdStore(null))); + assertFalse(target.seedAbsent(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.seedAbsent( + 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.seedAbsent( + 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.seedAbsent( + 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.seedAbsent(child, target, TagStoresTest::put), "read-through source"); + assertEquals(1, target.size(), "nothing copied"); } @Test From 5d1f8788a8866da1168c1a60cda9a09044a9b708 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 30 Oct 2026 15:40:03 -0400 Subject: [PATCH 38/39] Freeze Kafka consumer tags once per topic per poll Each consumed record's span got messaging.destination.name, kafka.group, messaging.kafka.cluster.id and messaging.kafka.bootstrap.servers set one by one, though all four are the same for every record of a topic in a poll. The poll's iterator now freezes them per topic (records come out partition by partition, so one cached topic suffices) and applies them with setTagsFrom(FrozenTags), at the same point in onConsume as before. Co-Authored-By: Claude Opus 5.5 --- .../KafkaConsumerInstrumentation.java | 1 + .../kafka_clients/KafkaConsumerTags.java | 56 ++++++++++++++++ .../kafka_clients/KafkaDecorator.java | 30 ++++----- .../kafka_clients/TracingIterator.java | 8 ++- .../kafka_clients/KafkaConsumerTagsTest.java | 66 +++++++++++++++++++ .../KafkaConsumerInstrumentation.java | 1 + .../kafka_clients38/KafkaConsumerTags.java | 56 ++++++++++++++++ .../kafka_clients38/KafkaDecorator.java | 30 ++++----- .../kafka_clients38/TracingIterator.java | 8 ++- 9 files changed, 216 insertions(+), 40 deletions(-) create mode 100644 dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/main/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerTags.java create mode 100644 dd-java-agent/instrumentation/kafka/kafka-clients-0.11/src/test/java/datadog/trace/instrumentation/kafka_clients/KafkaConsumerTagsTest.java create mode 100644 dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/main/java17/datadog/trace/instrumentation/kafka_clients38/KafkaConsumerTags.java 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 { From 3ddcf66479cbbc43b0b312d4e947ff471726af55 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 30 Oct 2026 17:06:59 -0400 Subject: [PATCH 39/39] Replay frozen tags in write order and keep peer DNS outside the tag lock Review fixes, keeping FrozenTags exactly equivalent to the live writes: - FrozenTags records each write (same setter, same value) and replays them in order on every tag-by-tag path. Split-by-tags is last-write- wins, so replaying in store order could pick a different service. A recorded null or empty value now replays as written instead of being dropped, and keeps the set off the bulk path, as does a custom tag that split-by-tags routes. - BaseDecorator.onPeerConnection calls the peer extractors directly: through setTagsFrom, the hostname's reverse DNS lookup ran under the span's tag lock. - netty reads the address from the request's channel as before, shares the connection's frozen tags only when the parent reports the same address object (HTTP/2 streams, not a server channel), and refreezes when the address changes. - Rename the partial seed to IdStore.seedPartial. Co-Authored-By: Claude Opus 5.5 --- .../decorator/BaseDecorator.java | 12 ++- .../decorator/PeerAddress.java | 4 + .../HttpClientRequestTracingHandler.java | 33 ++++--- .../netty41/AttributeKeys.java | 7 +- .../datadog/trace/core/DDSpanContext.java | 42 +++++++-- .../trace/core/DDSpanFrozenTagsTest.java | 74 +++++++++++++++ .../java/datadog/trace/api/DenseIdStore.java | 2 +- .../main/java/datadog/trace/api/IdStore.java | 2 +- .../instrumentation/api/AgentSpan.java | 2 +- .../instrumentation/api/FrozenTags.java | 91 ++++++++++++++++--- .../java/datadog/trace/api/TagStoresTest.java | 11 ++- .../instrumentation/api/FrozenTagsTest.java | 58 +++++++++--- 12 files changed, 281 insertions(+), 57 deletions(-) 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 e889750638e..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 @@ -190,9 +190,15 @@ 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) { - span.setTagsFrom(remoteConnection, PeerSocketAddress.INSTANCE); + if (remoteConnection != null) { + PeerSocketAddress.INSTANCE.extract(remoteConnection, span); + } } public final void onPeerConnection( @@ -202,7 +208,9 @@ public final void onPeerConnection( public final void onPeerConnection( AgentSpan span, @Nullable InetAddress remoteAddress, boolean resolved) { - span.setTagsFrom(remoteAddress, resolved ? PeerAddress.RESOLVED : PeerAddress.UNRESOLVED); + if (remoteAddress != null) { + (resolved ? PeerAddress.RESOLVED : PeerAddress.UNRESOLVED).extract(remoteAddress, span); + } } public void setPeerPort(AgentSpan span, String port) { 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 index 7dac17ab4a6..35691fade5d 100644 --- 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 @@ -14,6 +14,10 @@ * 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. */ 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 ea7dec2407f..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,7 @@ 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; @@ -113,22 +114,28 @@ public void write(final ChannelHandlerContext ctx, final Object msg, final Chann } /** - * The peer tags of the connection {@code channel} belongs to, frozen on first use and kept on the - * connection: its remote address never changes, so later requests skip rebuilding the IP string - * and resolving the hostname. HTTP/2 streams share their parent connection's tags. Not cached - * while the connection has no remote address yet. + * 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 Channel connection = channel.parent() != null ? channel.parent() : channel; - final Attribute attribute = connection.attr(PEER_TAGS_ATTRIBUTE_KEY); - FrozenTags tags = attribute.get(); - if (tags == null) { - final SocketAddress address = connection.remoteAddress(); - if (!(address instanceof InetSocketAddress)) { - return FrozenTags.EMPTY; - } + 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); - attribute.set(tags); // racing requests freeze equal tags; either may win + tagsAttribute.set(tags); + addressAttribute.set(address); } return tags; } 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 32d4653d8cb..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 @@ -9,6 +9,7 @@ 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; @@ -29,10 +30,14 @@ public final class AttributeKeys { public static final AttributeKey HTTP2_CONNECTION_CODEC_ATTRIBUTE_KEY = attributeKey("datadog.http2.connection.codec"); - /** A client connection's peer tags, frozen on first use: its remote address never changes. */ + /** 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-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 8b9dca66a41..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 @@ -798,11 +798,34 @@ private SeedPlan seedPlanFor(final FrozenTags frozen) { if (isCurrentPlan(cached)) { return (SeedPlan) cached; } - SeedPlan plan = newSeedPlan(frozen.tags()); + 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; } @@ -1994,19 +2017,20 @@ void setTagsFrom(final TagContributor contributor) { /** * Applies frozen tags as if their recorded writes were made now, under one lock. When the plan - * allows bulk (no known tag needs the interceptor beyond span.kind), the known tags the masks - * prove absent are copied in at once and the ones that may be present -- typically set earlier - * from another source, which these overwrite -- go one at a time through the interceptor, as - * {@code setTag} does (the partial {@code IdStore.seedAbsent}); span.kind's ordinal is then set - * directly. Otherwise every tag goes one at a time. Custom tags always do. Returns whether the - * bulk path was taken, for tests. + * 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.seedAbsent( + && knownTags.seedPartial( tags.knownStore(), this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry))) { if (plan.kindOrdinal != SPAN_KIND_UNSET) { spanKindOrdinal = plan.kindOrdinal; @@ -2017,7 +2041,7 @@ boolean setTagsFrom(final FrozenTags frozen) { } return true; } - tags.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); + frozen.replay(new HeldSpanData(this)); return false; } } 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 index 54bba7717ab..36a9e5362f9 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanFrozenTagsTest.java @@ -1,6 +1,8 @@ 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; @@ -11,6 +13,10 @@ 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; @@ -197,4 +203,72 @@ void emptyOrNullFrozenTagsChangeNothing() { 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/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java index 3b7ea7d5bd3..0a331a8d051 100644 --- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -367,7 +367,7 @@ public boolean seedAbsent(IdStore source) { * so one appended here can't make a later one present. */ @Override - public boolean seedAbsent( + public boolean seedPartial( IdStore source, T thisObj, BiConsumer maybePresent) { if (this.seedAbsent(source)) { return true; diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index a4805b36a72..36727a308f1 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -70,7 +70,7 @@ public boolean seedAbsent(IdStore source) { * every tag itself. {@code source} must be frozen, and {@code maybePresent} must not keep the * entry it is handed. */ - public boolean seedAbsent( + public boolean seedPartial( IdStore source, T thisObj, BiConsumer maybePresent) { return false; } 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 66debb85c4f..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 @@ -185,7 +185,7 @@ default AgentSpan setTagsFrom(final TagContributor contributor) { */ default AgentSpan setTagsFrom(final FrozenTags frozen) { if (frozen != null) { - frozen.tags().forEach(this, (span, tagEntry) -> span.setTag(tagEntry)); + frozen.replay(this); } return this; } 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 index f2c1d2f8301..f9b865a65be 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -14,17 +16,17 @@ * #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 the recorded writes, interception included. A tracer core may - * copy the tags in at once when it can show none of them needs interception or is already present. + *

    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. A - * null value is recorded as no write, so unlike a live {@code setTag(tag, null)} it doesn't remove - * a tag the span already has. + * ({@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); + 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) { @@ -46,7 +48,19 @@ public static FrozenTags of(final TagContributor contributor) { return recorder.freeze(); } - private final TagMap tags; // frozen + /** 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 @@ -54,17 +68,35 @@ public static FrozenTags of(final TagContributor contributor) { */ private Object applyPlan; - private FrozenTags(final TagMap tags) { + private FrozenTags(final TagMap tags, final Write[] writes, final boolean replayOnly) { this.tags = tags; + this.writes = writes; + this.replayOnly = replayOnly; } - /** The recorded tags, frozen. */ + /** The net result of the recorded writes, frozen. */ public TagMap tags() { return tags; } + /** Whether nothing was written. */ public boolean isEmpty() { - return tags.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. */ @@ -82,98 +114,133 @@ public void applyPlan(final Object 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 tags.isEmpty() ? EMPTY : new FrozenTags(tags.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) { - tags.set(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; } diff --git a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java index dc0f2242c95..c843a427248 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java @@ -248,7 +248,8 @@ void theHashStoreNeverSeedsInBulk() { HashIdStore target = new HashIdStore(null); assertFalse(target.seedAbsent(denseSource(KnownTags.COMPONENT_ID))); assertFalse(target.seedAbsent(new HashIdStore(null))); - assertFalse(target.seedAbsent(denseSource(KnownTags.COMPONENT_ID), target, TagStoresTest::put)); + assertFalse( + target.seedPartial(denseSource(KnownTags.COMPONENT_ID), target, TagStoresTest::put)); } /** The caller's tag-by-tag fallback for a partial seed: an overwriting put. */ @@ -268,7 +269,7 @@ void partialSeedAppendsTheAbsentAndHandsBackTheRest() { Map handedBack = new HashMap<>(); assertTrue( - target.seedAbsent( + target.seedPartial( source, target, (store, entry) -> { @@ -291,7 +292,7 @@ void partialSeedCopiesInOneGoWhenNothingMayBePresent() { DenseIdStore target = new DenseIdStore(null); assertTrue( - target.seedAbsent( + target.seedPartial( source, target, (store, entry) -> { @@ -312,7 +313,7 @@ void partialSeedHandsBackATagThatOnlySharesASlot() { Map handedBack = new HashMap<>(); assertTrue( - target.seedAbsent( + target.seedPartial( source, target, (store, entry) -> { @@ -352,7 +353,7 @@ void partialSeedRefusesASourceWithAParentOrTombstones() { DenseIdStore target = new DenseIdStore(null); target.store(KnownTags.SPAN_KIND_ID, "client"); // so the all-at-once copy can't take it - assertFalse(target.seedAbsent(child, target, TagStoresTest::put), "read-through source"); + assertFalse(target.seedPartial(child, target, TagStoresTest::put), "read-through source"); assertEquals(1, target.size(), "nothing copied"); } 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 index cb789ad491e..8318f587adb 100644 --- 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 @@ -1,18 +1,20 @@ 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.ArgumentMatchers.argThat; 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 datadog.trace.api.TagMap; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; class FrozenTagsTest { @@ -48,17 +50,54 @@ void aLaterWriteOfTheSameTagWins() { } @Test - void aNullValueIsNoWrite() { - FrozenTags frozen = + 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)); - assertEquals(1, frozen.tags().size()); - assertNull(frozen.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 @@ -97,11 +136,6 @@ void theDefaultSetTagsFromReplaysTheTags() { span.setTagsFrom(frozen); - verify(span) - .setTag( - argThat( - (TagMap.EntryReader entry) -> - entry.tagId() == KnownTags.PEER_IPV4_ID - && "10.0.0.1".equals(entry.objectValue()))); + verify(span).setTag(KnownTags.PEER_IPV4_ID, (CharSequence) "10.0.0.1"); } }