Skip to content

Introduce SpanPrototype: baked-once constant span-tag descriptor (phase 1b)#11894

Open
dougqh wants to merge 4 commits into
masterfrom
dougqh/span-prototype-api
Open

Introduce SpanPrototype: baked-once constant span-tag descriptor (phase 1b)#11894
dougqh wants to merge 4 commits into
masterfrom
dougqh/span-prototype-api

Conversation

@dougqh

@dougqh dougqh commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Introduces SpanPrototype — a baked-once, immutable descriptor of a span's constant identity + tags (builder API: extends_ / init*) — and the construction path that builds a span from one: buildSpan(SpanPrototype, operationName) / startSpan(SpanPrototype, operationName).

A decorator declares its constant span shape once (identity + span.kind, component, …); construction seeds those constants with a fast TagMap-to-TagMap copy that reuses the underlying TagMap.Entry objects, instead of BaseDecorator.afterStart stamping each tag per span.

The side effects afterStart performs alongside tag writes are preserved, not just the tags:

  • span.kind seeds through the TagInterceptor, so the cached span-kind ordinal (which drives isOutbound / isKind) is set — not merely the tag.
  • component via initComponentAndIntegration also records the integration name (setIntegrationName), which the IntegrationAdder post-processor serializes as _dd.integration. Plain initComponent stays tag-only.
  • Null / empty constants are dropped (no tag), matching AgentSpan.setTag.

This replaces the per-tag approach used today in BaseDecorator / TagProcessors, which requires working at the TagMap level rather than the span level and a cached TagMap.Entry field per tag.

Motivation

The constant tags a span carries are applied today via per-afterStart setTag calls. SpanPrototype sets up those constants once so a span can be constructed with them with fast, light copying.

Additional Notes

Part of the #11828 split. The decorator demo that bakes prototypes in real integrations is the stacked follow-up, #11895.

Commits:

  1. Drop null/empty constants; add TagMap.Entry.isEmptyValue
  2. Add the construction path (buildSpan / startSpan(SpanPrototype))
  3. Carry the integration name (component_dd.integration)
  • SpanPrototype.NONE is intentionally kept: the no-prototype sentinel (CoreTracer default field + != NONE guard), not dead code.

🤖 Generated with Claude Code

