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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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 3c91e7f27b93c1c756e976e58f5693c9ccdd325d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 11:49:11 -0400 Subject: [PATCH 09/27] 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 f1b60e47b3cb64b6f948939c1ba46898a0a83ef2 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 11:56:51 -0400 Subject: [PATCH 10/27] 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 11/27] 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 12/27] 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 afad98bc652a192eddc4830ae66a4816f9844173 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 26 Oct 2026 14:47:49 -0400 Subject: [PATCH 13/27] 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 14/27] 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 15/27] 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 16/27] 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 17/27] 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 18/27] 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 19/27] 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 20/27] 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 21/27] 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 22/27] 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 23/27] 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 24/27] 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 25/27] 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 26/27] 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 0291032322d67754ec68ed2b78b05df4df5b2e01 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 28 Oct 2026 11:00:01 -0400 Subject: [PATCH 27/27] Take no nulls on writes to the id and name tag stores TagMap and the span's TagStore stay null-tolerant: a null value removes and a null entry is a no-op. The stores one level down now require non-null writes, so null is handled once by their owner, and the annotations make the contract checkable. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/api/HashIdStore.java | 13 +++------ .../java/datadog/trace/api/HashStore.java | 27 +++++++++---------- .../main/java/datadog/trace/api/IdStore.java | 21 +++++++++++---- .../java/datadog/trace/api/HashStoreTest.java | 14 ---------- .../trace/api/IdStoreContractTest.java | 16 ----------- 5 files changed, 32 insertions(+), 59 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 19ba7bec790..2ec53294d7f 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -7,6 +7,7 @@ import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Consumer; +import javax.annotation.Nonnull; /** * The hash {@link IdStore}: known tags in their own bucket array ({@link HashSupport}), keyed by @@ -115,20 +116,12 @@ public String loadString(long tagId) { } @Override - public void store(long tagId, Object value) { - if (value == null) { - this.remove(tagId); // a null value removes the tag - return; - } + public void store(long tagId, @Nonnull Object value) { this.put(Entry.newAnyEntry(tagId, value)); } @Override - public void store(long tagId, CharSequence value) { - if (value == null) { - this.remove(tagId); // a null value removes the tag - return; - } + public void store(long tagId, @Nonnull CharSequence value) { this.put(Entry.newObjectEntry(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 d742de5a4fb..9ea6baf8d5f 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java @@ -10,6 +10,8 @@ import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Consumer; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; /** * The custom-tag half of a {@link TagMap}: custom tags by name, in their own bucket array ({@link @@ -18,7 +20,8 @@ * IdStore}. * *

    Values move by {@code store}, as through {@link TagStore}; entries by {@code getEntry}, {@code - * put} and {@code remove}. + * put} and {@code remove}. Unlike {@link TagStore}, writes take no nulls: the owner turns a null + * value into {@code remove} and skips a null entry before calling. * *

    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, @@ -120,7 +123,8 @@ int estimateSize() { return total; } - /** The custom tag {@code tag}'s entry, reading through to the parent chain. */ + /** The custom tag {@code tag}'s entry, reading through to the parent chain, or null. */ + @Nullable 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))); @@ -145,19 +149,11 @@ private Entry get(String tag, int hash) { * Stores the custom tag {@code tag}, which must not be a known tag's name. */ - public void store(String tag, Object value) { - if (value == null) { - this.remove(tag); // a null value removes the tag - return; - } + public void store(String tag, @Nonnull Object value) { this.put(Entry.newAnyEntry(tag, value)); } - public void store(String tag, CharSequence value) { - if (value == null) { - this.remove(tag); // a null value removes the tag - return; - } + public void store(String tag, @Nonnull CharSequence value) { this.put(Entry.newObjectEntry(tag, value)); } @@ -185,7 +181,8 @@ public void store(String tag, double 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) { + @Nullable + public Entry put(@Nonnull 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) { @@ -199,7 +196,8 @@ public Entry put(Entry newEntry) { } /** {@link #put}, returning the prior visible entry -- local, else read through. */ - public Entry getAndPut(Entry newEntry) { + @Nullable + public Entry getAndPut(@Nonnull 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 = @@ -219,6 +217,7 @@ public Entry getAndPut(Entry newEntry) { * 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. */ + @Nullable 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)); 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 917a054539b..79e181ed426 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,8 @@ import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Consumer; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; /** * Known tags keyed by their {@link KnownTags} id. Every method takes an id, or an {@link @@ -16,7 +18,8 @@ * 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}. + * getEntry}, {@code put} and {@code remove}. Unlike {@link TagStore}, writes take no nulls: the + * owner turns a null value into {@code remove} and skips a null entry before calling. * *

    {@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 @@ -27,6 +30,7 @@ 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. */ + @Nullable EntryReader next(); } @@ -47,6 +51,7 @@ static IllegalArgumentException notKnown(long tagId) { * * @throws IllegalArgumentException if {@code tagId} names no known tag */ + @Nullable public abstract EntryReader getEntryReader(long tagId); /** @@ -57,12 +62,14 @@ static IllegalArgumentException notKnown(long tagId) { * * @throws IllegalArgumentException if {@code tagId} names no known tag */ + @Nullable 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. */ + @Nullable public abstract Object load(long tagId); /** @@ -70,15 +77,16 @@ static IllegalArgumentException notKnown(long tagId) { * Entry's cached string, so a numeric tag read repeatedly, as the serializer does, is formatted * once. */ + @Nullable public abstract String loadString(long tagId); /* * Stores a known tag without validating its id: the caller has checked KnownTagCodec.isKnown. */ - public abstract void store(long tagId, Object value); + public abstract void store(long tagId, @Nonnull Object value); - public abstract void store(long tagId, CharSequence value); + public abstract void store(long tagId, @Nonnull CharSequence value); public abstract void store(long tagId, boolean value); @@ -95,10 +103,12 @@ static IllegalArgumentException notKnown(long tagId) { * 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); + @Nullable + public abstract EntryReader put(@Nonnull EntryReader entryReader); /** {@link #put}, returning the prior visible entry -- local, else read through. */ - public abstract EntryReader getAndPut(EntryReader entryReader); + @Nullable + public abstract EntryReader getAndPut(@Nonnull EntryReader entryReader); /** * Removes the known tag {@code tagId}, returning the prior visible entry; an inherited one is @@ -106,6 +116,7 @@ static IllegalArgumentException notKnown(long tagId) { * * @throws IllegalArgumentException if {@code tagId} names no known tag */ + @Nullable public abstract EntryReader remove(long tagId); /* diff --git a/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java b/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java index 8e2d6d5f6b5..71b959826a2 100644 --- a/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java +++ b/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java @@ -136,20 +136,6 @@ void typedStoresRoundTrip() { 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); 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 2689d2a3f4f..770b4134f73 100644 --- a/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java @@ -386,22 +386,6 @@ void getStringFormatsTheVisibleValue(Function newStore) { IllegalArgumentException.class, () -> store.loadString(KnownTagCodec.makeTagId(9999))); } - @ParameterizedTest - @MethodSource("stores") - void settingANullValueRemovesTheTag(Function newStore) { - IdStore parent = newStore.apply(null); - parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); - IdStore store = newStore.apply(parent); - store.store(KnownTags.HTTP_METHOD_ID, "GET"); - - store.store(KnownTags.HTTP_METHOD_ID, (Object) null); - store.store(KnownTags.HTTP_ROUTE_ID, (CharSequence) null); // hides the inherited tag - assertNull(store.load(KnownTags.HTTP_METHOD_ID)); - assertNull(store.load(KnownTags.HTTP_ROUTE_ID)); - assertEquals(0, store.size()); - assertEquals("/inherited", parent.load(KnownTags.HTTP_ROUTE_ID), "the parent keeps it"); - } - @ParameterizedTest @MethodSource("stores") void aReaderWithNoEntryIsANoOp(Function newStore) {