Skip to content

Add TagContributor / TagExtractor span-tag APIs (otlp - phase 4 & span api) - #11817

Open
dougqh wants to merge 11 commits into
masterfrom
dougqh/tag-extractor-api
Open

dougqh wants to merge 11 commits into
masterfrom
dougqh/tag-extractor-api

Conversation

@dougqh

@dougqh dougqh commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

What Does This Do

Adds two span-tag APIs and a benchmark that justifies them. This is the API contract only (draft).

The APIs:

  • TagContributor (intrinsic): a typed POJO we own projects its own state onto a span via addTo(AgentSpan). DBInfo, git metadata and ExtractedContext are the precedents.
  • TagExtractor<T> (extrinsic, @FunctionalInterface): extracts tags from a foreign object we don't own (InetSocketAddress, Request) onto a span. It's a @Strategy preferably an inline non-capturing or a single instance held in a static final field.
  • AgentSpan.setTagsFrom(...): the span-first, product-facing forms, span.setTagsFrom(connection, EXTRACTOR) for an extractor and span.setTagsFrom(dbInfo) for a contributor. Named setTagsFrom rather than setTags because the source isn't itself a tag value: setTag(name, value) stores one value, while these derive any number of tags from the source. The extra indirection inlines away at a monomorphic site; it's also the seam an implementer can override to coarse-lock the whole extraction.
  • TagProjectionBenchmark: compares decorator, extractor and contributor dispatch.

Motivation

This is the foundation for incrementally dissolving the span decorators, a deep virtual hierarchy (BaseDecorator → {Client,Server}Decorator → {Http,Database,…}* → ~149 concrete), into composable, JIT-friendly tag projection.

It establishes a style we know compiles well (call-time binding, one dispatch, a small consumer) ahead of setTag itself inlining well. Today setTag goes through DDSpan, DDSpanContext, TagInterceptor's name switch and TagMap's lookup, which limits the end-to-end gain. That's why the benchmark uses a lightweight sink: it isolates dispatch rather than letting setTag's cost swamp it. The payoff compounds as the write path gets cheaper: id-keyed setters, id-keyed interception, the dense store.

The template-method decorator makes one dynamic dispatch per hook; an extractor or contributor makes one in total. Benchmark (@Threads(8) @Fork(3), throughput, -prof gc):

arm mono mega
decorator 1525M 426M (−72%)
extractor 1256M 914M (−27%)
contributor 1655M 1026M (−38%)
  • mono (one type): everything inlines, so it's roughly a tie. The abstraction is free at the decorator's best case.
  • mega (8 equivalent loaded types, megamorphic): the decorator collapses; the extractor and contributor degrade far less. Zero allocation in all arms (pure dispatch).
  • -XX:+PrintInlining confirms the mechanism: decorator-mega leaves all 4 template calls as virtual call (megamorphic, not inlined); extractor-mega is one virtual call plus an inlined body.
  • These numbers were taken with @Threads(8), which adds noise to a benchmark that allocates nothing: the three mono arms should nearly tie but spread about 30%. So the extractor and contributor aren't ranked against each other here; only the decorator's mega drop clearly stands out.

What the benchmark supports is a robust floor: fewer dynamic dispatches (N→1), which holds on the interpreter, C1 and the cold path with no JIT needed. That's what the mega arm shows, and it models the failure mode where the binding isn't visible to the JIT. When it is visible, both shapes can compile to straight-line code: an extractor bound as a static final through the tiny setTagsFrom devirtualizes on its exact type once setTagsFrom inlines, and a decorator's template calls devirtualize the same way if its larger template method inlines (see Strategy). The extractor's practical edge there is that its consumer is small enough to inline reliably.

The arms read receivers from arrays at a private call site, so they measure profile-driven dispatch rather than the shape that ships (a static final constant through a shared, inlined consumer). The mono-vs-mega ratios are illustrative, not a prediction of production gains.