@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🎯 Code Coverage (details)
Patch Coverage: 53.75%
Overall Coverage: 57.26% (-0.12%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 9521907 | Docs | Datadog PR Page | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.92 s 13.98 s [-0.9%; +0.2%] (no difference)
startup:insecure-bank:tracing:Agent 12.88 s 12.97 s [-1.2%; -0.1%] (maybe better)
startup:petclinic:appsec:Agent 17.06 s 16.94 s [-0.3%; +1.7%] (no difference)
startup:petclinic:iast:Agent 16.98 s 17.06 s [-1.4%; +0.4%] (no difference)
startup:petclinic:profiling:Agent 16.62 s 16.76 s [-1.9%; +0.2%] (no difference)
startup:petclinic:sca:Agent 16.95 s 16.93 s [-0.8%; +1.1%] (no difference)
startup:petclinic:tracing:Agent 16.10 s 16.20 s [-1.6%; +0.5%] (no difference)

Commit: 95219075 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

@dougqh

dougqh commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Design note: SpanPrototype × TagInterceptor (resolve before baking is wired into construction, #11834)

Several tags a prototype naturally bakes as constants are exactly the keys TagInterceptor.intercept() special-cases (dd-trace-core/.../taginterceptor/TagInterceptor.java switch): span.type, span.kind, http.method, http.url, component, plus resource.name / service / peer.service. For these, setTag() today is not a plain map write — it has side effects: span.type populates the DDSpan.type field, URL-as-resource-name, service/peer.service extraction, normalization, etc.

So there's a fork once prototypes write baked tags directly to storage:

  • Bypass intercept() (direct write) → the side effects don't fire — e.g. a baked span.type tag wouldn't populate DDSpan.type, URL-as-resource-name wouldn't run.
  • Route baked tags through intercept() per span → correctness preserved, but we lose the baked-once benefit (re-running the interceptor per span is the cost we're trying to remove).

Proposed resolution (fits "baked once"): the interceptor's inputs are constant for a given prototype, so its outputs are too — bake the post-interception result at prototype-construction time (resolve the effect once: set the type field, store normalized values) rather than the raw tag. That means prototype baking partitions its tags into interceptor-relevant (bake the resolved effect) vs inert (direct write), and we want an explicit list of which constant tags carry interceptor semantics.

Ties into the TagInterceptor-retirement direction (absorb interceptor logic into the map as inlinable data) — baking the resolved effect is a step in that same direction.

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 <noreply@anthropic.com>
@dougqh
dougqh force-pushed the dougqh/span-prototype-api branch from 3af8e13 to 41666f5 Compare July 20, 2026 20:04
@dougqh dougqh changed the title Introduce SpanPrototype: baked-once constant span-tag descriptor Introduce SpanPrototype: baked-once constant span-tag descriptor (phase 1b) Jul 20, 2026
@dougqh
dougqh marked this pull request as ready for review July 21, 2026 16:48
@dougqh
dougqh requested review from a team as code owners July 21, 2026 16:48
@dougqh
dougqh requested review from mcculls and removed request for a team July 21, 2026 16:48

@datadog-datadog-prod-us1-2 datadog-datadog-prod-us1-2 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Datadog Autotest: PASS

More details

SpanPrototype is a well-designed immutable descriptor for constant span tags. The builder correctly handles null values, supports inheritance via extends_(), and freezes tags at construction. No behavioral regressions detected from synthetic scenarios covering identity fields, tag accumulation, inheritance chains, and edge cases.

Was this helpful? React 👍 or 👎

📊 Validated against 8 scenarios · Open Bits AI session

🤖 Datadog Autotest · Commit 41666f5 · What is Autotest? · Any feedback? Reach out in #autotest

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41666f5648

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +117 to +118
if (value != null) {
this.tags.set(key, value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve empty-value tag filtering in prototypes

When a decorator constant is an empty String/CharSequence, this path stores an actual empty tag in the prototype. The existing per-span stamping paths either go through AgentSpan.setTag(String|CharSequence), which removes empty values, or cached TagMap.Entry.create(...), whose null-tolerance contract treats empty values as "no tag"; TagMap.set(key, value) does not apply that filtering. A prototype built with something like initComponent("") or initKind("") would therefore emit a tag that is absent today, so the builder should use the null-tolerant Entry.create/set(EntryReader) path for these constants.

Useful? React with 👍 / 👎.

Comment on lines +107 to +108
public Builder initKind(final CharSequence kind) {
return initTag(Tags.SPAN_KIND, kind);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve span.kind side effects when seeding tags

When construction uses the advertised fast prototype.tags() copy, baking span.kind as just another raw tag misses the side effect that existing AgentSpan.setTag(TagMap.EntryReader) performs through TagInterceptor: it updates DDSpanContext's cached span-kind ordinal. DDSpan.isOutbound() and isKind() read that ordinal rather than falling back to the tag map, so client/producer prototypes would serialize span.kind but still behave as if the kind is unset unless the prototype carries this side effect separately or requires intercepted application for this tag.

Useful? React with 👍 / 👎.

Comment on lines +112 to +113
public Builder initComponent(final CharSequence component) {
return initTag(Tags.COMPONENT, component);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve integration-name side effect for components

When a prototype is used to replace BaseDecorator.afterStart, recording component only as a tag loses the existing span.spanContext().setIntegrationName(component) side effect. That context field is what IntegrationAdder later serializes as _dd.integration; copying prototype.tags() would still include component but would omit _dd.integration unless the prototype stores the integration name separately or construction explicitly derives and sets it from this component value.

Useful? React with 👍 / 👎.

@dougqh

dougqh commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e1b32a9dd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +373 to +374
default AgentSpan startSpan(SpanPrototype prototype, CharSequence operationName) {
return buildSpan(prototype, operationName).start();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return a no-op span for prototype starts

When no real tracer has registered (or tests force NOOP_TRACER), this default method is inherited by NoopTracerAPI; its buildSpan(String, ...) implementation returns null, so AgentTracer.get().startSpan(prototype, ...) throws a NullPointerException instead of returning NoopSpan like the existing startSpan overloads. Any instrumentation adopting this entry point can crash before/without tracer registration; please override this in NoopTracerAPI or make the default tolerate a null/no-op builder.

Useful? React with 👍 / 👎.

// 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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply prototype constants after inherited/root tags

When local-root, contextual, or extracted tags contain a key baked into the prototype (for example span.kind or component), this applies the prototype before those later construction tags are applied, so those tags can overwrite the decorator constants and the cached span-kind ordinal. BaseDecorator.afterStart currently stamps these constants after span construction, so moving decorators to prototypes changes spans under those tag configurations; seed the prototype after the inherited/root/contextual tags while still letting explicit builder tags win.

Useful? React with 👍 / 👎.

Comment on lines +100 to +103
public Builder instrumentationName(final String[] instrumentationNames) {
return (instrumentationNames == null || instrumentationNames.length == 0)
? this
: instrumentationName(instrumentationNames[0]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty instrumentation-name arrays

When this helper is used with an existing decorator whose instrumentationNames() returns an empty array, it silently leaves instrumentationName unset. Starting a span from that prototype then passes null into DDSpan/SpanMetricRegistry.computeIfAbsent, which throws at runtime; please fail during prototype construction or require an explicit fallback name instead of building a prototype that cannot start spans.

Useful? React with 👍 / 👎.

Comment on lines +2254 to +2256
final CharSequence integrationName = spanPrototype.integrationName();
if (integrationName != null) {
context.setIntegrationName(integrationName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep integration name aligned with component overrides

When a prototype uses initComponentAndIntegration(...) and the caller explicitly overrides the component tag with builder tags, this stores the prototype integration name before the later tagLedger overwrite runs, so serialization can emit component=override but _dd.integration=<prototype component>. Since the new construction contract says explicit builder tags override prototype constants, the integration-name side effect should be updated after those overrides or otherwise kept in sync with the final component value.

Useful? React with 👍 / 👎.

@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Bits has a CI fix ready

🟢 Investigated · 🟢 Fix prepared · ⚪ Validation skipped · 🟠 Ready

SpanPrototype.Builder lacked coverage for identity setup, optional inputs, inheritance, cached tag entries, and frozen tag snapshots. Added focused tests in internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java to cover those paths.

Commit fix to this PR


View in Datadog | Reviewed commit 9521907 · Any feedback? Reach out in #deveng-pr-agent

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 <noreply@anthropic.com>
@dougqh
dougqh force-pushed the dougqh/span-prototype-api branch from 8e1b32a to 45f121f Compare July 21, 2026 18:56
@dougqh

dougqh commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 45f121f777

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

* {@link #buildSpan(SpanPrototype, CharSequence)}.
*/
default AgentSpan startSpan(@Nonnull SpanPrototype prototype, CharSequence operationName) {
return buildSpan(prototype, operationName).start();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return a no-op span when starting from a prototype

When the active provider is NOOP_TRACER (for example before a real tracer is installed or after it has been reset), this default method dispatches to NoopTracerAPI.buildSpan(...), which currently returns null, and then immediately calls .start(). That makes the new prototype start path throw an NPE instead of matching the existing startSpan(String, ...) overloads' no-op behavior; please override this in the no-op tracer or make the default tolerate a null builder.

Useful? React with 👍 / 👎.

@Benchmark
public TagMap newBulkApply() {
TagMap tags = TagMap.create();
tags.putAll(prototype.tags());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Benchmark the intercepted prototype path

This benchmark copies prototype tags with a raw TagMap.putAll, but the committed construction path in CoreTracer applies context.setAllTags(spanPrototype.tags()), which runs every entry through TagInterceptor so span.kind, analytics sample rate, and other intercepted constants get their side effects. As written, newBulkApply can report a much cheaper path than spans actually pay when using buildSpan(prototype, ...); please add a benchmark for the intercepted DDSpanContext.setAllTags path or make this explicitly future-only so the results are not used as evidence for the current implementation.

Useful? React with 👍 / 👎.

// 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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve decorator constants after context tags

When a prototype replaces BaseDecorator.afterStart, applying its tags here changes precedence because coreTags, rootSpanTags, and contextual tags are still applied afterward. In configurations where an extracted/header/root tag uses a reserved key like span.kind or component, that later tag can overwrite the decorator constant and even update the cached span-kind ordinal after the prototype set it; the current afterStart flow writes those constants after span construction, so they win. Please seed prototype constants after the construction-time tag maps, while preserving the intended explicit builder override behavior.

Useful? React with 👍 / 👎.

Comment on lines +1058 to +1059
CoreSpanBuilder builder =
createMultiSpanBuilder(prototype.instrumentationName(), operationName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid allocating a builder in prototype startSpan

perf: The inherited startSpan(SpanPrototype, ...) routes through this buildSpan method, so each prototype-based auto-instrumented span allocates a MultiSpanBuilder before starting. Existing CoreTracer.startSpan(String, ...) avoids that by calling CoreSpanBuilder.startSpan directly; if follow-up integrations use the new prototype start path, this adds one allocation per span. Please override startSpan(SpanPrototype, ...) in CoreTracer to use the direct construction path.

AGENTS.md reference: AGENTS.md:L79-L81

Useful? React with 👍 / 👎.

@dougqh
dougqh force-pushed the dougqh/span-prototype-api branch from 45f121f to 2143299 Compare July 21, 2026 19:23
@dougqh

dougqh commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

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 <noreply@anthropic.com>
@dougqh

dougqh commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@dougqh
dougqh force-pushed the dougqh/span-prototype-api branch from 2143299 to 4276da0 Compare July 21, 2026 19:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4276da0399

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +362 to +365
default SpanBuilder buildSpan(@Nonnull SpanPrototype prototype, CharSequence operationName) {
return buildSpan(
prototype.instrumentationName(),
operationName != null ? operationName : prototype.operationName());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return a no-op builder for noop prototype builds

When the registered tracer is still NOOP_TRACER (for example during early instrumentation or tests before tracer registration), this new default is inherited by NoopTracerAPI and delegates to buildSpan(String, ...), which returns null. The new startSpan(prototype, ...) path was explicitly overridden to avoid this NPE, but callers using the advertised builder path (AgentTracer.get().buildSpan(prototype, op).withTag(...).start()) still fail immediately instead of behaving as a no-op; add a noop SpanBuilder/override for this overload as well.

Useful? React with 👍 / 👎.

…formly 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 <noreply@anthropic.com>
@dougqh
dougqh force-pushed the dougqh/span-prototype-api branch from 4276da0 to 9521907 Compare July 21, 2026 20:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM tag: no release notes Changes to exclude from release notes type: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant