diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java
index c2bcd4a1cfb..eba7c661aff 100644
--- a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java
+++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java
@@ -255,6 +255,40 @@ public void webServerSpanViaPrototype() {
span.finish();
}
+ /**
+ * Web-server-shaped span the way decorators build it today: started by name, then the decorator's
+ * prototype stamped over it after start ({@code BaseDecorator.afterStart} -> {@code
+ * applyOverwriting}). The baseline the construction-time prototype paths have to beat.
+ */
+ @Benchmark
+ public void webServerSpanRetrofit() {
+ AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME);
+ span.applyOverwriting(webProto);
+ span.setTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE);
+ span.setTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE);
+ span.setTag(Tags.HTTP_URL, HTTP_URL_VALUE);
+ span.setTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE);
+ span.setTag(Tags.PEER_PORT, PEER_PORT_VALUE);
+ span.finish();
+ }
+
+ /**
+ * JDBC-client-shaped span the way decorators build it today: started by name, then the prototype
+ * stamped over it after start; see {@link #webServerSpanRetrofit}.
+ */
+ @Benchmark
+ public void jdbcClientSpanRetrofit() {
+ AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, JDBC_OPERATION_NAME);
+ span.applyOverwriting(jdbcProto);
+ span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE);
+ span.setTag(Tags.DB_USER, DB_USER_VALUE);
+ span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE);
+ span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE);
+ span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE);
+ span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE);
+ span.finish();
+ }
+
/**
* JDBC/DB-client-shaped span via {@link SpanPrototype}: component, span.kind, and db.type ride
* the prototype; the dynamic db.* / peer.* tags are set per-span. Compare against {@link
@@ -272,6 +306,23 @@ public void jdbcClientSpanViaPrototype() {
span.finish();
}
+ /**
+ * JDBC-client-shaped span via {@code singleSpanBuilder(SpanPrototype, ...)}: the thread's
+ * reusable builder carries the prototype. Compare against {@link #jdbcClientSpanViaPrototype} (a
+ * fresh multi-span builder) and {@link #jdbcClientSpanViaPrototypeStartSpan} (no builder).
+ */
+ @Benchmark
+ public void jdbcClientSpanViaPrototypeSingleSpanBuilder() {
+ AgentSpan span = tracer.singleSpanBuilder(jdbcProto, null).start();
+ span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE);
+ span.setTag(Tags.DB_USER, DB_USER_VALUE);
+ span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE);
+ span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE);
+ span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE);
+ span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE);
+ span.finish();
+ }
+
/**
* Web-server-shaped span via {@code startSpan(SpanPrototype, ...)} — the builder-free
* construction entry (no MultiSpanBuilder allocation), the auto-instrumentation path. Compare
diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java
index 5556fa26139..f346d02bf2c 100644
--- a/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java
+++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java
@@ -8,6 +8,8 @@
import datadog.trace.bootstrap.instrumentation.api.SpanPrototype;
import datadog.trace.bootstrap.instrumentation.api.Tags;
import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
+import datadog.trace.common.writer.Writer;
+import java.util.List;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
@@ -44,7 +46,8 @@
* outside {@code Integer}'s box cache, so primitive boxing shows up.
*
*
This copy: 1.68 snapshot (decorators apply a prototype in {@code afterStart}, #12037; peer
- * tags by id, #12715).
+ * tags by id, #12715), with the dense store's per-operation sizing hints, which the writer feeds as
+ * serialization would.
*/
@State(Scope.Benchmark)
@Warmup(iterations = 5)
@@ -103,7 +106,7 @@ public class TraceShapeBenchmark {
@Setup
public void setup(Blackhole blackhole) {
- this.tracer = CoreTracer.builder().writer(new DropWriter(blackhole)).build();
+ this.tracer = CoreTracer.builder().writer(new SizeRecordingWriter(blackhole)).build();
// the decorators' prototype chains: Base (type, component) -> Server / Client (kind, language)
this.serverProto =
SpanPrototype.builder()
@@ -267,6 +270,41 @@ public void newApi() {
server.finish();
}
+ /**
+ * {@link DropWriter}, except that it first records each span's dense-store size, as serialization
+ * does for a published trace. Without it the per-operation sizing hints never learn here, and
+ * every span would be presized to the initial hint and then grow.
+ */
+ static final class SizeRecordingWriter implements Writer {
+ private final Blackhole blackhole;
+
+ SizeRecordingWriter(Blackhole blackhole) {
+ this.blackhole = blackhole;
+ }
+
+ @Override
+ public void write(List trace) {
+ for (DDSpan span : trace) {
+ span.spanContext().recordDenseSize();
+ }
+ blackhole.consume(trace);
+ }
+
+ @Override
+ public void start() {}
+
+ @Override
+ public boolean flush() {
+ return true;
+ }
+
+ @Override
+ public void close() {}
+
+ @Override
+ public void incrementDropCounts(int spanCount) {}
+ }
+
/** The advice's exit: activate around the call, then close and finish. */
void finishScoped(AgentSpan span) {
ContextScope scope = tracer.activateSpan(span);
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java
index 74b7188402d..c63a172e3c4 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java
@@ -42,6 +42,7 @@
import datadog.trace.api.IdGenerationStrategy;
import datadog.trace.api.InstrumenterConfig;
import datadog.trace.api.Pair;
+import datadog.trace.api.SizingHint;
import datadog.trace.api.TagMap;
import datadog.trace.api.TraceConfig;
import datadog.trace.api.civisibility.config.BazelMode;
@@ -1160,8 +1161,26 @@ public CoreSpanBuilder buildSpan(
if (operationName == null) {
operationName = prototype.operationName();
}
- CoreSpanBuilder builder =
- createMultiSpanBuilder(prototype.instrumentationName(), operationName);
+ return seedFrom(
+ createMultiSpanBuilder(prototype.instrumentationName(), operationName), prototype);
+ }
+
+ /**
+ * Like {@link #buildSpan(SpanPrototype, CharSequence)}, for one span only: the thread's reusable
+ * builder carries the prototype (see {@link #singleSpanBuilder(String, CharSequence)}), which it
+ * drops when reset for the next span.
+ */
+ @Override
+ public CoreSpanBuilder singleSpanBuilder(
+ @Nonnull final SpanPrototype prototype, CharSequence operationName) {
+ if (operationName == null) {
+ operationName = prototype.operationName();
+ }
+ return seedFrom(singleSpanBuilder(prototype.instrumentationName(), operationName), prototype);
+ }
+
+ /** Seeds {@code builder}'s prototype and span type; its tags are seeded when the span starts. */
+ private static CoreSpanBuilder seedFrom(CoreSpanBuilder builder, SpanPrototype prototype) {
builder.spanPrototype = prototype;
if (prototype.spanType() != null) {
builder.spanType = prototype.spanType();
@@ -1442,8 +1461,9 @@ void write(final SpanList trace) {
writtenTrace.get(i).setTag(APM_ENABLED, 0);
}
}
- writer.write(writtenTrace);
+ writer.write(writtenTrace); // serialization records these spans' sizes
} else {
+ recordDenseSizes(writtenTrace);
// with span streaming this won't work - it needs to be changed
// to track an effective sampling rate instead, however, tests
// checking that a hard reference on a continuation prevents
@@ -1455,6 +1475,19 @@ void write(final SpanList trace) {
}
}
+ /**
+ * Tunes each span's per-operation sizing hint, for a trace that is dropped rather than written:
+ * published spans are recorded when they're serialized, with every tag present. Done here rather
+ * than at span finish: the early post-processors and the root's sampling decision add tags after
+ * a span finishes, and a hint that missed them would leave every later span of the operation
+ * growing its store.
+ */
+ private static void recordDenseSizes(List trace) {
+ for (int i = 0; i < trace.size(); i++) {
+ trace.get(i).spanContext().recordDenseSize();
+ }
+ }
+
private List interceptCompleteTrace(SpanList originalTrace) {
return interceptCompleteTrace(interceptors, originalTrace);
}
@@ -2373,6 +2406,15 @@ protected static final DDSpanContext buildSpanContext(
requestContextDataIast = builderRequestContextDataIast;
}
+ // Per-operation dense-store sizing: an entry (local-root) span carries tags a child doesn't,
+ // so the lane is chosen by whether there's a local parent. A span using its prototype's own
+ // operation name gets the hint cached on the prototype; any other span (including NONE's,
+ // which has no operation name) gets its operation's hint from the table. Null with the dense
+ // store off -- the flag test folds, so the default path does no lookup.
+ final SizingHint sizingHint =
+ spanPrototype.sizingHint(
+ !(resolvedParentSpanContext instanceof DDSpanContext), operationName);
+
// some attributes are inherited from the parent
context =
new DDSpanContext(
@@ -2400,7 +2442,8 @@ protected static final DDSpanContext buildSpanContext(
tracer.profilingContextIntegration,
tracer.injectBaggageAsTags,
tracer.injectLinksAsTags,
- tagParent);
+ tagParent,
+ sizingHint);
// By setting the tags on the context we apply decorators to any tags that have been set via
// the builder. The `mergedTracerTags` are always applied first (the precedence floor:
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java
index 95d8de1eaee..4391ed816d6 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java
@@ -20,6 +20,7 @@
import datadog.trace.api.KnownTagCodec;
import datadog.trace.api.KnownTags;
import datadog.trace.api.ProcessTags;
+import datadog.trace.api.SizingHint;
import datadog.trace.api.TagMap;
import datadog.trace.api.cache.DDCache;
import datadog.trace.api.cache.DDCaches;
@@ -58,6 +59,7 @@
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Function;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import javax.annotation.concurrent.GuardedBy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -389,6 +391,10 @@ public DDSpanContext(
null);
}
+ /**
+ * Without a sizing hint: for tests and benchmarks that build contexts directly. The tracer uses
+ * the constructor that takes one.
+ */
public DDSpanContext(
final DDTraceId traceId,
final long spanId,
@@ -415,6 +421,62 @@ public DDSpanContext(
final boolean injectBaggageAsTags,
final boolean injectLinksAsTags,
final TagMap readThroughParent) {
+ this(
+ traceId,
+ spanId,
+ parentId,
+ parentServiceName,
+ serviceNameSource,
+ serviceName,
+ operationName,
+ resourceName,
+ samplingPriority,
+ origin,
+ baggageItems,
+ w3cBaggage,
+ errorFlag,
+ spanType,
+ traceCollector,
+ requestContextDataAppSec,
+ requestContextDataIast,
+ CiVisibilityContextData,
+ pathwayContext,
+ disableSamplingMechanismValidation,
+ propagationTags,
+ profilingContextIntegration,
+ injectBaggageAsTags,
+ injectLinksAsTags,
+ readThroughParent,
+ null);
+ }
+
+ public DDSpanContext(
+ final DDTraceId traceId,
+ final long spanId,
+ final long parentId,
+ final CharSequence parentServiceName,
+ final CharSequence serviceNameSource,
+ final String serviceName,
+ final CharSequence operationName,
+ final CharSequence resourceName,
+ final int samplingPriority,
+ final CharSequence origin,
+ final Map baggageItems,
+ final Baggage w3cBaggage,
+ final boolean errorFlag,
+ final CharSequence spanType,
+ final TraceCollector traceCollector,
+ final Object requestContextDataAppSec,
+ final Object requestContextDataIast,
+ final Object CiVisibilityContextData,
+ final PathwayContext pathwayContext,
+ final boolean disableSamplingMechanismValidation,
+ final PropagationTags propagationTags,
+ final ProfilingContextIntegration profilingContextIntegration,
+ final boolean injectBaggageAsTags,
+ final boolean injectLinksAsTags,
+ final TagMap readThroughParent,
+ @Nullable final SizingHint sizingHint) {
assert traceCollector != null;
this.traceCollector = traceCollector;
@@ -442,7 +504,7 @@ public DDSpanContext(
// Reads through to the frozen tracer-level tags, if any.
TagMap parentTags = TagMap.readThroughParent(readThroughParent);
- this.knownTags = IdStore.create(parentTags);
+ this.knownTags = IdStore.create(parentTags, sizingHint);
this.customTags = HashStore.inheritedFrom(parentTags);
this.customTagsBorrowed = this.customTags != null;
@@ -642,11 +704,8 @@ public void setSpanType(final CharSequence spanType) {
*
* This is the construction-time seam ({@code CoreSpanBuilder}) -- see {@link
* #applyOverwriting} for the decorator {@code afterStart} seam (via {@link
- * DDSpan#applyOverwriting}), which needs different precedence. The context owns the tag map, so
- * the eventual cheaper bulk-share path (skipping interception for non-intercepted tags) and the
- * identity short-circuit will land here, with the dense store; a tag's intercept status is
- * already on its id ({@link KnownTagCodec#isIntercepted}). Until then each constant tag is
- * prechecked by the interceptor, identical to the per-tag calls this replaces.
+ * DDSpan#applyOverwriting}), which needs different precedence. The tags are seeded by {@link
+ * #seedPrototypeTags}, in bulk where the store and the prototype allow it.
*/
public void apply(@Nonnull final SpanPrototype prototype) {
if (this.spanType == null) {
@@ -655,7 +714,7 @@ public void apply(@Nonnull final SpanPrototype prototype) {
setSpanType(spanType);
}
}
- seedAbsentTags(prototype.tags());
+ seedPrototypeTags(prototype);
if (this.integrationName == null) {
final CharSequence integrationName = prototype.integrationName();
if (integrationName != null) {
@@ -676,7 +735,9 @@ public void applyOverwriting(@Nonnull final SpanPrototype prototype) {
setSpanType(spanType);
}
final TagMap tags = prototype.tags();
- setAllTags(tags, true);
+ if (!overwriteInBulk(prototype)) {
+ setAllTags(tags, true);
+ }
tagInterceptor.resolveSplitByTagsPrecedence(this, tags);
final CharSequence integrationName = prototype.integrationName();
if (integrationName != null) {
@@ -685,25 +746,131 @@ public void applyOverwriting(@Nonnull final SpanPrototype prototype) {
}
/**
- * Seeds tags that are not already present, routed through the interceptor. Mirrors {@link
- * #setAllTags(TagMap, boolean)}'s intercepting path but skips any key already set, so explicit
- * tags keep precedence over the prototype's constant defaults.
+ * How a {@link SpanPrototype}'s tags are seeded into a span, cached on the prototype for the
+ * interceptor it was checked against ({@link SpanPrototype#seedPlan}).
*/
- private void seedAbsentTags(final TagMap map) {
- if (map == null) {
- return;
+ private static final class SeedPlan {
+ final TagInterceptor interceptor;
+
+ /**
+ * The store can seed in bulk (the dense store) and no known tag needs the interceptor beyond
+ * span.kind (TagInterceptor#canSeedInBulk). False with the dense store off, so the default path
+ * never tries.
+ */
+ final boolean bulk;
+
+ /** The ordinal of the prototype's span.kind, or SPAN_KIND_UNSET. */
+ final byte kindOrdinal;
+
+ SeedPlan(TagInterceptor interceptor, boolean bulk, byte kindOrdinal) {
+ this.interceptor = interceptor;
+ this.bulk = bulk;
+ this.kindOrdinal = kindOrdinal;
+ }
+ }
+
+ private SeedPlan seedPlanFor(final SpanPrototype prototype) {
+ Object cached = prototype.seedPlan();
+ if (cached instanceof SeedPlan && ((SeedPlan) cached).interceptor == tagInterceptor) {
+ return (SeedPlan) cached;
}
+ TagMap tags = prototype.tags();
+ TagMap.Entry kind = tags.getEntry(KnownTags.SPAN_KIND_ID);
+ SeedPlan plan =
+ new SeedPlan(
+ tagInterceptor,
+ tags.knownStore().supportsBulkSeed() && tagInterceptor.canSeedInBulk(tags),
+ kind == null ? SPAN_KIND_UNSET : spanKindOrdinalOf(kind.stringValue()));
+ prototype.seedPlan(plan);
+ return plan;
+ }
+
+ /**
+ * Seeds a prototype's tags where the span doesn't already have them, so explicit tags keep
+ * precedence over the prototype's constant defaults. Two paths:
+ *
+ *
+ * - Bulk, when no known tag needs the interceptor beyond span.kind and none of them
+ * can already be present: the known tags are copied in at once ({@link
+ * IdStore#seedAbsent}), and span.kind's one effect, the cached kind ordinal, is set
+ * directly.
+ *
- Tag by tag otherwise, through the interceptor, mirroring {@link
+ * #setAllTags(TagMap, boolean)}'s intercepting path but skipping any tag already set.
+ *
+ *
+ * Custom tags always go tag by tag.
+ */
+ private void seedPrototypeTags(final SpanPrototype prototype) {
+ final TagMap tags = prototype.tags();
+ final SeedPlan plan = seedPlanFor(prototype);
synchronized (knownTags) {
- map.forEach(
- this,
- (ctx, tagEntry) -> {
- if (ctx.isPresent(tagEntry)) {
- return;
- }
- if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) {
- ctx.routePut(tagEntry);
- }
- });
+ if (seedKnownInBulk(plan, tags)) {
+ HashStore custom = tags.customStore();
+ if (custom != null) {
+ // rare: a prototype with custom tags seeds those one at a time
+ custom.forEach(this, (ctx, tagEntry) -> ctx.seedIfAbsent(tagEntry));
+ }
+ return;
+ }
+ tags.forEach(this, (ctx, tagEntry) -> ctx.seedIfAbsent(tagEntry));
+ }
+ }
+
+ /**
+ * The bulk step shared by {@link #seedPrototypeTags} and {@link #overwriteInBulk}: when the plan
+ * allows it and the masks prove none of the prototype's known tags can already be on the span,
+ * copies them in at once ({@link IdStore#seedAbsent}) and sets span.kind's ordinal directly, and
+ * returns true. Otherwise changes nothing and returns false. Call under the {@code knownTags}
+ * lock; custom tags are the caller's.
+ */
+ private boolean seedKnownInBulk(final SeedPlan plan, final TagMap tags) {
+ if (!plan.bulk || !knownTags.seedAbsent(tags.knownStore())) {
+ return false;
+ }
+ if (plan.kindOrdinal != SPAN_KIND_UNSET) {
+ spanKindOrdinal = plan.kindOrdinal;
+ }
+ return true;
+ }
+
+ /**
+ * The overwriting counterpart of {@link #seedPrototypeTags}'s bulk path: when the masks prove
+ * none of the prototype's known tags is on the span, overwriting them is just appending them, so
+ * they are copied in at once ({@link IdStore#seedAbsent}) and span.kind's ordinal set directly.
+ * Custom tags go through the interceptor one at a time. Returns false, having changed nothing,
+ * when the prototype needs the interceptor or a tag may already be present; the caller then
+ * overwrites tag by tag.
+ */
+ private boolean overwriteInBulk(final SpanPrototype prototype) {
+ final SeedPlan plan = seedPlanFor(prototype);
+ if (!plan.bulk) {
+ return false; // checked before taking the lock: the caller locks again on its fallback
+ }
+ final TagMap tags = prototype.tags();
+ synchronized (knownTags) {
+ if (!seedKnownInBulk(plan, tags)) {
+ return false;
+ }
+ HashStore custom = tags.customStore();
+ if (custom != null) {
+ // rare: a prototype with custom tags overwrites those one at a time
+ custom.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry));
+ }
+ return true;
+ }
+ }
+
+ /** Seeds one tag if the span doesn't have it yet. Call under the {@code knownTags} lock. */
+ private void seedIfAbsent(final TagMap.EntryReader tagEntry) {
+ if (!isPresent(tagEntry)) {
+ putIntercepted(tagEntry);
+ }
+ }
+
+ /** Puts one tag, unless the interceptor routes it. Call under the {@code knownTags} lock. */
+ private void putIntercepted(final TagMap.EntryReader tagEntry) {
+ if (!tagInterceptor.interceptTag(this, tagEntry)) {
+ routePut(tagEntry);
}
}
@@ -1515,13 +1682,7 @@ void setAllTags(final TagMap map, boolean needsIntercept) {
// forEach out-performs the iterator of TagMap
// Taking advantage of ability to pass through other context arguments
// to avoid using a capturing lambda
- map.forEach(
- this,
- (ctx, tagEntry) -> {
- if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) {
- ctx.routePut(tagEntry);
- }
- });
+ map.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry));
} else {
routePutAll(map);
}
@@ -1683,6 +1844,15 @@ public void setMetaStruct(final String field, final T value) {
}
}
+ /**
+ * Feeds this span's known-tag count back into its operation's {@link SizingHint}, held by its
+ * dense store, so later spans of the operation are presized. A best-effort racy max; with the
+ * dense store off this does nothing.
+ */
+ void recordDenseSize() {
+ knownTags.recordSize();
+ }
+
void earlyProcessTags(AppendableSpanLinks links) {
synchronized (knownTags) {
TagMap view = tagView();
@@ -1740,6 +1910,11 @@ void processTagsAndBaggage(
}
}
+ // Tune the operation's sizing hint now that every tag, serialization-time ones included, is
+ // present. This is where published spans are recorded; CoreTracer.write records only the
+ // spans it drops.
+ recordDenseSize();
+
// Baggage
Map baggageItemsWithPropagationTags;
if (this.injectBaggageAsTags) {
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java
index b487d4665b7..dbff87375d3 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java
@@ -151,6 +151,25 @@ public boolean needsIntercept(TagMap.EntryReader entry) {
return tagId != 0 ? needsIntercept(tagId) : isSplitServiceTag(entry.tag());
}
+ /**
+ * Whether a span prototype's tags can be seeded in bulk: none of its known tags needs the
+ * interceptor except {@code span.kind}, whose only effect is the span's cached kind ordinal,
+ * which the seed sets directly. Custom tags don't count: they are seeded one at a time. Depends
+ * only on this interceptor's configuration, so a prototype computes it once per interceptor.
+ */
+ public boolean canSeedInBulk(TagMap tags) {
+ for (TagMap.EntryReader entry : tags) {
+ long tagId = entry.tagId();
+ if (tagId == 0) {
+ continue;
+ }
+ if (tagId == KnownTags.SPAN_KIND_ID ? isSplitServiceTag(tagId) : needsIntercept(tagId)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
/** Whether a custom tag (one with no id) may be routed; only split-by-tags routes them. */
public boolean needsInterceptCustomTag(String customTag) {
return isSplitServiceTag(customTag);
diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java
new file mode 100644
index 00000000000..7d880dda6ea
--- /dev/null
+++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java
@@ -0,0 +1,109 @@
+package datadog.trace.core;
+
+import static datadog.trace.bootstrap.instrumentation.api.Tags.COMPONENT;
+import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND;
+import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_CLIENT;
+import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_SERVER;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import datadog.trace.api.IdStore;
+import datadog.trace.api.SizingHint;
+import datadog.trace.api.config.TracerConfig;
+import datadog.trace.bootstrap.instrumentation.api.SpanPrototype;
+import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
+import datadog.trace.common.writer.ListWriter;
+import datadog.trace.test.junit.utils.config.WithConfig;
+import org.junit.jupiter.api.Test;
+
+/**
+ * {@link SpanPrototypeConstructionTest} with the dense store on, where a prototype's known tags are
+ * seeded in bulk ({@code DDSpanContext.seedPrototypeTags}); runs in its own JVM because the flag is
+ * read once. Adds the bulk path's fallback: a span that already holds one of the prototype's tags
+ * is seeded tag by tag, so its own value wins and the rest still land.
+ */
+@WithConfig(key = TracerConfig.TRACE_DENSE_TAGS_ENABLED, value = "true")
+class SpanPrototypeConstructionDenseForkedTest extends SpanPrototypeConstructionTest {
+
+ @Test
+ void theDenseStoreIsOn() {
+ // guards the rest of this class: with the flag missed, every test would quietly run on the
+ // hash store, where the prototype tests pass too
+ assertEquals("DenseIdStore", IdStore.create(null).getClass().getSimpleName());
+ }
+
+ @Test
+ void aTagTheSpanAlreadyHasKeepsItsValueAndTheRestAreSeeded() {
+ CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build();
+ try {
+ SpanPrototype prototype =
+ SpanPrototype.builder()
+ .initInstrumentationName("test-instr")
+ .initOperationName("proto.op")
+ .initKind(SPAN_KIND_CLIENT)
+ .initComponentOnly("prototype-component")
+ .build();
+ DDSpan span = (DDSpan) tracer.buildSpan("test-instr", "op").start();
+ try {
+ span.setTag(COMPONENT, "explicit");
+ span.spanContext().apply(prototype);
+
+ assertEquals("explicit", span.getTags().get(COMPONENT)); // present: kept
+ assertEquals(SPAN_KIND_CLIENT, span.getTags().get(SPAN_KIND)); // absent: seeded
+ assertEquals(SPAN_KIND_CLIENT, span.getSpanKindString()); // and its ordinal
+ } finally {
+ span.finish();
+ }
+ } finally {
+ tracer.close();
+ }
+ }
+
+ @Test
+ void aPrototypeCachesTheSizingHintForItsOwnOperationNameOnly() {
+ SpanPrototype prototype =
+ SpanPrototype.builder()
+ .initInstrumentationName("test-instr")
+ .initOperationName("sizing.proto.op")
+ .build();
+ SizingHint own = prototype.sizingHint(true, "sizing.proto.op");
+ assertNotNull(own);
+ assertSame(SizingHint.forOperation("sizing.proto.op", true), own, "keyed like the table");
+ assertSame(own, prototype.sizingHint(true, UTF8BytesString.create("sizing.proto.op")));
+
+ // another operation name gets that name's hint, never the prototype's
+ SizingHint other = prototype.sizingHint(true, "sizing.proto.other");
+ assertSame(SizingHint.forOperation("sizing.proto.other", true), other);
+ assertNotSame(own, other);
+
+ // the entry and child lanes stay apart
+ assertNotSame(own, prototype.sizingHint(false, "sizing.proto.op"));
+ }
+
+ @Test
+ void aPrototypeSeedsEverySpanBuiltFromIt() {
+ CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build();
+ try {
+ SpanPrototype prototype =
+ SpanPrototype.builder()
+ .initInstrumentationName("test-instr")
+ .initOperationName("proto.op")
+ .initKind(SPAN_KIND_SERVER)
+ .initComponentOnly("prototype-component")
+ .build();
+ for (int i = 0; i < 3; i++) { // the first span builds the cached plan, the rest reuse it
+ DDSpan span = (DDSpan) tracer.buildSpan(prototype, null).start();
+ try {
+ assertEquals("prototype-component", span.getTags().get(COMPONENT));
+ assertEquals(SPAN_KIND_SERVER, span.getSpanKindString());
+ } finally {
+ span.finish();
+ }
+ }
+ } finally {
+ tracer.close();
+ }
+ }
+}
diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java
index 7a3722d77cf..a2303e3b44a 100644
--- a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java
+++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java
@@ -8,6 +8,7 @@
import datadog.trace.bootstrap.instrumentation.api.SpanPrototype;
import datadog.trace.common.writer.ListWriter;
+import java.lang.reflect.Field;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -88,6 +89,98 @@ void startSpanSeedsPrototype() {
}
}
+ @Test
+ void singleSpanBuilderSeedsPrototype() {
+ DDSpan span = (DDSpan) tracer.singleSpanBuilder(prototype, null).start();
+ try {
+ assertEquals("proto.op", span.getOperationName().toString());
+ assertEquals("web", span.getSpanType());
+ assertEquals("test-component", span.getTags().get(COMPONENT));
+ assertEquals(SPAN_KIND_SERVER, span.getSpanKindString());
+ } finally {
+ span.finish();
+ }
+ }
+
+ @Test
+ void aReusedSingleSpanBuilderDropsThePrototype() {
+ // the thread's reusable builder carried a prototype for the first span; reset must clear it
+ tracer.singleSpanBuilder(prototype, null).start().finish();
+ DDSpan plain = (DDSpan) tracer.singleSpanBuilder("other-instr", "plain.op").start();
+ try {
+ assertNull(plain.getTags().get(COMPONENT));
+ assertNull(plain.getSpanType());
+ } finally {
+ plain.finish();
+ }
+ }
+
+ @Test
+ void applyOverwritingAfterStartStampsThePrototype() {
+ // the decorator path: start by name, then afterStart stamps the prototype over the span
+ DDSpan span = (DDSpan) tracer.startSpan("test-instr", "decorated.op");
+ try {
+ span.applyOverwriting(prototype);
+ assertEquals("web", span.getSpanType());
+ assertEquals("test-component", span.getTags().get(COMPONENT));
+ assertEquals(SPAN_KIND_SERVER, span.getTags().get(SPAN_KIND));
+ assertEquals(SPAN_KIND_SERVER, span.getSpanKindString());
+ } finally {
+ span.finish();
+ }
+ }
+
+ @Test
+ void applyOverwritingReplacesATagTheSpanAlreadyHas() {
+ DDSpan span = (DDSpan) tracer.startSpan("test-instr", "decorated.op");
+ try {
+ span.setTag(COMPONENT, "earlier");
+ span.applyOverwriting(prototype);
+ assertEquals("test-component", span.getTags().get(COMPONENT)); // the prototype wins
+ assertEquals(SPAN_KIND_SERVER, span.getSpanKindString());
+ } finally {
+ span.finish();
+ }
+ }
+
+ @Test
+ void theSeedPlanTriesTheBulkPathOnlyWhenTheStoreSupportsIt() throws Exception {
+ // With the hash store (the default) the plan must never be bulk: applyOverwriting would take
+ // the tag lock for a bulk attempt that always fails, then again for its fallback.
+ tracer.buildSpan(prototype, null).start().finish();
+ Object plan = prototype.seedPlan();
+ Field bulk = plan.getClass().getDeclaredField("bulk");
+ bulk.setAccessible(true);
+ assertEquals(prototype.tags().knownStore().supportsBulkSeed(), bulk.getBoolean(plan));
+ }
+
+ @Test
+ void aPrototypesCustomTagIsSeededAndOverwritten() {
+ SpanPrototype withCustom =
+ SpanPrototype.builder()
+ .initInstrumentationName("test-instr")
+ .initOperationName("proto.op")
+ .initKind(SPAN_KIND_SERVER)
+ .initTag("my.custom.constant", "custom-value")
+ .build();
+ DDSpan built = (DDSpan) tracer.buildSpan(withCustom, null).start();
+ try {
+ assertEquals("custom-value", built.getTags().get("my.custom.constant"));
+ assertEquals(SPAN_KIND_SERVER, built.getSpanKindString());
+ } finally {
+ built.finish();
+ }
+
+ DDSpan decorated = (DDSpan) tracer.startSpan("test-instr", "decorated.op");
+ try {
+ decorated.setTag("my.custom.constant", "earlier");
+ decorated.applyOverwriting(withCustom);
+ assertEquals("custom-value", decorated.getTags().get("my.custom.constant"));
+ } finally {
+ decorated.finish();
+ }
+ }
+
@Test
void explicitBuilderTagOverridesPrototypeConstant() {
// prototype seeds `component` just before the builder's own tags, so the explicit withTag wins
diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java
index bb0e9803eac..c86955a2749 100644
--- a/internal-api/src/main/java/datadog/trace/api/Config.java
+++ b/internal-api/src/main/java/datadog/trace/api/Config.java
@@ -3457,7 +3457,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment())
this.spanBuilderReuseEnabled =
configProvider.getBoolean(GeneralConfig.SPAN_BUILDER_REUSE_ENABLED, true);
- this.traceDenseTagsEnabled = configProvider.getBoolean(TRACE_DENSE_TAGS_ENABLED, false);
+ this.traceDenseTagsEnabled = configProvider.getBoolean(TRACE_DENSE_TAGS_ENABLED, true);
this.tagNameUtf8CacheSize =
Math.max(configProvider.getInteger(GeneralConfig.TAG_NAME_UTF8_CACHE_SIZE, 128), 0);
this.tagValueUtf8CacheSize =
diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java
index 6d2d35f5be6..d5954fa193d 100644
--- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java
+++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java
@@ -6,6 +6,7 @@
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
+import javax.annotation.Nullable;
/**
* The dense {@link IdStore}: known tags in two parallel arrays -- each tag's 16-bit serial and its
@@ -181,6 +182,13 @@ public EntryReader next() {
/** The frozen parent's id store, or null. */
final DenseIdStore parent;
+ /**
+ * The per-operation hint this store was presized from, and that {@link #recordSize} tunes, or
+ * null. Held here rather than on the span so a span with the dense store off carries no field for
+ * it.
+ */
+ @Nullable private final SizingHint hint;
+
/**
* The reused flyweight that writes ({@code put}, {@code getAndPut}, {@code remove}) answer their
* prior value with; built on first use. Only writes use it: a store being written has a single
@@ -189,8 +197,90 @@ public EntryReader next() {
private DenseReader reader;
DenseIdStore(IdStore parent) {
+ this(parent, null);
+ }
+
+ /**
+ * Like {@link #DenseIdStore(IdStore)}, with the arrays allocated up front at {@code hint}'s size.
+ * A span's store presized this way never grows on its first tag, so {@link #grow} stays off the
+ * write path. A null hint, or one not yet seeded (a racy reader can see 0), keeps the shared
+ * empty arrays.
+ */
+ DenseIdStore(IdStore parent, @Nullable SizingHint hint) {
super(parent);
this.parent = (DenseIdStore) parent;
+ this.hint = hint;
+ if (hint != null) {
+ int cap = hint.size; // read the racy field once
+ if (cap > 0) {
+ this.serials = new char[cap];
+ this.values = new Object[cap];
+ }
+ }
+ }
+
+ /**
+ * The bulk seed: when {@code source} is a parentless dense store with no tombstones and its
+ * occupancy words share no bit with this store's (live, tombstoned or inherited, per level), none
+ * of its tags can be present here, so its serials and values are appended with two array copies
+ * and its occupancy ORed in. The occupancy words over-approximate presence, so a shared bit only
+ * means "maybe present" and falls back to the caller's tag-by-tag seed.
+ */
+ @Override
+ public boolean supportsBulkSeed() {
+ return true;
+ }
+
+ @Override
+ public boolean seedAbsent(IdStore source) {
+ if (!(source instanceof DenseIdStore)) {
+ return false;
+ }
+ DenseIdStore src = (DenseIdStore) source;
+ int n = src.used;
+ if (src.parent != null || n != src.count) {
+ return false; // read-through or tombstones: not a plain list of tags
+ }
+ long maybeHere = this.tombstoneMask | this.inheritedMask;
+ if ((src.spanOccupancy & (this.spanOccupancy | maybeHere)) != 0L
+ || (src.traceOccupancy & (this.traceOccupancy | maybeHere)) != 0L) {
+ return false;
+ }
+ if (n == 0) {
+ return true;
+ }
+ int used = this.used;
+ if (used + n > this.serials.length) {
+ this.growTo(used + n);
+ }
+ System.arraycopy(src.serials, 0, this.serials, used, n);
+ System.arraycopy(src.values, 0, this.values, used, n);
+ this.used = used + n;
+ this.count += n;
+ this.spanOccupancy |= src.spanOccupancy;
+ this.traceOccupancy |= src.traceOccupancy;
+ return true;
+ }
+
+ /** Grows the arrays to hold at least {@code minCapacity} slots. Rare, so kept out of line. */
+ private void growTo(int minCapacity) {
+ int newCap =
+ Math.max(minCapacity, Math.min(this.serials.length << 1, KnownTagCodec.serialLimit()));
+ this.serials = Arrays.copyOf(this.serials, newCap);
+ this.values = Arrays.copyOf(this.values, newCap);
+ }
+
+ @Override
+ public void recordSize() {
+ // Capped hints (the shared overflow) are fixed: unlike sharers mustn't grow them. Best-effort
+ // racy max; a benign plain-int write.
+ SizingHint hint = this.hint;
+ if (hint != null && !hint.capped) {
+ int used = this.used;
+ if (used > hint.size) {
+ hint.size = used;
+ }
+ }
}
/**
@@ -305,11 +395,11 @@ public EntryReader getAndPut(EntryReader entryReader) {
this.markLocal(bit, level);
this.count++;
}
- this.values[i] = entryReader.objectValue();
} else {
prior = this.mayInherit(bit, level) ? this.parent.getVisible(serial, bit, level) : null;
- this.append(serial, bit, level, entryReader.objectValue());
+ i = this.appendSlot(serial, bit, level);
}
+ this.values[i] = entryReader.objectValue(); // the one reference store; see store
return this.readerOf(tagId, prior);
}
@@ -339,7 +429,8 @@ public EntryReader remove(long tagId) {
this.values[i] = TOMBSTONE;
this.count--;
} else {
- this.appendSlot(serial, TOMBSTONE);
+ int slot = this.reserveSlot(serial); // first: it may replace this.values
+ this.values[slot] = TOMBSTONE;
}
this.markTombstone(bit, level);
return this.readerOf(tagId, local != null ? local : inherited);
@@ -358,35 +449,43 @@ private Object store(long tagId, Object value) {
long bit = slotBit(tagId);
int level = levelOf(tagId);
int i = this.mayHaveLocal(bit, level) ? this.indexOf(serial) : -1;
+ Object prior;
if (i >= 0) {
- Object prior = this.values[i];
- this.values[i] = value;
+ prior = this.values[i];
if (prior == TOMBSTONE) {
+ prior = null; // a tombstone replaced is a removal undone: no prior local value
this.markLocal(bit, level);
- this.count++; // a tombstone replaced is a removal undone: no prior local value
- return null;
+ this.count++;
}
- return prior;
+ } else {
+ prior = null;
+ i = this.appendSlot(serial, bit, level);
}
- this.append(serial, bit, level, value);
- return null;
+ // The one reference store on this path. Under G1 (before JDK 24's late barrier expansion) C2
+ // expands each reference-store site's write barriers inline, roughly 250 bytes apiece, so the
+ // overwrite and append paths share this store rather than each having their own: that keeps
+ // the setters that inline this small enough to inline into their callers.
+ this.values[i] = value;
+ return prior;
}
- /** Appends a live local tag. */
- private void append(char serial, long bit, int level, Object value) {
- this.appendSlot(serial, value);
+ /** Reserves a slot for a new live local tag, returning its index; the caller stores the value. */
+ private int appendSlot(char serial, long bit, int level) {
+ int i = this.reserveSlot(serial);
this.markLocal(bit, level);
this.count++;
+ return i;
}
- private void appendSlot(char serial, Object value) {
+ /** Reserves the next slot for {@code serial}, returning its index; its value is left unset. */
+ private int reserveSlot(char serial) {
int i = this.used;
if (i == this.serials.length) {
this.grow();
}
this.serials[i] = serial;
- this.values[i] = value;
this.used = i + 1;
+ return i;
}
/** Removes slot {@code i}, swapping the last slot into its place. The masks stay as they were. */
@@ -417,11 +516,9 @@ private void grow() {
if (length == 0) {
this.serials = new char[INIT_CAP];
this.values = new Object[INIT_CAP];
- return;
+ } else {
+ this.growTo(length + 1); // doubles, bounded by the serial limit
}
- int newCap = Math.max(Math.min(length << 1, KnownTagCodec.serialLimit()), length + 1);
- this.serials = Arrays.copyOf(this.serials, newCap);
- this.values = Arrays.copyOf(this.values, newCap);
}
/** The reused flyweight over a write's prior {@code value}, or null when there is none. */
@@ -524,7 +621,7 @@ private boolean readIfVisible(DenseIdStore level, int i, DenseReader reader) {
}
@Override
- void forEach(Consumer super EntryReader> consumer) {
+ public void forEach(Consumer super EntryReader> consumer) {
DenseReader reader = new DenseReader();
for (DenseIdStore level = this; level != null; level = level.parent) {
for (int i = 0; i < level.used; ++i) {
@@ -536,7 +633,7 @@ void forEach(Consumer super EntryReader> consumer) {
}
@Override
- void forEach(T thisObj, BiConsumer consumer) {
+ public void forEach(T thisObj, BiConsumer consumer) {
DenseReader reader = new DenseReader();
for (DenseIdStore level = this; level != null; level = level.parent) {
for (int i = 0; i < level.used; ++i) {
@@ -548,7 +645,8 @@ void forEach(T thisObj, BiConsumer consumer) {
}
@Override
- void forEach(T thisObj, U otherObj, TriConsumer consumer) {
+ public void forEach(
+ T thisObj, U otherObj, TriConsumer consumer) {
DenseReader reader = new DenseReader();
for (DenseIdStore level = this; level != null; level = level.parent) {
for (int i = 0; i < level.used; ++i) {
diff --git a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java
index 32c065fa8b2..81b5ee5e65f 100644
--- a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java
+++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java
@@ -309,7 +309,7 @@ boolean parentEntryVisible(Entry parentEntry, HashIdStore fromAncestor) {
}
@Override
- void forEach(Consumer super EntryReader> consumer) {
+ public void forEach(Consumer super EntryReader> consumer) {
HashSupport.forEach(this.buckets, consumer);
if (this.parent != null) {
this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry));
@@ -317,7 +317,7 @@ void forEach(Consumer super EntryReader> consumer) {
}
@Override
- void forEach(T thisObj, BiConsumer consumer) {
+ public void forEach(T thisObj, BiConsumer consumer) {
HashSupport.forEach(this.buckets, thisObj, consumer);
if (this.parent != null) {
this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry));
@@ -325,7 +325,8 @@ void forEach(T thisObj, BiConsumer consumer) {
}
@Override
- void forEach(T thisObj, U otherObj, TriConsumer consumer) {
+ public void forEach(
+ T thisObj, U otherObj, TriConsumer consumer) {
HashSupport.forEach(this.buckets, thisObj, otherObj, consumer);
if (this.parent != null) {
this.forEachParent(thisObj, otherObj, consumer);
diff --git a/internal-api/src/main/java/datadog/trace/api/HashStore.java b/internal-api/src/main/java/datadog/trace/api/HashStore.java
index 8b526d385a0..e142b6e5671 100644
--- a/internal-api/src/main/java/datadog/trace/api/HashStore.java
+++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java
@@ -263,21 +263,22 @@ boolean parentEntryVisible(Entry parentEntry, HashStore fromAncestor) {
}
/** Visits the local entries, then the visible inherited ones. */
- void forEach(Consumer super EntryReader> consumer) {
+ public void forEach(Consumer super EntryReader> consumer) {
HashSupport.forEach(this.buckets, consumer);
if (this.parent != null) {
this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry));
}
}
- void forEach(T thisObj, BiConsumer consumer) {
+ public void forEach(T thisObj, BiConsumer consumer) {
HashSupport.forEach(this.buckets, thisObj, consumer);
if (this.parent != null) {
this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry));
}
}
- void forEach(T thisObj, U otherObj, TriConsumer consumer) {
+ public void forEach(
+ T thisObj, U otherObj, TriConsumer consumer) {
HashSupport.forEach(this.buckets, thisObj, otherObj, consumer);
if (this.parent != null) {
this.forEachParent(thisObj, otherObj, consumer);
diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java
index fec64355c51..4de1cef8afe 100644
--- a/internal-api/src/main/java/datadog/trace/api/IdStore.java
+++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java
@@ -6,6 +6,7 @@
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
+import javax.annotation.Nullable;
/**
* Known tags keyed by their {@link KnownTags} id. Every method takes an id, or an {@link
@@ -79,7 +80,40 @@ interface Cursor {
/** A fresh id store reading through to {@code parent}'s known tags (parent may be null). */
public static IdStore create(TagMap parent) {
- return IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore);
+ return create(parent, null);
+ }
+
+ /**
+ * Like {@link #create(TagMap)}, presized from {@code hint} (see {@link SizingHintTable}); a null
+ * hint, or a store that isn't sized per span, ignores it.
+ */
+ public static IdStore create(TagMap parent, @Nullable SizingHint hint) {
+ return IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore, hint);
+ }
+
+ /**
+ * Feeds this store's size back into the hint it was presized from -- a best-effort, racy max, so
+ * later stores of the same operation are presized to it. A store created without a hint, or with
+ * a capped one, or that isn't sized per span, does nothing.
+ */
+ public void recordSize() {}
+
+ /**
+ * Copies every tag of {@code source} in at once if none of them can already be present here --
+ * locally, as a tombstone, or inherited -- and returns true; otherwise copies nothing and returns
+ * false, and the caller seeds tag by tag. A store that can't prove absence in bulk always returns
+ * false. {@code source} must be frozen.
+ */
+ public boolean seedAbsent(IdStore source) {
+ return false;
+ }
+
+ /**
+ * Whether {@link #seedAbsent} can ever succeed for this kind of store, so a caller can skip it,
+ * and the lock it needs, when it can't.
+ */
+ public boolean supportsBulkSeed() {
+ return false;
}
/** {@code tagId}'s slot bit. */
@@ -227,11 +261,11 @@ static IllegalArgumentException notKnown(long tagId) {
abstract boolean hasParent();
/** Visits the local entries, then the visible inherited ones. */
- abstract void forEach(Consumer super EntryReader> consumer);
+ public abstract void forEach(Consumer super EntryReader> consumer);
- abstract void forEach(T thisObj, BiConsumer consumer);
+ public abstract void forEach(T thisObj, BiConsumer consumer);
- abstract void forEach(
+ public abstract void forEach(
T thisObj, U otherObj, TriConsumer consumer);
/**
diff --git a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java
index 765906c62ec..98d3d823f64 100644
--- a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java
+++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java
@@ -1,5 +1,7 @@
package datadog.trace.api;
+import javax.annotation.Nullable;
+
/**
* Builds the {@link IdStore}s of the one implementation this JVM uses: {@link DenseIdStore} when
* the dense store is on, {@link HashIdStore} otherwise. Rollout scaffolding: the hash store stays
@@ -16,14 +18,23 @@
abstract class IdStoreFactory {
/** The JVM's store, chosen once by the dense-store flag. */
static final class Default {
+ /** Whether the dense store is on; folds wherever it is tested. */
+ static final boolean DENSE = Config.get().isTraceDenseTagsEnabled();
+
static final IdStoreFactory INSTANCE =
- Config.get().isTraceDenseTagsEnabled()
- ? DenseIdStoreFactory.INSTANCE
- : HashIdStoreFactory.INSTANCE;
+ DENSE ? DenseIdStoreFactory.INSTANCE : HashIdStoreFactory.INSTANCE;
}
/** A fresh store reading through to {@code parent} (may be null). */
- abstract IdStore create(IdStore parent);
+ final IdStore create(IdStore parent) {
+ return create(parent, null);
+ }
+
+ /**
+ * A fresh store reading through to {@code parent} (may be null), presized from {@code hint} where
+ * the store keeps sized arrays; a null hint presizes nothing.
+ */
+ abstract IdStore create(IdStore parent, @Nullable SizingHint hint);
/** A new empty parentless store, for a frozen map. */
abstract IdStore createEmpty();
@@ -35,8 +46,8 @@ final class HashIdStoreFactory extends IdStoreFactory {
private HashIdStoreFactory() {}
@Override
- IdStore create(IdStore parent) {
- return new HashIdStore(parent);
+ IdStore create(IdStore parent, @Nullable SizingHint hint) {
+ return new HashIdStore(parent); // buckets aren't sized per span; a hint is only for dense
}
@Override
@@ -51,8 +62,8 @@ final class DenseIdStoreFactory extends IdStoreFactory {
private DenseIdStoreFactory() {}
@Override
- IdStore create(IdStore parent) {
- return new DenseIdStore(parent);
+ IdStore create(IdStore parent, @Nullable SizingHint hint) {
+ return new DenseIdStore(parent, hint);
}
@Override
diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHint.java b/internal-api/src/main/java/datadog/trace/api/SizingHint.java
new file mode 100644
index 00000000000..238c5620178
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/SizingHint.java
@@ -0,0 +1,56 @@
+package datadog.trace.api;
+
+import datadog.trace.util.FlatHashtable;
+import javax.annotation.Nullable;
+
+/**
+ * Opaque per-operation dense-store sizing hint, and a self-contained {@link FlatHashtable.D1}
+ * entry: it carries everything the probe compares (the operation name as the {@link
+ * FlatHashtable.D1.Entry} key, plus the cached hash from {@link FlatHashtable.Entry#hash}) and the
+ * tuned payload ({@link #size}). Holding key, hash, and value in ONE object behind ONE array slot
+ * is deliberate — entry publication is a single reference store, so a reader sees {@code null} or a
+ * complete entry (never a torn one), and the {@code final} identity fields are visible even under
+ * racy publication (JMM final-field guarantee). That sidesteps the memory-ordering / visibility
+ * problems parallel key/hash/value arrays would create, no volatile or atomics.
+ *
+ * Opaque to everything outside {@code datadog.trace.api}: its only public member is the lookup,
+ * {@link #forOperation}. {@link DenseIdStore} reads {@link #size} to size a span's fresh store and
+ * writes it back (best-effort max, see {@link IdStore#recordSize}) at a terminal point; {@link
+ * SizingHintTable} mints them. Callers only ever hold the reference.
+ */
+public final class SizingHint extends FlatHashtable.D1.Entry {
+ // Payload: the tuned dense-store size. Plain racy int, updated best-effort max (see
+ // IdStore#recordSize -- not strictly monotonic under concurrent finishes). A stale/lost/lowered
+ // read only mis-sizes an array (over/under-provision), never corrupts tag data, so no
+ // synchronization. Not safely published by a racing create either (FlatHashtable class doc): a
+ // reader may see the default 0, which DenseIdStore treats as "no presize" -- benign.
+ int size;
+
+ // When true, size is fixed and recordSize won't grow it. For the shared overflow hint (a
+ // HETEROGENEOUS catch-all for over-budget operations): self-tuning it toward the observed max
+ // would converge to the max across unlike sharers and over-provision the lean ones.
+ final boolean capped;
+
+ /**
+ * The hint for spans of {@code operationName}, in the entry-span or child-span lane; null when
+ * the dense store is off (constant-folded) or there is no operation name. See {@link
+ * SizingHintTable#hintFor}.
+ */
+ @Nullable
+ public static SizingHint forOperation(@Nullable CharSequence operationName, boolean entrySpan) {
+ return SizingHintTable.hintFor(operationName, entrySpan);
+ }
+
+ /**
+ * A fresh, uncapped hint for {@code operationName}, seeded at {@link SizingHintTable#SEED_SIZE}.
+ */
+ SizingHint(String operationName) {
+ this(operationName, SizingHintTable.SEED_SIZE, false);
+ }
+
+ SizingHint(String operationName, int seedSize, boolean capped) {
+ super(operationName);
+ this.size = seedSize;
+ this.capped = capped;
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java
new file mode 100644
index 00000000000..555ae95b5a4
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java
@@ -0,0 +1,101 @@
+package datadog.trace.api;
+
+import datadog.trace.util.FlatHashtable;
+import javax.annotation.Nullable;
+
+/**
+ * Process-wide, self-tuning registry of per-operation {@link SizingHint}s, keyed by operation name.
+ * Pure static and package-private: the tracer resolves a hint at span build through {@link
+ * SizingHint#forOperation} and hands it to the span, which sizes its dense {@link IdStore} from it
+ * and records the actual size back when it finishes — the hint converges to the operation's real
+ * known-tag high-water mark, so later spans of that operation size correctly.
+ *
+ * Only meaningful with the dense store on ({@link IdStoreFactory.Default#DENSE}); with it off,
+ * {@link #hintFor} returns {@code null} before touching anything, so the default span path does no
+ * lookup and the lane tables are never even allocated (they live in a holder class).
+ *
+ *
Two lanes, because a span's dense size depends systematically on whether it is an entry
+ * (local-root) span or not: entry spans carry the trace-metadata / enriching tags and children
+ * don't, so the same operation name has two different steady-state sizes. {@link #hintFor} picks
+ * the lane.
+ *
+ *
Bounded + racy by design. Each lane is a fixed-capacity {@link FlatHashtable.D1} (never
+ * resized) so memory is bounded even under unbounded/dynamic operation names; once a lane's
+ * cardinality budget is spent, further operations share a capped overflow hint. Insertion and the
+ * best-effort-max size update are lock-free and deliberately racy (the table is used from many
+ * threads without synchronization): a lost size-counter update lets a lane slightly overshoot its
+ * budget (still bounded by the backing array, whose full-table insert just fails), and two threads
+ * minting the same operation at once may leave a shadowed, unreachable duplicate. Either only
+ * mis-sizes an array (over/under-provision) for a span or two, never corrupts tag data (see {@link
+ * FlatHashtable} and {@link SizingHint} for the rationale).
+ *
+ *
Keyed by the operation name's {@code String} form. In practice every operation name we see is
+ * a {@code String} or a {@link datadog.trace.bootstrap.instrumentation.api.UTF8BytesString}, whose
+ * {@code toString()} just returns its backing field — so the {@code toString()} here is O(1) and
+ * allocation-free on the hot path. A {@code null} operation name gets no hint (the span falls back
+ * to the generic default capacity); a missed hint is benign, never wrong.
+ */
+final class SizingHintTable {
+ private SizingHintTable() {}
+
+ // Seed for a fresh per-operation hint: a small floor that self-tunes up via best-effort-max
+ // recordSize. Kept small because the hint only grows -- a large seed would lock small spans into
+ // oversized arrays forever; a span that outgrows it pays one doubling.
+ static final int SEED_SIZE = 4;
+
+ // Fixed size for the shared over-budget hint: the untuned default, DenseIdStore's initial
+ // capacity (a compile-time constant, so reading it loads no class). Capped (never grown by
+ // recordSize) because it's a heterogeneous catch-all -- growing it would over-provision lean
+ // sharers to the max of an unlike cohort.
+ static final int OVERFLOW_SEED = DenseIdStore.INIT_CAP;
+
+ // Max distinct operation names per lane that get their own hint before collapsing to the capped
+ // overflow hint.
+ static final int CARDINALITY_LIMIT = 512;
+
+ /**
+ * The lanes, in a holder so they are only allocated once the dense store is on and a span asks
+ * for a hint.
+ */
+ static final class Lanes {
+ private Lanes() {}
+
+ // Entry (local-root, enriched) lane and non-entry (child) lane, keyed by operation name.
+ static final FlatHashtable.D1 ENTRY =
+ FlatHashtable.D1.createFixed(SizingHint.class, CARDINALITY_LIMIT);
+ static final FlatHashtable.D1 CHILD =
+ FlatHashtable.D1.createFixed(SizingHint.class, CARDINALITY_LIMIT);
+
+ // Shared capped hint each lane returns once its budget is exhausted.
+ static final SizingHint ENTRY_OVERFLOW = new SizingHint(null, OVERFLOW_SEED, /* capped */ true);
+ static final SizingHint CHILD_OVERFLOW = new SizingHint(null, OVERFLOW_SEED, /* capped */ true);
+ }
+
+ /**
+ * The sizing hint for {@code operationName} in the given lane: the existing one, a freshly-minted
+ * (seeded) one if the lane has budget, or the shared capped overflow hint if it's full. Returns
+ * {@code null} when the dense store is off (constant-folded, so the default span path does no
+ * lookup) and for a {@code null} operation name — the span then uses the generic default capacity
+ * (operation-less spans aren't reliably similar, so they get no hint). Hits are a single probe;
+ * the create path is warmup-rare.
+ */
+ @Nullable
+ static SizingHint hintFor(@Nullable CharSequence operationName, boolean entrySpan) {
+ if (!IdStoreFactory.Default.DENSE) {
+ return null;
+ }
+ if (operationName == null) {
+ return null;
+ }
+ // O(1) for String / UTF8BytesString (see class doc)
+ final String key = operationName.toString();
+ final FlatHashtable.D1 lane = entrySpan ? Lanes.ENTRY : Lanes.CHILD;
+ // One probe: returns a hit directly, and null only when the fixed lane is full and the key is
+ // absent.
+ final SizingHint hint = lane.tryGetOrCreateOrNull(key, SizingHint::new);
+ if (hint != null) {
+ return hint;
+ }
+ return entrySpan ? Lanes.ENTRY_OVERFLOW : Lanes.CHILD_OVERFLOW;
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java
index 37733e11c7e..6b28b431641 100644
--- a/internal-api/src/main/java/datadog/trace/api/TagMap.java
+++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java
@@ -1309,6 +1309,12 @@ private static Entry entryOf(EntryReader reader) {
return reader == null ? null : reader.entry();
}
+ /** This map's known-tag store. */
+ @Restricted(allowedIn = {"datadog.trace.core"})
+ public IdStore knownStore() {
+ return this.knownStore;
+ }
+
/** This map's custom store: null, borrowed ({@link #isCustomStoreBorrowed}), or its own. */
@Restricted(allowedIn = {"datadog.trace.core"})
public HashStore customStore() {
diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java
index 71767ea7fae..c0fb2936ad0 100644
--- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java
+++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java
@@ -360,6 +360,21 @@ default AgentSpan startSpan(@Nonnull SpanPrototype prototype, CharSequence opera
*/
SpanBuilder singleSpanBuilder(String instrumentationName, CharSequence spanName);
+ /**
+ * Returns a SpanBuilder seeded from a {@link SpanPrototype}, like {@link
+ * #buildSpan(SpanPrototype, CharSequence)}, that produces one and only one span, so it can be
+ * cheaper (see {@link #singleSpanBuilder(String, CharSequence)}). {@code operationName}
+ * overrides the prototype's when non-null.
+ *
+ * This default seeds identity only, like {@link #buildSpan(SpanPrototype, CharSequence)}'s.
+ */
+ default SpanBuilder singleSpanBuilder(
+ @Nonnull SpanPrototype prototype, CharSequence operationName) {
+ return singleSpanBuilder(
+ prototype.instrumentationName(),
+ operationName != null ? operationName : prototype.operationName());
+ }
+
void close();
/**
diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java
index 24ccd2b39d8..830ca3d33be 100644
--- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java
+++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java
@@ -1,6 +1,9 @@
package datadog.trace.bootstrap.instrumentation.api;
+import datadog.apicontract.Restricted;
+import datadog.trace.api.SizingHint;
import datadog.trace.api.TagMap;
+import javax.annotation.Nullable;
/**
* A baked-once, frozen descriptor of a span's constant initial state — the per-decorator constants
@@ -30,6 +33,23 @@ public static Builder builder() {
private final CharSequence integrationName;
private final TagMap tags; // frozen
+ /**
+ * The tracer core's plan for seeding these tags into a span (whether they can be copied in bulk,
+ * and the span kind they carry), built on first use. Racy by design: the plan is immutable, so a
+ * reader sees null or a complete plan, and two threads building it at once just build it twice.
+ */
+ private Object seedPlan;
+
+ /**
+ * The dense-store sizing hints for spans that use this prototype's own operation name, in the
+ * entry-span and child-span lanes, resolved on first use so later spans skip the table lookup.
+ * Racy like {@link #seedPlan}: a hint is stable for its operation name, so two threads resolving
+ * it at once agree.
+ */
+ private SizingHint entryHint;
+
+ private SizingHint childHint;
+
private SpanPrototype(final Builder builder) {
this.instrumentationName = builder.instrumentationName;
this.operationName = builder.operationName;
@@ -64,6 +84,51 @@ public TagMap tags() {
return tags;
}
+ /** The tracer core's seed plan for these tags, or null before the first span uses it. */
+ @Restricted(allowedIn = {"datadog.trace.core"})
+ public Object seedPlan() {
+ return seedPlan;
+ }
+
+ /** Records the tracer core's seed plan for these tags; see {@link #seedPlan()}. */
+ @Restricted(allowedIn = {"datadog.trace.core"})
+ public void seedPlan(Object plan) {
+ this.seedPlan = plan;
+ }
+
+ /**
+ * The sizing hint for a span built from this prototype with {@code operationName}, in the
+ * entry-span or child-span lane; null with the dense store off. Hints are keyed by operation
+ * name, like {@link SizingHint#forOperation}: when the span uses the prototype's own operation
+ * name, the hint is resolved once per lane and cached here, so the span skips the table lookup; a
+ * span given a different operation name gets that name's hint from the table, so spans of unlike
+ * operations never share one.
+ */
+ @Restricted(allowedIn = {"datadog.trace.core"})
+ @Nullable
+ public SizingHint sizingHint(boolean entrySpan, @Nullable CharSequence operationName) {
+ CharSequence own = this.operationName;
+ if (own == null || !sameName(own, operationName)) {
+ return SizingHint.forOperation(operationName, entrySpan);
+ }
+ SizingHint hint = entrySpan ? this.entryHint : this.childHint;
+ if (hint == null) {
+ hint = SizingHint.forOperation(own, entrySpan);
+ if (entrySpan) {
+ this.entryHint = hint;
+ } else {
+ this.childHint = hint;
+ }
+ }
+ return hint;
+ }
+
+ /** Whether {@code name} is {@code own}: the same instance, or the same characters. */
+ private static boolean sameName(CharSequence own, @Nullable CharSequence name) {
+ // toString() is O(1) for the String and UTF8BytesString operation names we see
+ return own == name || (name != null && own.toString().equals(name.toString()));
+ }
+
public static final class Builder {
private String instrumentationName;
private CharSequence operationName;
diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java
new file mode 100644
index 00000000000..cd62bad0c2b
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java
@@ -0,0 +1,218 @@
+package datadog.trace.api;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Registry semantics for {@link SizingHintTable}, and how {@link DenseIdStore} sizes from and tunes
+ * a {@link SizingHint}. Hints are only minted with the dense store on, and the flag is read once
+ * into a static final, so this runs in its own JVM ({@code ForkedTest}) with the flag set first.
+ *
+ *
The table is process-wide static state; each test uses distinct operation names so tests don't
+ * interfere. The overflow test fills the child lane, so no other test here uses that lane expecting
+ * a fresh hint.
+ */
+class SizingHintTableForkedTest {
+ static {
+ System.setProperty("dd.trace.experimental.dense.tags.enabled", "true");
+ }
+
+ /** Distinct known tag ids, in a stable order. */
+ static final List KNOWN_IDS = new ArrayList<>();
+
+ @BeforeAll
+ static void checkSetup() throws IllegalAccessException {
+ assertTrue(IdStoreFactory.Default.DENSE, "dense store must be on in this JVM");
+ for (Field field : KnownTags.class.getFields()) {
+ if (field.getType() != long.class) continue;
+ long id = field.getLong(null);
+ if (KnownTagCodec.isKnown(id) && !KNOWN_IDS.contains(id)) {
+ KNOWN_IDS.add(id);
+ }
+ }
+ assertTrue(KNOWN_IDS.size() > 24, "need enough known tags to exercise growth");
+ }
+
+ private static IdStore storeFor(SizingHint hint) {
+ IdStore store = IdStore.create(null, hint);
+ assertSame(DenseIdStore.class, store.getClass());
+ return store;
+ }
+
+ private static Object field(IdStore store, String name) {
+ try {
+ Field f = DenseIdStore.class.getDeclaredField(name);
+ f.setAccessible(true);
+ return f.get(store);
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private static int capacity(IdStore store) {
+ char[] serials = (char[]) field(store, "serials");
+ Object[] values = (Object[]) field(store, "values");
+ assertEquals(serials.length, values.length, "dense arrays stay parallel");
+ return serials.length;
+ }
+
+ /** Sets the first {@code n} known tags. */
+ private static void fill(IdStore store, int n) {
+ for (int i = 0; i < n; ++i) {
+ store.set(KNOWN_IDS.get(i), "v" + i);
+ }
+ assertEquals(n, store.size());
+ }
+
+ @Test
+ void nullOperationNameGetsNoHint() {
+ assertNull(SizingHintTable.hintFor(null, true));
+ assertNull(SizingHintTable.hintFor(null, false));
+ }
+
+ @Test
+ void nonStringCharSequenceIsKeyedByContent() {
+ // Operation names are commonly UTF8BytesString, not String; a content-equal name must resolve
+ // to the same hint as its String form (keyed by toString(), which is O(1) for these types).
+ SizingHint viaString = SizingHintTable.hintFor("registry.utf8", true);
+ CharSequence utf8 = UTF8BytesString.create("registry.utf8");
+ assertNotNull(viaString);
+ assertSame(viaString, SizingHintTable.hintFor(utf8, true));
+ }
+
+ @Test
+ void freshHintIsSeededAndUncapped() {
+ SizingHint hint = SizingHintTable.hintFor("registry.fresh", true);
+ assertNotNull(hint);
+ assertEquals(SizingHintTable.SEED_SIZE, hint.size);
+ assertFalse(hint.capped);
+ assertEquals("registry.fresh", hint.key());
+ }
+
+ @Test
+ void sameOperationAndLaneReturnsSameInstance() {
+ assertSame(
+ SizingHintTable.hintFor("registry.stable", true),
+ SizingHintTable.hintFor("registry.stable", true));
+ }
+
+ @Test
+ void entryAndChildLanesAreIndependent() {
+ SizingHint entry = SizingHintTable.hintFor("registry.twolane", true);
+ SizingHint child = SizingHintTable.hintFor("registry.twolane", false);
+ assertNotNull(entry);
+ assertNotNull(child);
+ assertNotSame(entry, child, "each lane holds its own hint for the same operation name");
+ assertSame(entry, SizingHintTable.hintFor("registry.twolane", true));
+ assertSame(child, SizingHintTable.hintFor("registry.twolane", false));
+ }
+
+ @Test
+ void beyondCardinalityBudgetSharesACappedOverflowHint() {
+ // A hint minted before the lane fills keeps resolving to itself afterwards.
+ SizingHint early = SizingHintTable.hintFor("registry.flood.early", false);
+ assertFalse(early.capped);
+
+ // Fill the child lane past its budget; a capped shared hint must appear.
+ SizingHint firstOverflow = null;
+ for (int i = 0; i <= SizingHintTable.CARDINALITY_LIMIT && firstOverflow == null; i++) {
+ SizingHint hint = SizingHintTable.hintFor("registry.flood." + i, false);
+ assertNotNull(hint);
+ if (hint.capped) {
+ firstOverflow = hint;
+ }
+ }
+ assertNotNull(firstOverflow, "lane collapses to a capped overflow hint once full");
+ assertEquals(SizingHintTable.OVERFLOW_SEED, firstOverflow.size);
+
+ // Every further over-budget name shares that same capped instance...
+ SizingHint another = SizingHintTable.hintFor("registry.flood.after", false);
+ assertTrue(another.capped);
+ assertSame(firstOverflow, another);
+ // ...existing hints still resolve (the cap blocks only creation)...
+ assertSame(early, SizingHintTable.hintFor("registry.flood.early", false));
+ // ...and the entry lane has its own budget.
+ assertFalse(SizingHintTable.hintFor("registry.flood.after", true).capped);
+
+ // A capped hint is never tuned: a heavy span sharing it does not grow it.
+ IdStore heavy = storeFor(another);
+ fill(heavy, SizingHintTable.OVERFLOW_SEED + 4);
+ heavy.recordSize();
+ assertEquals(SizingHintTable.OVERFLOW_SEED, another.size);
+ }
+
+ @Test
+ void sizingHintPresizesTheDenseStore() {
+ SizingHint hint = SizingHintTable.hintFor("registry.presize", true);
+ assertEquals(
+ hint.size, capacity(storeFor(hint)), "presized at the hint's size, before a write");
+
+ // Without a hint, a store starts on the shared empty arrays.
+ assertEquals(0, capacity(storeFor(null)));
+ assertEquals(0, capacity(IdStore.create(null)));
+ }
+
+ @Test
+ void sizingHintFeedsAndTunesTheDenseStore() {
+ SizingHint hint = SizingHintTable.hintFor("registry.tuning", true);
+ assertEquals(SizingHintTable.SEED_SIZE, hint.size);
+
+ int observed = SizingHintTable.SEED_SIZE + 3;
+ IdStore store = storeFor(hint);
+ fill(store, observed);
+ store.recordSize();
+ assertEquals(observed, hint.size, "hint self-tunes up to the observed high-water mark");
+
+ // The next store of the operation is presized to fit, so it never grows.
+ IdStore next = storeFor(hint);
+ assertEquals(observed, capacity(next));
+ fill(next, observed);
+ assertEquals(observed, capacity(next));
+
+ // A smaller later observation does not shrink the hint.
+ IdStore smaller = storeFor(hint);
+ fill(smaller, 1);
+ smaller.recordSize();
+ assertEquals(observed, hint.size, "recordSize never shrinks the hint");
+
+ // A store without a hint records nothing.
+ storeFor(null).recordSize();
+ }
+
+ @Test
+ void fullArraysDoubleUpToTheSerialLimit() {
+ // Presized small: 4 -> 8 -> 16
+ IdStore store = storeFor(new SizingHint("registry.growth"));
+ fill(store, 4);
+ assertEquals(4, capacity(store));
+ fill(store, 5);
+ assertEquals(8, capacity(store));
+ fill(store, 9);
+ assertEquals(16, capacity(store));
+
+ // Unhinted: first allocation at INIT_CAP, then doubling
+ IdStore unhinted = storeFor(null);
+ fill(unhinted, 1);
+ assertEquals(DenseIdStore.INIT_CAP, capacity(unhinted));
+ fill(unhinted, DenseIdStore.INIT_CAP + 1);
+ assertEquals(2 * DenseIdStore.INIT_CAP, capacity(unhinted));
+
+ // Growth never passes the serial limit, yet always makes room.
+ IdStore all = storeFor(new SizingHint("registry.growth.all"));
+ fill(all, KNOWN_IDS.size());
+ assertTrue(capacity(all) <= KnownTagCodec.serialLimit());
+ assertTrue(capacity(all) >= KNOWN_IDS.size());
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java
new file mode 100644
index 00000000000..c32823b740c
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java
@@ -0,0 +1,59 @@
+package datadog.trace.api;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.lang.reflect.Method;
+import org.junit.jupiter.api.Test;
+
+/**
+ * With the dense store off, {@link SizingHintTable} hands out no hints and never allocates its
+ * lanes, and the hash store ignores a hint. Runs in its own JVM so the flag is read as off.
+ */
+class SizingHintTableHashOnlyForkedTest {
+ static {
+ System.setProperty("dd.trace.experimental.dense.tags.enabled", "false");
+ }
+
+ @Test
+ void noHintWhenTheDenseStoreIsOff() {
+ assertFalse(IdStoreFactory.Default.DENSE);
+ assertNull(SizingHintTable.hintFor("registry.off", true));
+ assertNull(SizingHintTable.hintFor("registry.off", false));
+ assertNull(SizingHintTable.hintFor(null, true));
+ assertFalse(isLoaded("datadog.trace.api.SizingHintTable$Lanes"));
+ }
+
+ @Test
+ void theHashStoreIgnoresAHint() {
+ SizingHint hint = new SizingHint("registry.off.direct");
+ IdStore store = IdStore.create(null, hint);
+ store.set(KnownTags.COMPONENT_ID, "okhttp");
+ store.set(KnownTags.DD_BASE_SERVICE_ID, "svc");
+ store.recordSize();
+
+ assertSame(HashIdStore.class, store.getClass());
+ assertEquals(SizingHintTable.SEED_SIZE, hint.size, "recordSize is a no-op on the hash store");
+ assertFalse(isLoaded("datadog.trace.api.DenseIdStore"));
+ }
+
+ /** Whether {@code className} has been loaded by TagMap's class loader, without loading it. */
+ private static boolean isLoaded(String className) {
+ Method findLoadedClass;
+ try {
+ findLoadedClass = ClassLoader.class.getDeclaredMethod("findLoadedClass", String.class);
+ findLoadedClass.setAccessible(true);
+ } catch (RuntimeException | NoSuchMethodException e) {
+ assumeTrue(false, "cannot inspect loaded classes on this JVM: " + e);
+ return false;
+ }
+ try {
+ return findLoadedClass.invoke(TagMap.class.getClassLoader(), className) != null;
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError(e);
+ }
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java
index 97b5f64e415..5709d45e81b 100644
--- a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java
+++ b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java
@@ -162,6 +162,73 @@ void aClearSlotBitIsADefiniteMiss() {
assertEquals("/a", child.getValue(KnownTags.HTTP_ROUTE_ID));
}
+ /** A frozen-style source: a parentless dense store holding {@code ids}, each set to its name. */
+ private static DenseIdStore denseSource(long... ids) {
+ DenseIdStore source = new DenseIdStore(null);
+ for (long id : ids) {
+ source.set(id, KnownTagCodec.nameOf(id));
+ }
+ return source;
+ }
+
+ @Test
+ void seedAbsentCopiesEveryTagWhenNoneMayBePresent() {
+ DenseIdStore source = denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID);
+ DenseIdStore target = new DenseIdStore(null);
+ target.set(KnownTags.HTTP_ROUTE_ID, "/a");
+ assertEquals(
+ 0L, source.spanOccupancy & target.spanOccupancy, "test precondition: disjoint slots");
+
+ assertTrue(target.seedAbsent(source));
+ assertEquals(3, target.size());
+ assertEquals("component", target.getValue(KnownTags.COMPONENT_ID));
+ assertEquals("span.kind", target.getValue(KnownTags.SPAN_KIND_ID));
+ assertEquals("/a", target.getValue(KnownTags.HTTP_ROUTE_ID));
+ target.checkIntegrity();
+ }
+
+ @Test
+ void seedAbsentRefusesWhenAnyTagMayBePresent() {
+ DenseIdStore source = denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID);
+ DenseIdStore target = new DenseIdStore(null);
+ target.set(KnownTags.COMPONENT_ID, "explicit");
+
+ assertFalse(target.seedAbsent(source));
+ assertEquals(1, target.size(), "nothing copied");
+ assertEquals("explicit", target.getValue(KnownTags.COMPONENT_ID));
+ }
+
+ @Test
+ void seedAbsentRefusesASourceWithAParentOrTombstones() {
+ DenseIdStore parent = denseSource(KnownTags.COMPONENT_ID);
+ DenseIdStore child = new DenseIdStore(parent);
+ child.set(KnownTags.SPAN_KIND_ID, "server");
+ assertFalse(new DenseIdStore(null).seedAbsent(child), "read-through source");
+
+ DenseIdStore tombstoned = new DenseIdStore(parent);
+ tombstoned.remove(KnownTags.COMPONENT_ID);
+ assertFalse(new DenseIdStore(null).seedAbsent(tombstoned), "tombstoned source");
+ }
+
+ @Test
+ void seedAbsentGrowsTheTargetToFit() {
+ DenseIdStore source =
+ denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID, KnownTags.HTTP_ROUTE_ID);
+ DenseIdStore target = new DenseIdStore(null); // shared empty arrays: no room yet
+
+ assertTrue(target.seedAbsent(source));
+ assertEquals(3, target.size());
+ assertEquals("http.route", target.getValue(KnownTags.HTTP_ROUTE_ID));
+ target.checkIntegrity();
+ }
+
+ @Test
+ void theHashStoreNeverSeedsInBulk() {
+ HashIdStore target = new HashIdStore(null);
+ assertFalse(target.seedAbsent(denseSource(KnownTags.COMPONENT_ID)));
+ assertFalse(target.seedAbsent(new HashIdStore(null)));
+ }
+
@Test
void aTraceLevelTagDoesNotDefeatASpanLevelMissInTheSameSlot() {
// A local root span holds trace-level and span-level tags in one store, and the two levels are
diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json
index a6733fc287e..d11fceadb36 100644
--- a/metadata/supported-configurations.json
+++ b/metadata/supported-configurations.json
@@ -5856,7 +5856,7 @@
{
"version": "A",
"type": "boolean",
- "default": "false",
+ "default": "true",
"aliases": []
}
],