From 41666f56483b48ad0a0f92f5dc04752be111eafb Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 9 Jul 2026 05:35:18 -0400 Subject: [PATCH 1/5] Introduce SpanPrototype: baked-once constant span-tag descriptor The builder API (extends_/init*) plus its per-mechanism microbenchmark and a pure-API test, split out from the combined span-prototype work so the abstraction lands independently of the decorator demo. Co-Authored-By: Claude Opus 4.8 --- .../trace/api/SpanPrototypeBenchmark.java | 91 +++++++++++ .../instrumentation/api/SpanPrototype.java | 145 ++++++++++++++++++ .../api/SpanPrototypeTest.java | 25 +++ 3 files changed, 261 insertions(+) create mode 100644 internal-api/src/jmh/java/datadog/trace/api/SpanPrototypeBenchmark.java create mode 100644 internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java create mode 100644 internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java 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..2ef2b182dd4 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/api/SpanPrototypeBenchmark.java @@ -0,0 +1,91 @@ +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: + * + * + * + *

Isolates the constant-application only (not span creation or the {@code afterStart} virtual + * chain), so the delta is purely N-stamps vs. bulk-copy. 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() + .initComponent("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/bootstrap/instrumentation/api/SpanPrototype.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java new file mode 100644 index 00000000000..6a0d9f53e4c --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java @@ -0,0 +1,145 @@ +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 TagMap tags; // frozen + + private SpanPrototype(final Builder builder) { + this.instrumentationName = builder.instrumentationName; + this.operationName = builder.operationName; + this.spanType = builder.spanType; + this.tags = builder.tags.immutableCopy(); + } + + public String instrumentationName() { + return instrumentationName; + } + + public CharSequence operationName() { + return operationName; + } + + public CharSequence spanType() { + return spanType; + } + + /** 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; + // 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; + } + this.tags.putAll(base.tags); + } + return this; + } + + public Builder instrumentationName(final String[] instrumentationNames) { + return (instrumentationNames == null || instrumentationNames.length == 0) + ? this + : instrumentationName(instrumentationNames[0]); + } + + public Builder instrumentationName(final String instrumentationName) { + this.instrumentationName = instrumentationName; + return this; + } + + public Builder operationName(final CharSequence operationName) { + this.operationName = operationName; + return this; + } + + public Builder spanType(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 {@code component}. */ + public Builder initComponent(final CharSequence component) { + return initTag(Tags.COMPONENT, component); + } + + public Builder initTag(final String key, final CharSequence value) { + if (value != null) { + this.tags.set(key, value); + } + return this; + } + + public Builder initTag(final String key, final Object value) { + if (value != null) { + 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..fa5a20c1b1f --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java @@ -0,0 +1,25 @@ +package datadog.trace.bootstrap.instrumentation.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class SpanPrototypeTest { + + @Test + void extendsInheritsBaseIdentityAndTagsThenOverrides() { + final SpanPrototype base = + SpanPrototype.builder() + .instrumentationName("base") + .spanType("base-type") + .initKind("server") + .build(); + final SpanPrototype derived = + SpanPrototype.builder().extends_(base).initComponent("netty").spanType("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 + } +} From 5017d0597487bdf8f19533f4556e8e0596199148 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 21 Jul 2026 14:52:27 -0400 Subject: [PATCH 2/5] Drop null/empty SpanPrototype constants; add TagMap.Entry.isEmptyValue A prototype constant that is null or an empty CharSequence should be "no tag" -- matching AgentSpan.setTag and the decorators' cached-Entry path -- not a baked empty tag. Add TagMap.Entry.isEmptyValue as the single definition of an empty value (both Entry.create overloads now delegate to it), and gate SpanPrototype.Builder.initTag on it via the plain set(key, value) path so no Entry is allocated (the wrong path once tags are stored densely). Co-Authored-By: Claude Opus 4.8 --- .../main/java/datadog/trace/api/TagMap.java | 24 ++++++++++--------- .../instrumentation/api/SpanPrototype.java | 4 ++-- .../api/SpanPrototypeTest.java | 23 ++++++++++++++++++ 3 files changed, 38 insertions(+), 13 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 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/SpanPrototype.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java index 6a0d9f53e4c..c474b48dab0 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java @@ -114,14 +114,14 @@ public Builder initComponent(final CharSequence component) { } public Builder initTag(final String key, final CharSequence value) { - if (value != null) { + if (!TagMap.Entry.isEmptyValue(value)) { this.tags.set(key, value); } return this; } public Builder initTag(final String key, final Object value) { - if (value != null) { + if (!TagMap.Entry.isEmptyValue(value)) { this.tags.set(key, value); } return 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 index fa5a20c1b1f..bb3004fc0a3 100644 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java @@ -1,6 +1,7 @@ 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; @@ -22,4 +23,26 @@ void extendsInheritsBaseIdentityAndTagsThenOverrides() { 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() + .initComponent("") // 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 + } } From 57a62faa3a8f0ae1f5928d5f57e4de5b4e511460 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 21 Jul 2026 15:33:36 -0400 Subject: [PATCH 3/5] Add SpanPrototype construction path: buildSpan/startSpan(SpanPrototype) Thread a SpanPrototype through span construction: AgentTracer gains buildSpan/startSpan(SpanPrototype, operationName) (defaults seed identity only, correct for the noop tracer, with an explicit NoopTracerAPI.startSpan override). CoreTracer overrides buildSpan to seed the prototype's frozen constant tags in buildSpanContext at the precedence slot just before the builder's own tags (prototype and builder form one precedence atom; explicit builder tags win), and overrides startSpan to seed builder-free via the static CoreSpanBuilder.startSpan path (no MultiSpanBuilder allocation, mirroring startSpan(String,...)). Explicit operationName wins; null falls back to the prototype's. Intercepted constants (e.g. span.kind) seed through the interceptor so their context side-effects still fire. Prototype params @Nonnull. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/core/CoreTracer.java | 75 +++++++++++++ .../core/SpanPrototypeConstructionTest.java | 100 ++++++++++++++++++ .../instrumentation/api/AgentTracer.java | 31 ++++++ 3 files changed, 206 insertions(+) create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java 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..13bf01b6fcb 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,12 @@ 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()); + } context.setAllTags(tagLedger); context.setAllTags(coreTags, coreTagsNeedsIntercept); context.setAllTags(rootSpanTags, rootSpanTagsNeedsIntercept); @@ -2293,6 +2367,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..c70820fdd52 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java @@ -0,0 +1,100 @@ +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 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() + .instrumentationName("test-instr") + .operationName("proto.op") + .spanType("web") + .initKind(SPAN_KIND_SERVER) + .initComponent("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(); + } + } +} 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) { From 95219075aff67edc9e9f55badb475537e156f3a4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 21 Jul 2026 15:34:38 -0400 Subject: [PATCH 4/5] Carry integration name in SpanPrototype; make the builder surface uniformly init* BaseDecorator.afterStart sets the integration name as a side effect alongside the component tag (setIntegrationName(component)), which IntegrationAdder later serializes as _dd.integration. A prototype baking only the component tag would drop that. Add initComponentAndIntegration(component): sets the component tag AND records it as the integration name (inherited via extends_), applied via setIntegrationName at construction. Rename the builder setters to a uniform init* surface now that a component sibling exists and to convey "everything here bakes the prototype's initial state": initComponent -> initComponentOnly, instrumentationName -> initInstrumentationName(s), operationName -> initOperationName, spanType -> initSpanType. Accessors are unchanged. Renames are confined to SpanPrototype.Builder and its callers. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/core/CoreTracer.java | 6 ++ .../core/SpanPrototypeConstructionTest.java | 67 +++++++++++++++++-- .../trace/api/SpanPrototypeBenchmark.java | 11 ++- .../instrumentation/api/SpanPrototype.java | 48 +++++++++++-- .../api/SpanPrototypeTest.java | 12 ++-- 5 files changed, 126 insertions(+), 18 deletions(-) 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 13bf01b6fcb..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 @@ -2286,6 +2286,12 @@ protected static final DDSpanContext buildSpanContext( // 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); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java index c70820fdd52..7a3722d77cf 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java @@ -4,6 +4,7 @@ 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; @@ -28,11 +29,11 @@ void setup() { tracer = tracerBuilder().writer(writer).build(); prototype = SpanPrototype.builder() - .instrumentationName("test-instr") - .operationName("proto.op") - .spanType("web") + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initSpanType("web") .initKind(SPAN_KIND_SERVER) - .initComponent("test-component") + .initComponentOnly("test-component") .build(); } @@ -97,4 +98,62 @@ void explicitBuilderTagOverridesPrototypeConstant() { 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 index 2ef2b182dd4..c75e9eefa94 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/SpanPrototypeBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/SpanPrototypeBenchmark.java @@ -31,8 +31,13 @@ * * *

Isolates the constant-application only (not span creation or the {@code afterStart} virtual - * chain), so the delta is purely N-stamps vs. bulk-copy. Run with {@code -prof gc} — the - * interesting axes are ops/s and B/op. + * 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) @@ -60,7 +65,7 @@ public void setUp() { // Baked once — the same constants, composed through the builder. prototype = SpanPrototype.builder() - .initComponent("netty") + .initComponentOnly("netty") .initKind(Tags.SPAN_KIND_SERVER) .initTag(DDTags.LANGUAGE_TAG_KEY, DDTags.LANGUAGE_TAG_VALUE) .initTag(ANALYTICS) diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java index c474b48dab0..24ccd2b39d8 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java @@ -27,12 +27,14 @@ public static Builder 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(); } @@ -48,6 +50,15 @@ 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; @@ -57,6 +68,7 @@ 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(); @@ -77,28 +89,31 @@ public Builder extends_(final SpanPrototype base) { 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 instrumentationName(final String[] instrumentationNames) { + public Builder initInstrumentationNames(final String[] instrumentationNames) { return (instrumentationNames == null || instrumentationNames.length == 0) ? this - : instrumentationName(instrumentationNames[0]); + : initInstrumentationName(instrumentationNames[0]); } - public Builder instrumentationName(final String instrumentationName) { + public Builder initInstrumentationName(final String instrumentationName) { this.instrumentationName = instrumentationName; return this; } - public Builder operationName(final CharSequence operationName) { + public Builder initOperationName(final CharSequence operationName) { this.operationName = operationName; return this; } - public Builder spanType(final CharSequence spanType) { + public Builder initSpanType(final CharSequence spanType) { this.spanType = spanType; return this; } @@ -108,11 +123,30 @@ public Builder initKind(final CharSequence kind) { return initTag(Tags.SPAN_KIND, kind); } - /** Sets {@code component}. */ - public Builder initComponent(final CharSequence component) { + /** + * 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); diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java index bb3004fc0a3..522a2918036 100644 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java @@ -11,12 +11,16 @@ class SpanPrototypeTest { void extendsInheritsBaseIdentityAndTagsThenOverrides() { final SpanPrototype base = SpanPrototype.builder() - .instrumentationName("base") - .spanType("base-type") + .initInstrumentationName("base") + .initSpanType("base-type") .initKind("server") .build(); final SpanPrototype derived = - SpanPrototype.builder().extends_(base).initComponent("netty").spanType("http").build(); + SpanPrototype.builder() + .extends_(base) + .initComponentOnly("netty") + .initSpanType("http") + .build(); assertEquals("base", derived.instrumentationName()); // inherited assertEquals("http", derived.spanType()); // overridden @@ -30,7 +34,7 @@ void emptyOrNullConstantsAreDroppedNotBaked() { // empty tag. A raw tags.set would otherwise bake a tag that per-span stamping never emits. final SpanPrototype proto = SpanPrototype.builder() - .initComponent("") // empty -> dropped + .initComponentOnly("") // empty -> dropped .initKind("") // empty -> dropped .initTag("empty.cs", "") // empty CharSequence -> dropped .initTag("null.cs", (CharSequence) null) // null -> dropped From e2a5162fdc5ab7f3d278baba76fb019246f05e88 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 21 Jul 2026 19:30:24 -0400 Subject: [PATCH 5/5] Add span-creation benchmark for the SpanPrototype construction path A dd-trace-core JMH benchmark covering the full create -> (tag) -> finish lifecycle, finished against a no-op DropWriter so -prof gc isolates create/tag/finish allocation from serialization. Pairs baseline shapes (web-server 7 tags, JDBC 9 tags; setTag and builder-withTag) with prototype arms: buildSpan(SpanPrototype).start() and the builder-free startSpan(SpanPrototype). Measured (Threads(8), -f3 -wi5 -i5 -prof gc): prototype construction cuts gc.alloc.rate.norm ~-5% web (-80 B/op) / ~-10% jdbc (-120 B/op) vs baseline -- tracking the number of baked constants (fewer per-span TagMap.Entry allocations). The builder-free startSpan is deterministic (no MultiSpanBuilder); buildSpan's builder is escape-analyzed away in this shallow micro, so startSpan is the EA-independent path for production's deeper/megamorphic call sites. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/core/DropWriter.java | 31 ++ .../trace/core/SpanCreationBenchmark.java | 308 ++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/DropWriter.java create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java 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(); + } +}