Additional Notes

  • Both interfaces target AgentSpan for now, so they can also drive span-level state (resource name, error, status) during the transition. The sink narrows toward TagMap as those fields migrate into the tag model.
  • Convention for extractors: extractors are promoted to named singleton classes (XExtractor implements TagExtractor<T> with a private-constructor INSTANCE) rather than inline lambdas. That gives a name at the call site, a home for pure statics and caches (class statics, not lambda captures, so it stays non-capturing), and the composability the inheritance chain lacked. The one rubric line: no capturing lambdas (they allocate per call and break memoization in any lazy variant).
  • /techdebt is deferred until the PR is ready for review.

Contributor Checklist

Jira ticket: N/A

🤖 Generated with Claude Code

dougqh and others added 2 commits June 30, 2026 17:03
Foundational contract for dissolving Decorators (see decorator-dissolution.md).
API types only -- no wiring or consumers yet.

- TagContributor (intrinsic): a typed POJO we own projects its own state onto
  a span via addTo(AgentSpan). DbInfo / git-meta / ExtractedContext are the
  precedents; addTo is a flat setTag sweep that becomes setTag(KnownTagIds.X,
  field) once the id-arm lands.
- TagExtractor<T> (extrinsic, @FunctionalInterface): extracts tags from a
  foreign object we don't own (Connection/Request) onto a span. Intended as a
  static-final non-capturing lambda invoked from the integration's own advice
  (a monomorphic site the JIT devirtualizes + inlines) -- the opposite of the
  decorator's shared megamorphic dispatch. Two modes: place tags directly, or
  build a memoized POJO that is itself a TagContributor.

Both target AgentSpan for now so they can drive span-level state (resource
name, error, status) during the transition; the sink narrows toward TagMap as
those fields migrate into the tag model. Javadoc bakes in the compile-to-~zero
litmus + the monomorphic-site / no-shared-sink discipline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Representative benchmark for the decorator-dissolution thesis: project a
typed source's fields onto a span three ways (decorator template-method
pattern / TagExtractor-shaped lambda / TagContributor-shaped), to show
what the JIT compiles away and what survives. Synthetic structural
equivalents + a light DCE-safe sink isolate *dispatch* cost.

@PARAM mode {mono, mega}: mono = one type (charitable to the decorator);
mega = 8 equivalent loaded types exercised so the call site / shared-base
template calls go megamorphic. Methodology: @threads(8), @fork(3),
Throughput + -prof gc (dougqh's standard — multi-thread reveals alloc
pressure, 3+ forks expose bimodal JIT/devirt).

Results (ops/s, 3f x 8t): mono ~ties (1.26-1.66B, abstraction free);
mega decorator 426M (-72%) vs extractor 914M (-27%) / contributor 1026M
(-38%). Zero alloc in all arms (pure dispatch). PrintInlining confirms
the mechanism: decorator-mono inlines all 4 template calls; decorator-
mega leaves all 4 as `virtual call` (megamorphic); extractor-mega has ONE
virtual call + inlined body. The template-method pattern pays one
megamorphic dispatch PER template method; extractor/contributor pay one
total -> the decorator's cost scales with field count (worse for the 7+
HttpServer hooks), while extractor/contributor can stay monomorphic
per-site (~3-4x faster in reality).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh dougqh added type: feature Enhancements and improvements comp: core Tracer core tag: no release notes Changes to exclude from release notes tag: ai generated Largely based on code generated by an AI or LLM labels Jun 30, 2026
@datadog-datadog-prod-us1

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Jun 30, 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 14.89 s 14.72 s [+0.2%; +2.2%] (maybe worse)
startup:insecure-bank:tracing:Agent 13.75 s 13.78 s [-1.1%; +0.7%] (no difference)
startup:petclinic:appsec:Agent 17.16 s 17.04 s [-0.1%; +1.5%] (no difference)
startup:petclinic:iast:Agent 16.45 s 17.00 s [-7.3%; +0.8%] (no difference)
startup:petclinic:profiling:Agent 16.57 s 16.87 s [-3.0%; -0.4%] (maybe better)
startup:petclinic:sca:Agent 16.63 s 17.00 s [-6.2%; +2.0%] (no difference)
startup:petclinic:tracing:Agent 16.16 s 16.21 s [-1.2%; +0.6%] (no difference)

