diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/DropWriter.java b/dd-trace-core/src/jmh/java/datadog/trace/core/DropWriter.java new file mode 100644 index 00000000000..727cc00fa57 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/DropWriter.java @@ -0,0 +1,31 @@ +package datadog.trace.core; + +import datadog.trace.common.writer.Writer; +import java.util.List; + +/** + * No-op {@link Writer}: drops finished traces so span-creation benchmarks measure only the + * application-thread (front-half) allocation — create, tag, finish, PendingTrace completion — with + * no serialization or agent I/O leaking into the {@code -prof gc} number. + * + *

Drift-stable: implements only the five-method {@link Writer} interface, unchanged + * v1.53→master. + */ +final class DropWriter implements Writer { + @Override + public void write(List trace) {} + + @Override + public void start() {} + + @Override + public boolean flush() { + return true; + } + + @Override + public void close() {} + + @Override + public void incrementDropCounts(int spanCount) {} +} diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java new file mode 100644 index 00000000000..0ec436c8163 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java @@ -0,0 +1,308 @@ +package datadog.trace.core; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Grounding benchmark for the full span-creation lifecycle: create -> (set tags) -> finish. + * + *

This is the "micro-ish" reference the TagMap 2.0 / SpanPrototype work is measured against. It + * pairs a tag-free baseline with two known-tag shapes — a web-server span (7 tags) and a JDBC/DB + * client span (9 tags) — so the dense-store and SpanPrototype allocation wins are actually + * exercised (a tag-free or custom-tag benchmark would be dense-neutral and show nothing), and so + * the marginal per-tag cost is visible across two realistic tag counts. It also covers the builder + * tag path ({@code withTag} before {@code start()}, the OTel-bridge shape) alongside the + * set-after-start path, so the startSpan/buildSpan lineages can be tracked as they diverge across + * releases. + * + *

Read the allocation columns, not just throughput. Run with {@code -prof gc}: {@code + * gc.alloc.rate.norm} (B/op) is deterministic run-to-run and is the primary signal; throughput is + * thermal/contention-fragile on a laptop and should be treated as directional. Multi-fork + * ({@code @Fork(3)}) guards against per-fork inlining bimodality. + * + *

Deliberately drift-stable so it can be copied onto past release tags and back-checked. + * It touches only API that is byte-identical from v1.53.0 to master: {@link + * CoreTracer#buildSpan(String, CharSequence)} / {@link CoreTracer#startSpan(String, CharSequence)}, + * {@code AgentSpan.setTag(String, ...)} / {@code finish()}, the {@link Tags} constants, and the + * five-method {@code Writer} interface (implemented as a no-op {@link DropWriter}). If you add to + * it, keep it inside that stable surface or grafting it onto old tags for the historical curve will + * stop compiling. (Source rebuilds only reach ~v1.53 — older tags hit dead build-time dependencies; + * deeper history is a published-jar job.) The {@code *ViaPrototype} / {@code + * *ViaPrototypeStartSpan} arms are the exception: they exercise {@link SpanPrototype} (new in this + * PR) and do not graft onto pre-SpanPrototype tags — the historical table above covers the baseline + * arms only. + * + *

Spans are finished against {@link DropWriter} so the create/tag/finish allocation is isolated + * from serialization and agent I/O — those live on a different lever and would otherwise leak into + * the {@code -prof gc} number via the writer's background threads. + * + *

Multi-threaded on purpose ({@code @Threads(8)}); some tracer optimizations only show under + * contention. Use {@code -t 1} for a single-threaded run. + * + *

Historical allocation (B/op, {@code gc.alloc.rate.norm}), this benchmark grafted onto + * each release tag and run {@code @Threads(8) -f3 -wi5 -i5 -prof gc} (measured 2026-07). The ~1.59 + * drop is the TagMap 1.0 shared-Entry default flip; the ~1.61 drop is the interceptor/links + * cluster. Net 1.53 → 1.64 is -20% to -31% per arm. + * + *

+ * ver    bareStart bareBuild webServer viaBuilder jdbcClient
+ * 1.53      1330.7    1331.1    2058.7     2434.7     1821.3
+ * 1.54      1423.8    1423.8    2131.4     2491.1     2037.8
+ * 1.55      1308.8    1422.0    2098.1     2477.2     2029.4
+ * 1.56      1322.2    1393.0    2131.5     2464.2     2016.6
+ * 1.57      1321.6    1363.3    2063.3     2410.6     2073.8
+ * 1.58      1312.6    1310.5    2018.0     2442.1     2065.3
+ * 1.59      1174.9    1166.1    1869.0     1987.2     1866.0
+ * 1.60      1180.6    1192.1    1858.9     1963.3     1818.0
+ * 1.61       959.2     951.7    1639.2     1774.1     1619.4
+ * 1.62       948.0     948.7    1674.9     1787.1     1614.1
+ * 1.63       927.0     926.7    1626.8     1737.7     1429.8
+ * 1.64       923.3     958.4    1636.6     1751.1     1421.5
+ * Δ%         -30.6     -28.0     -20.5      -28.1      -22.0
+ * 
+ * + *

Throughput (ops/us) from the same runs — noisier, treat as directional only (laptop + * thermals + per-fork inlining bimodality; no Δ% given because there is no reliable trend): + * + *

+ * ver    bareStart bareBuild webServer viaBuilder jdbcClient
+ * 1.53        5.69      5.49      4.00       3.99       5.33
+ * 1.54        4.26      4.25      3.47       3.55       3.15
+ * 1.55        3.87      4.13      3.37       3.52       3.14
+ * 1.56        3.92      4.03      3.79       3.33       3.15
+ * 1.57        4.21      4.11      3.31       3.49       3.37
+ * 1.58        4.16      4.19      3.34       3.52       3.27
+ * 1.59        4.20      4.66      3.43       3.52       3.45
+ * 1.60        5.48      5.66      3.42       3.54       3.77
+ * 1.61        4.17      4.31      3.46       3.58       3.46
+ * 1.62        5.53      5.72      4.20       4.37       4.49
+ * 1.63        6.03      5.97      4.66       5.13       4.49
+ * 1.64        5.37      5.26      4.62       5.29       5.06
+ * 
+ */ +@State(Scope.Benchmark) +@Warmup(iterations = 5) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.Throughput) +@Threads(8) +@OutputTimeUnit(MICROSECONDS) +// -DTEST_LOG_LEVEL=warn: the jmh classpath picks up dd-trace-core's test logback.xml, whose root +// defaults to DEBUG -> per-span "Started/Finished span" logging would dominate the -prof gc alloc. +@Fork( + value = 3, + jvmArgs = {"-DTEST_LOG_LEVEL=warn"}) +public class SpanCreationBenchmark { + private static final String INSTRUMENTATION_NAME = "bench"; + private static final String SERVER_OPERATION_NAME = "servlet.request"; + // The DB-shaped span gets its own operation name -- a real jdbc span is never "servlet.request", + // and keeping the two shapes distinct by operation avoids conflating them. + private static final String JDBC_OPERATION_NAME = "database.query"; + + // int tag values are deliberately kept inside Integer's built-in cache (-128..127) so valueOf + // returns a shared box and boxing does not allocate — the bench then measures tag storage / path + // cost, not incidental boxing (which differs between setTag(int) and the builder's + // withTag(Number)). + + // Web-server-shaped known tags — the profile the dense store / SpanPrototype target. + private static final String COMPONENT_VALUE = "tomcat-server"; + private static final String HTTP_METHOD_VALUE = "GET"; + private static final String HTTP_ROUTE_VALUE = "/owners/{ownerId}"; + private static final String HTTP_URL_VALUE = "http://localhost:8080/owners/42"; + private static final int HTTP_STATUS_VALUE = 100; // in-cache; value itself is immaterial here + private static final int PEER_PORT_VALUE = 80; + + // JDBC/DB-client-shaped known tags — a higher-tag-count shape (9 vs the web shape's 7), matching + // what DatabaseClientDecorator + JDBCDecorator set on a statement span. + private static final String DB_COMPONENT_VALUE = "java-jdbc-statement"; + private static final String DB_TYPE_VALUE = "postgresql"; + private static final String DB_INSTANCE_VALUE = "petclinic"; + private static final String DB_USER_VALUE = "app"; + private static final String DB_OPERATION_VALUE = "SELECT"; + private static final String DB_STATEMENT_VALUE = "SELECT * FROM owners WHERE id = ?"; + private static final String DB_PEER_HOSTNAME_VALUE = "db.internal"; + private static final int DB_PEER_PORT_VALUE = 90; // in-cache; value itself is immaterial here + + CoreTracer tracer; + + // Baked-once prototypes carrying only the type-constant subset each baseline sets individually + // (component + span.kind; jdbc also db.type). The dynamic tags are set per-span in both arms, so + // the *ViaPrototype vs *Span delta isolates the construction-path seeding of just those + // constants. + SpanPrototype webProto; + SpanPrototype jdbcProto; + + @Setup + public void setup() { + // DropWriter keeps finish() from pulling in serialization / agent I/O, so -prof gc reflects + // span creation + tagging + PendingTrace completion only. + this.tracer = CoreTracer.builder().writer(new DropWriter()).build(); + this.webProto = + SpanPrototype.builder() + .initInstrumentationName(INSTRUMENTATION_NAME) + .initOperationName(SERVER_OPERATION_NAME) + .initComponentOnly(COMPONENT_VALUE) + .initKind(Tags.SPAN_KIND_SERVER) + .build(); + this.jdbcProto = + SpanPrototype.builder() + .initInstrumentationName(INSTRUMENTATION_NAME) + .initOperationName(JDBC_OPERATION_NAME) + .initComponentOnly(DB_COMPONENT_VALUE) + .initKind(Tags.SPAN_KIND_CLIENT) + .initTag(Tags.DB_TYPE, DB_TYPE_VALUE) + .build(); + } + + @TearDown + public void tearDown() { + this.tracer.close(); + } + + /** Baseline: create + finish a bare span via startSpan, no tags. */ + @Benchmark + public void bareStartSpan() { + AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME); + span.finish(); + } + + /** Baseline: create + finish a bare span via the builder path, no tags. */ + @Benchmark + public void bareBuildSpan() { + AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME).start(); + span.finish(); + } + + /** Web-server-shaped span: create -> set the typical known tags -> finish. */ + @Benchmark + public void webServerSpan() { + AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME).start(); + span.setTag(Tags.COMPONENT, COMPONENT_VALUE); + span.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER); + span.setTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE); + span.setTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE); + span.setTag(Tags.HTTP_URL, HTTP_URL_VALUE); + span.setTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE); + span.setTag(Tags.PEER_PORT, PEER_PORT_VALUE); + span.finish(); + } + + /** + * Web-server-shaped span via the builder tag path: tags accumulated on the builder with + * {@code withTag} and applied at {@code start()}, rather than set on the span afterward. This is + * the shape the OTel bridge takes (OTel {@code SpanBuilder.setAttribute} → dd builder), still + * live today for manual OTel and OTel-bridge auto-instrumentation. Compare against {@link + * #webServerSpan} (same tags, set after start) to track how the startSpan/buildSpan paths diverge + * across releases. + */ + @Benchmark + public void webServerSpanViaBuilder() { + AgentSpan span = + tracer + .buildSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME) + .withTag(Tags.COMPONENT, COMPONENT_VALUE) + .withTag(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER) + .withTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE) + .withTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE) + .withTag(Tags.HTTP_URL, HTTP_URL_VALUE) + .withTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE) + .withTag(Tags.PEER_PORT, PEER_PORT_VALUE) + .start(); + span.finish(); + } + + /** JDBC/DB-client-shaped span: create -> set the typical DB known tags (9) -> finish. */ + @Benchmark + public void jdbcClientSpan() { + AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, JDBC_OPERATION_NAME).start(); + span.setTag(Tags.COMPONENT, DB_COMPONENT_VALUE); + span.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_CLIENT); + span.setTag(Tags.DB_TYPE, DB_TYPE_VALUE); + span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE); + span.setTag(Tags.DB_USER, DB_USER_VALUE); + span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE); + span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE); + span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE); + span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE); + span.finish(); + } + + /** + * Web-server-shaped span via {@link SpanPrototype}: the type-constants (component, span.kind) + * ride a baked-once prototype seeded at construction; the dynamic http.* / peer.port tags are set + * per-span, as real instrumentation does. Compare against {@link #webServerSpan} (identical tags, + * all set individually) to read the prototype's construction-path win on a full span. + */ + @Benchmark + public void webServerSpanViaPrototype() { + AgentSpan span = tracer.buildSpan(webProto, null).start(); // null -> prototype's operationName + span.setTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE); + span.setTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE); + span.setTag(Tags.HTTP_URL, HTTP_URL_VALUE); + span.setTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE); + span.setTag(Tags.PEER_PORT, PEER_PORT_VALUE); + span.finish(); + } + + /** + * JDBC/DB-client-shaped span via {@link SpanPrototype}: component, span.kind, and db.type ride + * the prototype; the dynamic db.* / peer.* tags are set per-span. Compare against {@link + * #jdbcClientSpan}. + */ + @Benchmark + public void jdbcClientSpanViaPrototype() { + AgentSpan span = tracer.buildSpan(jdbcProto, null).start(); + span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE); + span.setTag(Tags.DB_USER, DB_USER_VALUE); + span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE); + span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE); + span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE); + span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE); + span.finish(); + } + + /** + * Web-server-shaped span via {@code startSpan(SpanPrototype, ...)} — the builder-free + * construction entry (no MultiSpanBuilder allocation), the auto-instrumentation path. Compare + * against {@link #webServerSpanViaPrototype} (same prototype, but {@code buildSpan(...).start()} + * allocates a builder) to read the builder-free saving, and against {@link #webServerSpan} for + * the full win. + */ + @Benchmark + public void webServerSpanViaPrototypeStartSpan() { + AgentSpan span = tracer.startSpan(webProto, null); // null -> prototype's operationName + span.setTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE); + span.setTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE); + span.setTag(Tags.HTTP_URL, HTTP_URL_VALUE); + span.setTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE); + span.setTag(Tags.PEER_PORT, PEER_PORT_VALUE); + span.finish(); + } + + /** JDBC/DB-client-shaped span via the builder-free {@code startSpan(SpanPrototype, ...)}. */ + @Benchmark + public void jdbcClientSpanViaPrototypeStartSpan() { + AgentSpan span = tracer.startSpan(jdbcProto, null); + span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE); + span.setTag(Tags.DB_USER, DB_USER_VALUE); + span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE); + span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE); + span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE); + span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE); + span.finish(); + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index c48d8f94df6..abaf9b3fa3e 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -75,6 +75,7 @@ import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration; import datadog.trace.bootstrap.instrumentation.api.SpanAttributes; import datadog.trace.bootstrap.instrumentation.api.SpanLink; +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; import datadog.trace.bootstrap.instrumentation.api.TagContext; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.civisibility.interceptor.CiVisibilityApmProtocolInterceptor; @@ -132,6 +133,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import java.util.zip.ZipOutputStream; +import javax.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -1041,6 +1043,27 @@ public CoreSpanBuilder buildSpan( return createMultiSpanBuilder(instrumentationName, operationName); } + /** + * Seeds identity (instrumentation name, operation, span type) and constant tags from a prototype. + * {@code operationName} overrides the prototype's when non-null — the explicit value wins, the + * prototype is the fallback. The prototype's tags are seeded during {@link CoreSpanBuilder} + * construction just before the builder's own tags, so explicit tags override prototype constants. + */ + @Override + public CoreSpanBuilder buildSpan( + @Nonnull final SpanPrototype prototype, CharSequence operationName) { + if (operationName == null) { + operationName = prototype.operationName(); + } + CoreSpanBuilder builder = + createMultiSpanBuilder(prototype.instrumentationName(), operationName); + builder.spanPrototype = prototype; + if (prototype.spanType() != null) { + builder.spanType = prototype.spanType(); + } + return builder; + } + MultiSpanBuilder createMultiSpanBuilder( final String instrumentationName, final CharSequence operationName) { return new MultiSpanBuilder(this, instrumentationName, operationName); @@ -1146,6 +1169,17 @@ public AgentSpan startSpan( this, instrumentationName, spanName, parent, CoreSpanBuilder.IGNORE_SCOPE, startTimeMicros); } + @Override + public AgentSpan startSpan( + @Nonnull final SpanPrototype prototype, final CharSequence operationName) { + return CoreSpanBuilder.startSpan( + this, + prototype, + operationName != null ? operationName : prototype.operationName(), + CoreSpanBuilder.USE_SCOPE, + CoreSpanBuilder.AUTO_ASSIGN_TIMESTAMP); + } + @Override public AgentScope activateSpan(AgentSpan span) { return scopeManager.activateSpan(span); @@ -1588,6 +1622,7 @@ public abstract static class CoreSpanBuilder implements AgentTracer.SpanBuilder // Builder attributes // Make sure any fields added here are also reset properly in ReusableSingleSpanBuilder.reset protected TagMap.Ledger tagLedger; + protected SpanPrototype spanPrototype = SpanPrototype.NONE; protected long timestampMicro; protected AgentSpanContext parent; protected String serviceName; @@ -1626,6 +1661,7 @@ protected static final DDSpan buildSpan( boolean errorFlag, CharSequence spanType, TagMap.Ledger tagLedger, + SpanPrototype spanPrototype, List links, Object builderRequestContextDataAppSec, Object builderRequestContextDataIast, @@ -1645,6 +1681,7 @@ protected static final DDSpan buildSpan( errorFlag, spanType, tagLedger, + spanPrototype, links, builderRequestContextDataAppSec, builderRequestContextDataIast, @@ -1730,6 +1767,7 @@ protected AgentSpan startImpl() { this.errorFlag, this.spanType, this.tagLedger, + this.spanPrototype, this.links, this.builderRequestContextDataAppSec, this.builderRequestContextDataIast, @@ -1756,6 +1794,33 @@ protected static final AgentSpan startSpan( false /* errorFlag */, null /* spanType */, null /* tagLedger */, + SpanPrototype.NONE /* spanPrototype */, + null /* links */, + null /* appSec */, + null /* iast */, + null /* ciViz */); + } + + protected static final AgentSpan startSpan( + final CoreTracer tracer, + final SpanPrototype prototype, + final CharSequence operationName, + final boolean ignoreScope, + final long timestampMicros) { + return startSpan( + tracer, + AUTO_ASSIGN_SPAN_ID, + prototype.instrumentationName(), + timestampMicros, + null /* serviceName */, + operationName, + null /* resourceName */, + null /* specifiedParentSpanContext */, + ignoreScope, + false /* errorFlag */, + prototype.spanType(), + null /* tagLedger */, + prototype, null /* links */, null /* appSec */, null /* iast */, @@ -1775,6 +1840,7 @@ protected static final AgentSpan startSpan( boolean errorFlag, CharSequence spanType, TagMap.Ledger tagLedger, + SpanPrototype spanPrototype, List links, Object builderRequestContextDataAppSec, Object builderRequestContextDataIast, @@ -1837,6 +1903,7 @@ protected static final AgentSpan startSpan( errorFlag, spanType, tagLedger, + spanPrototype, links, builderRequestContextDataAppSec, builderRequestContextDataIast, @@ -1975,6 +2042,7 @@ protected static final DDSpanContext buildSpanContext( boolean errorFlag, CharSequence spanType, TagMap.Ledger tagLedger, + SpanPrototype spanPrototype, List links, Object builderRequestContextDataAppSec, Object builderRequestContextDataIast, @@ -2213,6 +2281,18 @@ protected static final DDSpanContext buildSpanContext( // the builder. This is the order that the tags were added previously, but maybe the `tags` // set in the builder should come last, so that they override other tags. context.setAllTags(mergedTracerTags, mergedTracerTagsNeedsIntercept); + if (spanPrototype != SpanPrototype.NONE) { + // Seed the frozen constant tags through the interceptor. A cheaper bulk-share path that + // skips interception for non-intercepted tags is deferred to the dense-store / tag-registry + // work, which will expose intercept status at the internal-api level. + context.setAllTags(spanPrototype.tags()); + // Apply the integration-name side effect BaseDecorator.afterStart performs alongside the + // component tag; IntegrationAdder serializes it as _dd.integration. + final CharSequence integrationName = spanPrototype.integrationName(); + if (integrationName != null) { + context.setIntegrationName(integrationName); + } + } context.setAllTags(tagLedger); context.setAllTags(coreTags, coreTagsNeedsIntercept); context.setAllTags(rootSpanTags, rootSpanTagsNeedsIntercept); @@ -2293,6 +2373,7 @@ final boolean reset(String instrumentationName, CharSequence operationName) { this.operationName = operationName; if (this.tagLedger != null) this.tagLedger.reset(); + this.spanPrototype = SpanPrototype.NONE; this.timestampMicro = 0L; this.parent = null; this.serviceName = null; diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java new file mode 100644 index 00000000000..7a3722d77cf --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java @@ -0,0 +1,159 @@ +package datadog.trace.core; + +import static datadog.trace.bootstrap.instrumentation.api.Tags.COMPONENT; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_SERVER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.common.writer.ListWriter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Verifies the SpanPrototype construction path: {@code buildSpan(prototype, operationName)} and + * {@code startSpan(prototype, operationName)} seed the prototype's identity + constant tags, with + * the explicit operationName / builder tags overriding the prototype's (prototype = defaults). + */ +public class SpanPrototypeConstructionTest extends DDCoreJavaSpecification { + + private ListWriter writer; + private CoreTracer tracer; + private SpanPrototype prototype; + + @BeforeEach + void setup() { + writer = new ListWriter(); + tracer = tracerBuilder().writer(writer).build(); + prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initSpanType("web") + .initKind(SPAN_KIND_SERVER) + .initComponentOnly("test-component") + .build(); + } + + @AfterEach + void cleanup() { + tracer.close(); + } + + @Test + void buildSpanSeedsPrototypeAndFallsBackToPrototypeOperationName() { + DDSpan span = (DDSpan) tracer.buildSpan(prototype, null).start(); + try { + assertEquals("proto.op", span.getOperationName().toString()); // null -> prototype's + assertEquals("web", span.getSpanType()); + assertEquals("test-component", span.getTags().get(COMPONENT)); // constant tag seeded + } finally { + span.finish(); + } + } + + @Test + void seedsSpanKindOrdinalAndTag() { + // span.kind is intercepted (its ordinal drives isOutbound). The prototype's tags seed through + // the interceptor, so BOTH the ordinal side-effect and the span.kind tag Entry must land. + DDSpan span = (DDSpan) tracer.buildSpan(prototype, null).start(); + try { + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); // ordinal side-effect applied + assertEquals(SPAN_KIND_SERVER, span.getTags().get(SPAN_KIND)); // tag (shared Entry) present + } finally { + span.finish(); + } + } + + @Test + void explicitOperationNameOverridesPrototype() { + DDSpan span = (DDSpan) tracer.buildSpan(prototype, "explicit.op").start(); + try { + assertEquals("explicit.op", span.getOperationName().toString()); // explicit wins + } finally { + span.finish(); + } + } + + @Test + void startSpanSeedsPrototype() { + DDSpan span = (DDSpan) tracer.startSpan(prototype, null); + try { + assertEquals("proto.op", span.getOperationName().toString()); + assertEquals("test-component", span.getTags().get(COMPONENT)); + } finally { + span.finish(); + } + } + + @Test + void explicitBuilderTagOverridesPrototypeConstant() { + // prototype seeds `component` just before the builder's own tags, so the explicit withTag wins + DDSpan span = (DDSpan) tracer.buildSpan(prototype, null).withTag(COMPONENT, "override").start(); + try { + assertEquals("override", span.getTags().get(COMPONENT)); + } finally { + span.finish(); + } + } + + @Test + void initComponentAndIntegrationSetsIntegrationName() { + // Mirrors BaseDecorator.afterStart: the component tag is seeded AND the integration name is set + // on the context, which IntegrationAdder serializes as _dd.integration (field -> tag mapping is + // covered by IntegrationAdderTest). + SpanPrototype proto = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initComponentAndIntegration("netty") + .build(); + DDSpan span = (DDSpan) tracer.buildSpan(proto, "op").start(); + try { + assertEquals("netty", span.getTags().get(COMPONENT)); // component tag seeded + assertEquals("netty", ((DDSpanContext) span.spanContext()).getIntegrationName()); + } finally { + span.finish(); + } + } + + @Test + void initComponentOnlyDoesNotSetIntegrationName() { + // initComponentOnly is tag-only: no integration-name side effect, so no _dd.integration. + SpanPrototype proto = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initComponentOnly("netty") + .build(); + DDSpan span = (DDSpan) tracer.buildSpan(proto, "op").start(); + try { + assertEquals("netty", span.getTags().get(COMPONENT)); // tag present + assertNull(((DDSpanContext) span.spanContext()).getIntegrationName()); // but no integration + } finally { + span.finish(); + } + } + + @Test + void extendsWithComponentOnlyOverrideLeavesInheritedIntegrationName() { + // Documents a known desync: overriding an inherited initComponentAndIntegration component with + // tag-only initComponentOnly does NOT clear the inherited integration name. Use + // initComponentAndIntegration to override both together. + SpanPrototype base = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initComponentAndIntegration("netty") + .build(); + SpanPrototype derived = + SpanPrototype.builder().extends_(base).initComponentOnly("other").build(); + DDSpan span = (DDSpan) tracer.buildSpan(derived, "op").start(); + try { + assertEquals("other", span.getTags().get(COMPONENT)); // component overridden + // integration name stays inherited from the base (the documented desync) + assertEquals("netty", ((DDSpanContext) span.spanContext()).getIntegrationName()); + } finally { + span.finish(); + } + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/api/SpanPrototypeBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/SpanPrototypeBenchmark.java new file mode 100644 index 00000000000..c75e9eefa94 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/api/SpanPrototypeBenchmark.java @@ -0,0 +1,96 @@ +package datadog.trace.api; + +import static java.util.concurrent.TimeUnit.SECONDS; + +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +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.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Per-mechanism benchmark for {@link SpanPrototype}: the constant-tag application a span pays at + * start. Compares the three phases of the mechanism, holding the resulting tag set identical: + * + *
    + *
  • oldPerSpanStamps — a fresh {@code TagMap} filled by N individual {@code set(entry)} + * calls, as {@code BaseDecorator.afterStart} does today (once per span). + *
  • newBulkApply — a fresh map + one {@code putAll} of the baked-once prototype (the + * afterStart-consolidation on-ramp). + *
  • newConstructionSeed — the span's map is born as a {@code copy()} of the + * frozen prototype (clone-at-birth; what increment 2's construction wiring unlocks). + *
+ * + *

Isolates the constant-application only (not span creation or the {@code afterStart} virtual + * chain), so the delta is purely N-stamps vs. bulk-copy. All arms apply tags at the {@code TagMap} + * level and skip the per-tag {@code TagInterceptor} dispatch that the real construction seed still + * incurs (span.kind, analytics-rate, ... are intercepted). That dispatch is a common cost + * on both the old and new production paths, so it cancels in the delta — but it means the absolute + * ops/s and the new/old ratio here are a TagMap-level upper bound, not the end-to-end win. (The + * interceptor-free bulk-share is what TagInterceptor retirement eventually unlocks; the current + * seed intercepts.) Run with {@code -prof gc} — the interesting axes are ops/s and B/op. + */ +@State(Scope.Thread) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(SECONDS) +@Warmup(iterations = 5, time = 2) +@Measurement(iterations = 5, time = 2) +@Fork(3) +@Threads(8) +public class SpanPrototypeBenchmark { + + // The constant set a typical server span carries, as cached entries (the shared-Entry + // hand-optimization the decorators use today). + private static final TagMap.Entry COMPONENT = TagMap.Entry.create(Tags.COMPONENT, "netty"); + private static final TagMap.Entry KIND = + TagMap.Entry.create(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER); + private static final TagMap.Entry LANGUAGE = + TagMap.Entry.create(DDTags.LANGUAGE_TAG_KEY, DDTags.LANGUAGE_TAG_VALUE); + private static final TagMap.Entry ANALYTICS = + TagMap.Entry.create(DDTags.ANALYTICS_SAMPLE_RATE, 1.0d); + + private SpanPrototype prototype; + + @Setup(Level.Trial) + public void setUp() { + // Baked once — the same constants, composed through the builder. + prototype = + SpanPrototype.builder() + .initComponentOnly("netty") + .initKind(Tags.SPAN_KIND_SERVER) + .initTag(DDTags.LANGUAGE_TAG_KEY, DDTags.LANGUAGE_TAG_VALUE) + .initTag(ANALYTICS) + .build(); + } + + @Benchmark + public TagMap oldPerSpanStamps() { + TagMap tags = TagMap.create(); + tags.set(COMPONENT); + tags.set(KIND); + tags.set(LANGUAGE); + tags.set(ANALYTICS); + return tags; + } + + @Benchmark + public TagMap newBulkApply() { + TagMap tags = TagMap.create(); + tags.putAll(prototype.tags()); + return tags; + } + + @Benchmark + public TagMap newConstructionSeed() { + return prototype.tags().copy(); + } +} 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 761320a2205..37335e8d2d8 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -177,6 +177,17 @@ public static final class Entry extends EntryChange */ static final byte ANY = 0; + /** + * Whether {@code value} is treated as "no tag" — a null, or an empty {@link CharSequence}. Set + * paths that honor the tag-filtering contract (e.g. {@code AgentSpan.setTag}, {@link + * SpanPrototype}) can gate on this without constructing an Entry — which matters once a dense + * store makes per-tag Entry allocation the wrong path. + */ + public static boolean isEmptyValue(Object value) { + return value == null + || (value instanceof CharSequence && ((CharSequence) value).length() == 0); + } + /** * Entry for {@code (tag, value)}, or null when {@code value} is null or an empty {@code * CharSequence} -- checked by runtime type, so an empty String passed as {@code Object} skips @@ -184,23 +195,14 @@ public static final class Entry extends EntryChange */ @Nullable public static final Entry create(@Nonnull String tag, Object value) { - if (value == null) { - return null; - } - if (value instanceof CharSequence && ((CharSequence) value).length() == 0) { - return null; - } - return TagMap.Entry.newAnyEntry(tag, value); + return isEmptyValue(value) ? null : TagMap.Entry.newAnyEntry(tag, value); } /** If value is non-null, returns a new TagMap.Entry If value is null or empty, returns null */ @Nullable public static final Entry create(@Nonnull String tag, CharSequence value) { // NOTE: From the static typing, we know that value is not a primitive box - - return (value == null || value.length() == 0) - ? null - : TagMap.Entry.newObjectEntry(tag, value); + return isEmptyValue(value) ? null : TagMap.Entry.newObjectEntry(tag, value); } public static final Entry create(@Nonnull String tag, boolean value) { diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java index 237ea38267f..c38d3bcdd8b 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java @@ -350,6 +350,30 @@ default AgentSpan blackholeSpan() { */ SpanBuilder buildSpan(String instrumentationName, CharSequence spanName); + /** + * Returns a SpanBuilder seeded from a {@link SpanPrototype}: the prototype supplies the + * instrumentation name, span type, and constant tags. {@code operationName} overrides the + * prototype's when non-null — the explicit value wins, the prototype is the fallback. + * + *

This default seeds identity only; a real tracer should override it to also seed the + * prototype's tags (see {@code CoreTracer}). The no-op tracer discards tags, so identity-only + * is correct there. + */ + default SpanBuilder buildSpan(@Nonnull SpanPrototype prototype, CharSequence operationName) { + return buildSpan( + prototype.instrumentationName(), + operationName != null ? operationName : prototype.operationName()); + } + + /** + * Creates and starts a span seeded from a {@link SpanPrototype}. This is the + * auto-instrumentation entry point mirroring {@link #startSpan(String, CharSequence)}; see + * {@link #buildSpan(SpanPrototype, CharSequence)}. + */ + default AgentSpan startSpan(@Nonnull SpanPrototype prototype, CharSequence operationName) { + return buildSpan(prototype, operationName).start(); + } + /** * Returns a SpanBuilder that can be used to produce one and only one span. By imposing the * single span creation limitation, this method is more efficient than {@link #buildSpan} @@ -453,6 +477,13 @@ public AgentSpan startSpan(final String instrumentationName, final CharSequence return NoopSpan.INSTANCE; } + @Override + public AgentSpan startSpan( + @Nonnull final SpanPrototype prototype, final CharSequence operationName) { + // The default routes through buildSpan(String,...), which is null on the noop tracer -> NPE. + return NoopSpan.INSTANCE; + } + @Override public AgentSpan startSpan( final String instrumentationName, final CharSequence spanName, final long startTimeMicros) { diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java new file mode 100644 index 00000000000..24ccd2b39d8 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java @@ -0,0 +1,179 @@ +package datadog.trace.bootstrap.instrumentation.api; + +import datadog.trace.api.TagMap; + +/** + * A baked-once, frozen descriptor of a span's constant initial state — the per-decorator constants + * (instrumentation name, span type, {@code span.kind}, component, …) that {@code + * BaseDecorator.afterStart} otherwise stamps one entry at a time, per span. + * + *

Composed through {@link #builder()}: authors set identity and constant tags via typed methods + * and never touch {@link TagMap} directly. {@link Builder#extends_(SpanPrototype)} inherits a base + * prototype (e.g. a SpanType base like {@code HttpServer}) so an integration adds only what's + * specific to it. Rides the existing {@code TagMap} API, so it's independent of any deeper TagMap + * rework — the internal seed can get faster without changing this surface. + * + *

v1 carries identity + constant tags. Derivation / canonicalization / lifecycle hooks are + * deliberately out — grown when the work that needs each arrives, not pre-slotted. + */ +public final class SpanPrototype { + /** The empty prototype — for spans created without a decorator-provided prototype. */ + public static final SpanPrototype NONE = builder().build(); + + public static Builder builder() { + return new Builder(); + } + + private final String instrumentationName; + private final CharSequence operationName; + private final CharSequence spanType; + private final CharSequence integrationName; + private final TagMap tags; // frozen + + private SpanPrototype(final Builder builder) { + this.instrumentationName = builder.instrumentationName; + this.operationName = builder.operationName; + this.spanType = builder.spanType; + this.integrationName = builder.integrationName; + this.tags = builder.tags.immutableCopy(); + } + + public String instrumentationName() { + return instrumentationName; + } + + public CharSequence operationName() { + return operationName; + } + + public CharSequence spanType() { + return spanType; + } + + /** + * The integration name to record on the span context ({@code setIntegrationName}), which the + * IntegrationAdder serializer step turns into {@code _dd.integration}. Null unless set via {@link + * Builder#initComponentAndIntegration}. Mirrors {@code BaseDecorator.afterStart}'s side effect. + */ + public CharSequence integrationName() { + return integrationName; + } + + /** The frozen constant tags — the internal seed applied at span construction. */ + public TagMap tags() { + return tags; + } + + public static final class Builder { + private String instrumentationName; + private CharSequence operationName; + private CharSequence spanType; + private CharSequence integrationName; + // Internal accumulator — never exposed; authors compose via the typed methods below. + private final TagMap tags = TagMap.create(); + + private Builder() {} + + /** + * Inherit a base prototype's identity and constant tags (e.g. a SpanType base). Subsequent + * identity / {@code init*} calls on this builder override the inherited values. + */ + public Builder extends_(final SpanPrototype base) { + if (base != null) { + if (base.instrumentationName != null) { + this.instrumentationName = base.instrumentationName; + } + if (base.operationName != null) { + this.operationName = base.operationName; + } + if (base.spanType != null) { + this.spanType = base.spanType; + } + if (base.integrationName != null) { + this.integrationName = base.integrationName; + } + this.tags.putAll(base.tags); + } + return this; + } + + public Builder initInstrumentationNames(final String[] instrumentationNames) { + return (instrumentationNames == null || instrumentationNames.length == 0) + ? this + : initInstrumentationName(instrumentationNames[0]); + } + + public Builder initInstrumentationName(final String instrumentationName) { + this.instrumentationName = instrumentationName; + return this; + } + + public Builder initOperationName(final CharSequence operationName) { + this.operationName = operationName; + return this; + } + + public Builder initSpanType(final CharSequence spanType) { + this.spanType = spanType; + return this; + } + + /** Sets {@code span.kind}. */ + public Builder initKind(final CharSequence kind) { + return initTag(Tags.SPAN_KIND, kind); + } + + /** + * Sets the {@code component} tag only. Does NOT touch the integration name — so overriding a + * component inherited via {@link #initComponentAndIntegration} with this leaves the inherited + * integration name in place (a desync). Use {@link #initComponentAndIntegration} to override + * both together. + */ + public Builder initComponentOnly(final CharSequence component) { + return initTag(Tags.COMPONENT, component); + } + + /** + * Sets the {@code component} tag AND records it as the integration name — the {@code + * BaseDecorator.afterStart} pairing (component tag + {@code setIntegrationName(component)}, + * which the IntegrationAdder serializer turns into {@code _dd.integration}). Null/empty is a + * no-op for both. + */ + public Builder initComponentAndIntegration(final CharSequence component) { + if (!TagMap.Entry.isEmptyValue(component)) { + this.tags.set(Tags.COMPONENT, component); + this.integrationName = component; + } + return this; + } + + public Builder initTag(final String key, final CharSequence value) { + if (!TagMap.Entry.isEmptyValue(value)) { + this.tags.set(key, value); + } + return this; + } + + public Builder initTag(final String key, final Object value) { + if (!TagMap.Entry.isEmptyValue(value)) { + this.tags.set(key, value); + } + return this; + } + + /** + * Advanced/internal: reuse an already-built entry — a decorator's cached constant or a metric + * entry — rather than re-creating it. Authors should prefer the typed {@code init*} methods. + */ + public Builder initTag(final TagMap.EntryReader entry) { + if (entry != null) { + this.tags.set(entry); + } + return this; + } + + public SpanPrototype build() { + return new SpanPrototype(this); + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java new file mode 100644 index 00000000000..522a2918036 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java @@ -0,0 +1,52 @@ +package datadog.trace.bootstrap.instrumentation.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +class SpanPrototypeTest { + + @Test + void extendsInheritsBaseIdentityAndTagsThenOverrides() { + final SpanPrototype base = + SpanPrototype.builder() + .initInstrumentationName("base") + .initSpanType("base-type") + .initKind("server") + .build(); + final SpanPrototype derived = + SpanPrototype.builder() + .extends_(base) + .initComponentOnly("netty") + .initSpanType("http") + .build(); + + assertEquals("base", derived.instrumentationName()); // inherited + assertEquals("http", derived.spanType()); // overridden + assertEquals("server", derived.tags().getString(Tags.SPAN_KIND)); // inherited tag + assertEquals("netty", derived.tags().getString(Tags.COMPONENT)); // added tag + } + + @Test + void emptyOrNullConstantsAreDroppedNotBaked() { + // Match AgentSpan.setTag / the cached-Entry path: a null or empty constant is "no tag", not an + // empty tag. A raw tags.set would otherwise bake a tag that per-span stamping never emits. + final SpanPrototype proto = + SpanPrototype.builder() + .initComponentOnly("") // empty -> dropped + .initKind("") // empty -> dropped + .initTag("empty.cs", "") // empty CharSequence -> dropped + .initTag("null.cs", (CharSequence) null) // null -> dropped + .initTag("null.obj", (Object) null) // null -> dropped + .initTag("kept", "v") // non-empty -> present + .build(); + + assertNull(proto.tags().getString(Tags.COMPONENT)); + assertNull(proto.tags().getString(Tags.SPAN_KIND)); + assertNull(proto.tags().getString("empty.cs")); + assertNull(proto.tags().getString("null.cs")); + assertNull(proto.tags().getString("null.obj")); + assertEquals("v", proto.tags().getString("kept")); // sanity: non-empty still stored + } +}