Commit: 6956c7dd · 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.

The span-first, product-dev-facing form of extractor.extract(source, this)
(matches setTag / setAllTags). The indirection inlines away at a monomorphic
call site, and it's the seam where an implementer can coarse-lock the whole
extraction. Part of the TagExtractor API; consumed by the JDBC canary and
subsequent extractor migrations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A null source is now a no-op, so a TagExtractor can assume a non-null
source and integrations don't each repeat the null-check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh dougqh changed the title Add TagContributor / TagExtractor span-tag APIs Add TagContributor / TagExtractor span-tag APIs (phase 3) Jul 20, 2026
@dougqh dougqh changed the title Add TagContributor / TagExtractor span-tag APIs (phase 3) Add TagContributor / TagExtractor span-tag APIs (phase 3 - span api) Sep 15, 2026
@dougqh dougqh changed the title Add TagContributor / TagExtractor span-tag APIs (phase 3 - span api) Add TagContributor / TagExtractor span-tag APIs (oltp - phase 4 & span api) Oct 5, 2026
dougqh and others added 2 commits October 5, 2026 14:34
TagExtractor is a stateless policy meant to be a static final,
non-capturing extractor bound at the call site; setTags is the small
consumer that must inline for each call site to see the exact extractor
type. The markers record that contract for review and future checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dougqh dougqh changed the title Add TagContributor / TagExtractor span-tag APIs (oltp - phase 4 & span api) Add TagContributor / TagExtractor span-tag APIs (otlp - phase 4 & span api) Oct 5, 2026
dougqh and others added 3 commits October 5, 2026 15:42
…load

setTags(source, extractor) sat one letter from setTag(name, value),
though the source is not a tag value: setTag stores one value, while
this derives any number of tags from the source. It also collided in
spirit with LLMObsSpan.setTags(Map). setTagsFrom reads as "set tags from
this source" and now covers both projections: setTagsFrom(source,
extractor) for foreign objects and setTagsFrom(contributor) for typed
objects we own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers both overloads: the extractor gets the source and this span, the
contributor gets this span, a null source or contributor is skipped,
and each returns the span. TagContributor's Javadoc named a
KnownTagIds class that doesn't exist; the generated class is KnownTags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…odel

TagExtractor now points to @strategy: prefer a named class held in a
static final of its concrete type, with a non-capturing lambda accepted
as the weaker shape, and setTagsFrom as the small consumer that must
inline. The benchmark Javadoc drops the "decorator is structurally
megamorphic" premise: 'mega' models the failure mode where the binding
isn't visible to the JIT, and the arms measure profile-driven dispatch,
not the static-final-through-an-inlined-consumer shape that ships.
Also restores TagContributor's future tense for id-keyed setters, warns
against deduplicating the benchmark's deliberately repeated bodies,
drops a conversational note, and reads the mode once in setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dougqh
dougqh marked this pull request as ready for review October 6, 2026 13:53
@dougqh
dougqh requested review from a team as code owners October 6, 2026 13:53
@dougqh
dougqh removed the request for review from a team October 6, 2026 13:53
@dougqh
dougqh requested a review from mcculls October 6, 2026 13:53
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T13:58:53.994355Z 8af312e Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 8af312ebf1

ℹ️ 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".

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 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.

Bits Code Review: PASS

More details

Immutable spans still discard projected tag writes. Callback execution matches the new methods’ documented delegation behavior, so the reviewer’s no-op callback concern does not establish a correctness regression.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Bits Code Review · Commit 8af312e · @DataDog review to ask questions

@mcculls mcculls 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.

LGTM

NoopSpan (via ImmutableSpan) discards every tag, but inherited the
setTagsFrom defaults, so an extractor or contributor still ran its
metadata access and side effects with tracing disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants