Skip to content

Store known span tags densely, behind an experimental flag (phase 3b - dense tag storage) - #12856

Closed
dougqh wants to merge 32 commits into
dougqh/tag-registry-direction-resolutionfrom
dougqh/dense-store-v3
Closed

dougqh wants to merge 32 commits into
dougqh/tag-registry-direction-resolutionfrom
dougqh/dense-store-v3

Conversation

@dougqh

@dougqh dougqh commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What Does This Do

Adds an experimental dense store for known span tags, behind trace.experimental.dense.tags.enabled (off by default). With the flag on, known tags are no longer bucket Entry objects. Their values sit in insertion-ordered parallel arrays (knownIds / knownValues) with no per-tag Entry. Custom tags stay in the hash buckets.

Stacked on #12731 (direction resolution) and #12882 (slot coloring), and merges both. Retarget to master once they merge; until then their commits also show in this diff. This PR replaces the old dense-store drafts, which were built before the build-logic tag registry: #12045, #12046, #12047, #12056 and #11901.

Reading guide

  1. The split is mechanical. TagMap becomes abstract, with two final implementations:

    • HashTagMap (flag off) is today's map.
    • DenseTagMap (flag on) is the hybrid.

    TagMapFactory picks one from the flag. This is rollout scaffolding, as LegacyTagMap was: the hash-only map stays one flag away until the dense store is proven, and is folded away afterwards.

    • One implementation per JVM: EMPTY and every factory method go through the factory, so a JVM loads only one implementation and class hierarchy analysis devirtualizes every TagMap call. A forked test per mode checks that the other implementation never loads.
  2. DenseTagMap can be read on its own, as one unit of about 600 lines. The base class reaches dense state only through a small set of hooks: knownCount, countDenseVisibleIn, forEachDenseVisibleIn, readDenseIfVisible, and the routing overrides.

  3. Slots come from the registry (Color known tags into co-occurrence slots in their ids (phase 3a - groundwork - dense tag storage) #12882). The tag-registry generator colors tags that occur together on a span type into distinct slots; that's reviewed in Color known tags into co-occurrence slots in their ids (phase 3a - groundwork - dense tag storage) #12882, not here. Every known tag is dense. Tags on no modeled span type share occupancy bit 63. Presence is one long; the scan is authoritative, so tags that share a slot can coexist.

  4. Per-operation sizing. SizingHintTable (on FlatHashtable.D1) keeps a hint per operation, for entry spans and child spans separately, each lane capped at 512 operations. Each hint is a racy best-effort maximum: it presizes a span's dense arrays and tunes up as spans finish.

  5. Direction needs nothing here. peer.port is one tag with one id (Resolve direction-scoped OpenTelemetry names at the OpenTelemetry boundary (otlp - tag registry - phase 3) #12731), so it takes one slot. Only its OpenTelemetry name depends on the span's direction, and that is resolved when the span is read or serialized, not in the store.

Changes on the default path (flag off)

Motivation

Known tags are most of a span's tags, and each one costs an Entry. Storing them densely by id removes that allocation and makes setting a constant id cheap. This is part of the tag-id work, whose goal is that instrumentation sets tags by KnownTags.X_ID.

Measurements

Zulu 17, Apple M1 Max, JMH -prof gc, 3–4 forks, logging off. The details are in the commit messages.

flag off flag on
SetTagBenchmark known tag by id 13.8 ns, 48 B 9.6 ns, 0 B
SpanCreationBenchmark.webServerSpan 1856 B 1648 B (−12% vs base 1883–1909)
SpanCreationBenchmark.jdbcClientSpan 1464 B 1184 B (−20% vs base 1464–1501)
bareStartSpan (4 known tags) 992 B 1088–1096 B (+~110 B: small-span overhead, follow-up)
  • Span throughput is within noise in every configuration.
  • With the flag off, allocation and timing match the base.

Additional Notes

🤖 Generated with Claude Code

dougqh and others added 20 commits October 10, 2026 15:41
The generator now assigns each required/conditional/recommended tag a slot
in id bits [47-32] by graph coloring: tags resolved onto one concrete span
type, or both trace-level, get distinct slots; tags that never co-occur --
such as a tag declared per direction -- may share one. The rest get NO_SLOT
and stay hash-stored. The current conventions need 18 slots, within the one
occupancy long the dense store will use. KnownTagCodec gains slotOf,
hasSlot, slotCount, and the slot layout constants.

Nothing reads the slot yet; the dense store does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Selects TagMap's dense store for slotted known tags. Read once into a static
final so the JIT folds the dense branches away when it is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A known tag with a dense-store slot is held in insertion-ordered parallel
arrays (knownIds/knownValues) with no per-tag Entry; custom tags and known
tags without a slot stay in the hash buckets. One occupancy long (1L << slot)
lets a definitely-absent tag skip the scan; the scan stays authoritative, so
two tags sharing a slot in one map still work. Whether a tag routes dense
depends only on its id and the flag, so the two regions are disjoint and
read-through shadows within a region (parentDenseVisible mirrors
parentEntryVisible). Iteration and forEach emit dense values through a reused
flyweight; point reads materialize an Entry. Buckets start on a shared empty
array and are copied on the first bucket write.

Ported from #12045 and #12046 onto today's TagMap: name canonicalization,
id-keyed setters, and per-direction shared-name lookups all route through the
same chokepoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TagMapDenseForkedTest covers dense routing against the generated registry:
slotted vs unslotted vs custom tags, id-keyed and OpenTelemetry-name writes,
two tags sharing a slot in one map, iteration/forEach/entrySet (retain-safe
entries), the shared empty buckets, copy/clear/putAll/ledger/freeze, and
read-through (inheritance, shadowing, tombstones, multi-level chains).
TagMapDenseFuzzForkedTest replays random actions against a HashMap oracle
over slotted, unslotted, and custom keys. Both fork, since the flag is read
once per JVM.

DenseStoreAllocBenchmark: with the store on, a 12-tag map of slotted tags
allocates 232 B/op instead of 856; custom tags are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
putKnownValue built an Entry for the value it replaced, which set(long, ...)
then discarded; the 0 B/op on overwrite held only because C2's escape
analysis removed it. Split the store: storeKnownValue/storeDenseLocal return
the raw prior value (dense values are never null) and build nothing, and only
putDenseLocal -- the getAndSet path, which must return the prior entry --
materializes one. With -XX:-DoEscapeAnalysis, SetTagBenchmark's dense
overwrite drops from 48 to 0 B/op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Internals.setKnown built an Entry and sent it through putEntry, whose dense
routing then built another for the replaced value: two throwaway allocations
on every span id write once the span moved to internals(). Check for a dense
id first and store straight into the dense arrays, as set(long, ...) does.
SetTagBenchmark with the store on: 0 B/op, and 16 B/op (just the Internals
view) with -XX:-DoEscapeAnalysis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the dense store on, every known tag now lives in the dense arrays --
denseIdOf means 'is a known id' -- so the id-keyed setters (set(long, ...)
and Internals.setKnown) never reach the hash path: with the flag a static
final, putEntry isn't compiled into them at all. The public set(long, ...)
checks isKnown itself, since the dense path skips requireKnownName, and the
dense write now applies the shared-name rule (an id write supersedes a value
held under the shared name).

The generator colors every tag resolved onto a modeled span type, whatever
its requirement level (24 slots today). Only tags on no span type -- the
overlay's routing-only keys and the CI tags of the unmodeled test type --
keep NO_SLOT; a long shift maps NO_SLOT to bit 63, which slots now stay below,
so slotBit needs no branch and those tags share that presence bit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
peer.port set by name before a span has a direction used to be stored as a
custom tag, so every id write of a direction's tag had to remove it from
the hash buckets. That pulled removeLocal into DDSpanContext.setTag(long, int)
and pushed it past C2's InlineSmallCode limit.

The registry now adds an undirected tag per shared Datadog name
(PEER_PORT_UNDIRECTED_ID). It is serialed after every declared tag and colored
into the same slot as its directional tags. Each directional id carries the
undirected serial at bits [30-16], so undirectedIdOf folds for a constant id.
In the dense store, a direction's write supersedes the undirected tag by
relabeling it in place during the scan it already does. The hash path removes
it by id. The span-level supersede calls are gone.

keyOf(String) still resolves a shared name to 0. The span's by-name setters
rely on that to take the directional path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A map holds each known tag at most once, so a full dense store grows
straight to KnownTagCodec.serialLimit() entries instead of doubling. Maps
start on shared empty arrays, so one full-capacity test covers a map's
first dense tag too. The first allocation, growth, tombstone clearing and
the frozen-map exception are now separate methods.

This change did not measurably shrink the compiled DDSpanContext.setTag in
SetTagMixBenchmark. In both the reused-span and fresh-span profiles, the
int overload's growth branch is already an uncommon trap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restore the factory design from before #11678 and #11967. TagMap becomes an
abstract class with two final subclasses: HashTagMap (dense store off) and
DenseTagMap (dense store on). TagMapFactory chooses one from the flag.
EMPTY and every factory method go through the factory, so a JVM loads only
one implementation and class hierarchy analysis devirtualizes TagMap calls.
A forked test per mode checks that the other implementation never loads.

This is rollout scaffolding, as LegacyTagMap was: the hash-only map stays
one flag away until the dense store is proven, and is then folded away.
The commit is structural only. The logic stays in TagMap; the next commit
moves the dense state into DenseTagMap.

The public no-arg constructor is removed; TagMap.create() is the intended
way to make a map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TagMap now holds only the hash-bucket store, read-through and views.
DenseTagMap holds the dense fields and helpers. It overrides the routing
methods (putEntry, getLocalEntry, removeLocal, the set(long, ...) overloads,
forEach and the bulk copies) to handle known ids and calls super for custom
tags. Shared walks over the parent chain reach dense state through
per-level hooks (knownCount, countDenseVisibleIn, forEachDenseVisibleIn,
readDenseIfVisible); in HashTagMap those return zero or do nothing.
DenseStore.ENABLED is now read only by TagMapFactory.

Internals.setKnown goes through setKnownTrusted, which skips the frozen check
because the core's span maps are never frozen. Every other rule
(tombstones, the shared-name supersede, routing) is unchanged. A new
Internals.getKnownValue reads a known tag's value with the same semantics
as getEntry(id).objectValue(), but builds no Entry for a local dense hit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DDSpanContext's primitive setTag(long, ...) overloads boxed the value twice
in their compiled code: once to hand it to the interceptor, through setBox,
and again inside Internals.setKnown when storing it. They now box once, and
pass the same box to the interceptor and to new boxed Internals.setKnown
overloads.

HashTagMap builds the tag's primitive-typed Entry around that box, so an
intercepted tag that is stored keeps its primitive type rather than
becoming ANY. DenseTagMap stores the box as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entry's full constructor and the Internals and Ledger constructors were
private, so javac (targeting Java 8, without nestmates) gave each one a
synthetic access constructor taking the never-loaded class TagMap$1. C2
refuses to inline a call whose signature names an unloaded class
("unloaded signature classes"). So the Internals view the core creates
for each id write could not be scalar-replaced, and every Entry built
through that constructor went through a real call. Making the
constructors package-private removes the accessors and TagMap$1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entry's *2Prim helpers and requireKnownName, and TagMap.parentEntryVisible,
were private but used across nested classes, so javac generated access$
methods for them. Each one adds a level of inlining depth, and the *2Prim
helpers are on HashTagMap's typed id-write path. They are now
package-private. TagMapSyntheticAccessorTest fails if any class in the
TagMap family gains a synthetic accessor again. It is the prototype for
the @NoSyntheticAccess contract (APMLP-1976).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
putAllIntoEmptyMap copied the source's dense arrays at their full capacity.
A source that had grown to the bound would then hand every copy, including
each span's starting tags, a full-bound array. The copy now allocates the
source's count, with the initial capacity as a floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port the per-operation sizing hint from #12056 onto the split TagMap and
the current FlatHashtable.D1 API. SizingHintTable keeps two fixed
512-operation lanes, one for entry spans and one for child spans, each with
a capped overflow hint for once it is full. CoreTracer resolves the hint
when it builds a span context. DenseTagMap presizes its arrays from it, and
DDSpanContext records the span's known-tag count when the span is
serialized. Each hint is a racy best-effort max: a stale or lost update
only mis-sizes an array.

This changes two things from #12056. A fresh hint is seeded at 4, not 1:
the hint only grows, so a large seed would lock small spans into oversized
arrays. And dense growth now doubles, bounded by serialLimit(), instead of
jumping straight to the bound, so outgrowing a small seed costs one
doubling.

With the dense store off, hintFor folds to null, its tables are never
loaded, and span creation does no lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spans that are never serialized, such as those dropped by sampling, never
fed their known-tag count back. Their operation's hint stayed at the seed,
and each such span grew its dense arrays from 4 slots upward. The span now
records its size at finish too. Recording at serialization still captures
the tags added then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
putAllIntoEmptyMap always allocated new dense arrays. A span map presized
from its SizingHint therefore threw its hinted arrays away when its starting
tags were copied in (CoreTracer -> setAllTags -> putAll), and allocated
12-slot arrays instead: two allocations, the wrong size. The copy now goes
into the map's own arrays when they fit, and allocates only when they don't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SpanCreationByIdBenchmark repeats SpanCreationBenchmark's tagged span
shapes (web server, JDBC client, and their SpanPrototype variants) but sets
each tag by its KnownTags id instead of its name. Same values, threads and
forks, so each arm compares directly with its namesake. peer.port uses its
directional id.

On Zulu 17, with 3 forks and debug logging off, setting tags by id matched
setting them by name within the error bars, in both throughput and
allocation, with the dense store on and off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Box-once (5e600f7) made every DDSpanContext.setTag(long, <primitive>)
box first and pass the box down. With the dense store off, HashTagMap then
kept that box in the tag's Entry, although the serializers read the
primitive. That was a retained allocation the hash path never had, for any
value outside the box cache (most status codes and ports, nearly all
longs, every float and double). The box escapes into interceptTag, which
is not inlined, so C2 cannot remove it.

TagMap.Internals.STORES_BOXES, a static final that is true only for the
dense store, chooses between the two shapes, and the check folds. The
dense store keeps box-once. The hash store goes back to boxing only on the
interception path and storing the primitive otherwise.
SetTagBenchmark gains a knownIntTag_byId_uncached arm, since the existing
arms only use cached values and could not see the allocation.

Also fixes stale documentation found by the techdebt review:
- an orphaned comment describing the removed setBox;
- comments that still said known tags without a slot are stored by hash
  (every known tag is dense now);
- comments that said sizing hints tune only at serialization (they also
  tune when a span finishes).
SizingHintTable.OVERFLOW_SEED now reads DenseTagMap.KNOWN_INIT_CAP
instead of repeating its value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dougqh dougqh added type: feature Enhancements and improvements comp: core Tracer core tag: performance Performance related changes tag: experimental Experimental changes tag: ai generated Largely based on code generated by an AI or LLM labels Oct 13, 2026
@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

🎯 Code Coverage (details)
• Patch Coverage: 83.48%
• Overall Coverage: 59.79% (+0.12%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 06419b4 | Docs | Give us feedback!

dougqh and others added 5 commits October 14, 2026 00:01
Brings master (#12715 merged, Spotless 8.10.4), #12817, and #12731's
direction-neutral peer.port id. The dense store moves onto that model:

- peer.port is one tag with one id, so the undirected id (e7a8721),
  the UNDIRECTED and SHARED_NAME bits, and the dense store's in-place
  relabel are gone. Bit 0 is #12731's HAS_SCOPED_ALIAS.
- Slot coloring is re-applied on #12731's one-tag-per-name registry:
  a name declared per direction is one vertex.
- Tests and benchmarks use PEER_PORT_ID; the undirected-model tests are
  dropped in favor of #12731's SpanTags and directional span tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings master, including #12814, and the carve-bit reordering
(HAS_SCOPED_ALIAS, INTERCEPTED, LEVEL_TRACE: bits 0, 1, 2 in order).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
resolveSplitByTagsPrecedence checked language through the custom-tag
overload, which only matches names that resolve to no known tag.
language is known, so the check was always false and a prototype's
language was never re-applied over another split-by-tags candidate the
prototype set. Check and read it by LANGUAGE_ID, as for component.

Found by Codex, via a review comment from AlexeyKuznetsov-DD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tag-registry generator now assigns each known tag a slot in id bits
[47-32] by graph coloring: each concrete span type's resolved tags, and
the trace-level tags, form a clique and get distinct slots. So
1L << slotOf(id) is an exact presence bit for a span's tags, and one
long holds them all. Tags on no modeled span type get NO_SLOT, which a
long shift maps onto bit 63; real slots are capped below it.

KnownTagCodec gains SLOT_SHIFT, NO_SLOT, slotOf, hasSlot and slotCount.
Nothing in the tracer reads slots yet: this is split out of the dense
store (#12856) so the store, the span builder's tag ledger and a
map-level routing summary can each build on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dougqh and others added 2 commits October 14, 2026 22:02
The tag-slot coloring moves into its own PR, #12882. Where both had it,
this takes #12882's wording, keeps #12731's HAS_SCOPED_ALIAS and serial
range check in encode, and names DenseTagMap as the slots' reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ForEachContextBenchmark is a local scratch benchmark that the previous
merge picked up; it isn't part of this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread internal-api/src/main/java/datadog/trace/api/SizingHintTable.java Outdated
@dougqh dougqh changed the title Store known span tags densely, behind an experimental flag (phase 2 - dense tag storage) Store known span tags densely, behind an experimental flag (phase 3 - dense tag storage) Oct 15, 2026
@dougqh dougqh changed the title Store known span tags densely, behind an experimental flag (phase 3 - dense tag storage) Store known span tags densely, behind an experimental flag (phase 3b - dense tag storage) Oct 15, 2026
Comment thread dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java Outdated
void theTagMapFamilyHasNoSyntheticAccessors() {
List<Class<?>> classes = new ArrayList<>();
for (Class<?> root : Arrays.asList(TagMap.class)) {
for (Class<?> root :

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

To Claude - another PR is adding a general mechanism for this -- maybe we should adopt that here, too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. #12862's SyntheticAccessors.assertNone is more thorough: it also finds anonymous and local classes. When it lands, I'll switch in #12817, where TagMapSyntheticAccessorTest started, to @NoSyntheticAccessors on TagMap, HashTagMap, DenseTagMap and the factories. Each top-level class is its own nest, so each gets a one-line test. Leaving this open until then.

dougqh and others added 2 commits October 14, 2026 23:32
CoreTracer was its only caller outside datadog.trace.api. It now looks a
hint up through SizingHint.forOperation, on the type it already holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each primitive setTag(long, ...) repeated the same three branches. They
now box only when the box is needed -- the store keeps boxes
(STORES_BOXES, folded) or the tag may be intercepted -- and pass it to
one shared setKnownBox; otherwise they store the primitive. That keeps a
single boxing site per setter and the hash store's unboxed plain path.

setTag(long, int) drops from 195 to 76 bytecode bytes. SetTagBenchmark's
int and peer.port arms are unchanged in time and allocation with the
dense store off and on, and C2 still inlines the setter at its call
sites (no too-big refusals in either version).

The typed boxed Internals.setKnown and setKnownTrusted overloads, used
only by the old setters, go: an intercepted-then-stored primitive on the
hash store is now an ANY entry, which resolves to the same type on read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Initial capacity of the dense arrays when no {@link SizingHint} presized them; the largest span
* type resolves about this many tags.
*/
static final int KNOWN_INIT_CAP = 12;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

To Claude - I don't think that's true anymore -- maybe we can source this value from KnownTagsCodec.

dougqh and others added 3 commits October 15, 2026 00:05
TagMapFactory computed INSTANCE in its own static initializer from a
subclass's INSTANCE. Initializing a factory subclass first initializes
TagMapFactory, which then read that subclass's INSTANCE while it was
still null; TagMap's initializer later failed on it, poisoning TagMap
for the JVM's life. And a factory that built its empty map in its own
initializer started TagMap's initializer mid-way, so TagMap.EMPTY could
read a still-null cached empty map.

- The JVM's factory moves to a holder, TagMapFactory.Default.INSTANCE;
  TagMapFactory has no static state.
- TagMap.EMPTY is built fresh by newEmpty(). The JVM's factory returns
  TagMap.EMPTY from empty(); only the other store (tests) caches its own.

Nothing in production initializes a factory before TagMap today; the
parameterized tests did. TagMapFactoryInitOrderForkedTest initializes
both factories first, in a fresh JVM: it fails before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dense-store flag picks one TagMap implementation per JVM, so
TagMapTest, TagMapReadThroughTest, TagMapFuzzTest and TagMapSetByIdTest
only ever exercised HashTagMap. A TagMapType test enum (HASH, DENSE)
builds maps through each store's own factory, as TagMapType once did for
the optimized and legacy maps, and those suites now run against both
(TagMapTest 112 -> 215 tests, ReadThrough 51 -> 102).

- copy() now creates through the map's own factory (TagMap.factory()),
  so a copy keeps its implementation instead of taking the JVM's
  default store.
- createFromParent's read-through checks take an explicit factory
  (package-private), which TagMapType uses.
- 22 of TagMapDenseForkedTest's 24 tests didn't need the flag; they move
  to DenseTagMapTest in the normal test JVM. The forked test keeps the
  two that only the flag decides: that HashTagMap never loads, and that
  a ledger builds a dense map.
- TagMapFuzzTest's PutAllTagMap builds its source through the test
  map's own store, so the two never mix stores.

No test fails for one store and not the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6015cbb routed createFromParent(TagMap, SizingHint) through the new
factory-taking overload, so on the span-creation path the factory was a
parameter instead of a static final constant receiver, behind an extra
call level. With the dense store off, SpanCreationBenchmark allocated
24 more bytes per span (jdbcClientSpan 1,472 -> 1,496 B,
webServerSpan 1,840 -> 1,864 B).

The production path calls TagMapFactory.Default.INSTANCE.create(...)
directly again; both overloads share the read-through checks in
readThroughParent. Both arms are back to 1,472 and 1,840 B (webServerSpan
in 6 of 6 forks; one earlier 2-fork run still had one fork at 1,864).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dougqh added a commit that referenced this pull request Oct 15, 2026
The tag-registry generator now assigns each known tag a slot in id bits
[47-32] by graph coloring: each concrete span type's resolved tags, and
the trace-level tags, form a clique and get distinct slots. So
1L << slotOf(id) is an exact presence bit for a span's tags, and one
long holds them all. Tags on no modeled span type get NO_SLOT, which a
long shift maps onto bit 63; real slots are capped below it.

KnownTagCodec gains SLOT_SHIFT, NO_SLOT, slotOf, hasSlot and slotCount.
Nothing in the tracer reads slots yet: this is split out of the dense
store (#12856) so the store, the span builder's tag ledger and a
map-level routing summary can each build on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dougqh added a commit that referenced this pull request Oct 16, 2026
- TagMap.setResolved now calls set(tagId, ...) for a known id, as
  set(String, ...) already does, so every name-keyed span write takes the
  id path. Same result on today's store; a store that keeps known tags
  without an Entry per tag no longer allocates one here.
- TagMap gains a package-private getKnownValue(long), exposed to the core
  as Internals.getKnownValue. DirectionalTags' id reads and
  KnownTagCodec.directionOf use it instead of getEntry(id).objectValue().

Both pulled forward from the dense store work (#12856) so it overrides
them rather than reworking these call sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dougqh added a commit that referenced this pull request Oct 16, 2026
The tag-registry generator now assigns each known tag a slot in id bits
[47-32] by graph coloring: each concrete span type's resolved tags, and
the trace-level tags, form a clique and get distinct slots. So
1L << slotOf(id) is an exact presence bit for a span's tags, and one
long holds them all. Tags on no modeled span type get NO_SLOT, which a
long shift maps onto bit 63; real slots are capped below it.

KnownTagCodec gains SLOT_SHIFT, NO_SLOT, slotOf, hasSlot and slotCount.
Nothing in the tracer reads slots yet: this is split out of the dense
store (#12856) so the store, the span builder's tag ledger and a
map-level routing summary can each build on it.

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

dougqh commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #12918 (id/name store split, which carries the dense store as DenseIdStore behind the same trace.experimental.dense.tags.enabled flag) and #12926 (per-operation sizing hints and bulk prototype seeding, ported from this PR).

@dougqh dougqh closed this Oct 19, 2026
gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Oct 19, 2026
…oundwork - dense tag storage) (#12882)

Color known tags into co-occurrence slots in their ids

The tag-registry generator now assigns each known tag a slot in id bits
[47-32] by graph coloring: each concrete span type's resolved tags, and
the trace-level tags, form a clique and get distinct slots. So
1L << slotOf(id) is an exact presence bit for a span's tags, and one
long holds them all. Tags on no modeled span type get NO_SLOT, which a
long shift maps onto bit 63; real slots are capped below it.

KnownTagCodec gains SLOT_SHIFT, NO_SLOT, slotOf, hasSlot and slotCount.
Nothing in the tracer reads slots yet: this is split out of the dense
store (#12856) so the store, the span builder's tag ledger and a
map-level routing summary can each build on it.

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

Treat a custom tag's zero id as unslotted

hasSlot(0L) decoded slot 0, so the id keyOf gives a custom tag looked
like the known tag in slot 0. It now returns false for 0L. The slot-count
test also allows the full 63 slots the generator permits.

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

Say a slot bit is only a may-be-present signal

A span can still hold two tags that share a slot, so slotOf's presence
bit is exact only when it is clear.

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

Let rarely set tags share slots in the coloring

A span type can carry more tags than the occupancy long has slots: a CI
Visibility test span carries about 100. Most of those are set only some
of the time (retries, ITR, pull request info, ...), and falling back to
NO_SLOT would put them all on bit 63, which is then set on most spans
and turns every check of those tags into a scan.

`slot: shared` marks a tag as rarely set. The coloring keeps a shared
tag apart from every tag that isn't shared in its cliques, but lets two
shared tags take the same slot. Tags that aren't shared are colored
first, so they take the low, exact slots; shared tags fill the slots
left free. A span type then fits as long as its always-present tags do.

With no tag marked shared, the coloring and every id are unchanged.

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

Share slots by requirement level instead of a slot marker

Replaces the `slot: shared` marker with the requirement level the
conventions already carry, so slots stay out of the registry's
vocabulary. A ref can override the level per span type, so a tag can be
always present on one span type and occasional on another.

Tags that are required, conditional or recommended on some span type
are colored first and always get exact slots. Optional and opt_in tags
get exact slots too while any are free; only when a span type runs out
do two of them share one, on the slot the fewest already hold. Coloring
fails only if a span type's non-optional tags alone need more than 63.

On the current conventions nothing overflows: SLOT_COUNT stays 24 and
no tag shares a slot, though some slots move with the new order.
KnownTags marks a tag that shares a slot with `+ shares slot`.

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

Share slots only on span types that overflow

An optional tag pushed onto a shared slot by one span type's overflow
could land on the slot of an optional tag of another span type, one
that fits. Optional tags now get soft edges only on span types with
more tags than slots, so a span type that fits never has two tags on
one slot.

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

Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Oct 20, 2026
…ndary (otlp - tag registry - phase 3) (#12731)

Set known tags by id on TagMap and spans

Adds id-keyed setters, getters and removal to TagMap, TagMap.Entry and
spans, so a writer that knows the tag skips the name lookup. Squashed
from the review history of #12715.

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

Ignore unknown ids when clearing a tag by id; fix a comment wrap

span.setTag(0L, "") or a null value reached TagMap.getAndRemove(long),
which rejects unknown ids, so clearing threw IllegalArgumentException
while every id-keyed setter ignores an unknown id. removeTag(long) now
ignores it too.

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

Precheck and reuse the box in long-valued setters

The long-valued setters prechecked with needsIntercept, but when the
interceptor declined the tag they discarded the box it was given and stored
the primitive. Follow the same precheckIntercept -> setBox shape as the
other primitive setters, so TagMap keeps the box.

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

Keep peer.port off the id path

peer.port is declared per direction in the registry (#12713), so it has no
direction-free id: BaseDecorator, shared by client and server decorators,
sets it by name, and the set-by-id tests use an unshared int tag.

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

Merge set-by-id (#12715) onto master

Mark intercepted tags with an id bit, via a tracer overlay

Set-path routing is a per-language concern, so it lives in a Java overlay
next to tag-conventions.yaml rather than in the language-agnostic
conventions. The overlay declares the keys that exist only to be routed
(resource.name, error, sampling directives, ...) and lists every tag
TagInterceptor may route. Each listed tag's id carries the INTERCEPTED bit
(bit 1), so a setter called with a constant id can fold the interception
test away. Serial numbers become public so TagInterceptor can switch on
them.

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

Dispatch TagInterceptor on tag ids

TagInterceptor switches on the tag's serial rather than its name, so a
known tag is matched under any of its names and the *_OTEL_NAME cases go
away. needsIntercept(long) tests the INTERCEPTED bit first, which folds
away for a constant id. split-by-tags entries resolve to serials at
construction (each direction for a name declared per direction); only
custom tags are still matched by name. A test checks that exactly the
tags carrying the bit have a case.

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

Set tags on spans by id first

A name setter resolves a known tag's name to its id once and sets it as the
id-keyed setters do; only a custom tag is still handled by name. The
id-keyed setters no longer look up the name to feed the interceptor, so with
a constant id the interception test folds to the INTERCEPTED bit. Entry
paths (builder ledger, prototypes, default tags) route by the entry's id
and precheck before boxing.

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

Look up tag names by serial in an array, not a switch

The generated nameOf switched over every tag's serial: 491 bytes of
bytecode, too big for C2 to inline, so each id-keyed set paid two
out-of-line calls into it (the span's unknown-id guard and the entry's
name). Read a NAMES_BY_SERIAL array instead; nameOf is now 23 bytes and
inlines at every caller.

Also adds SetTagBenchmark (span setTag by constant id, non-constant id,
name, and custom name, against a bare and a synchronized TagMap store).

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

Point stale comments at the tracer overlay and the intercepted bit

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

Index the split-by-tags table by serial, sized to every known tag

The table is always allocated and never resized, so the split check -- the
only run-time check left on a non-intercepted constant-id set -- is a single
load, with no null or length test.

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

Check for a known tag id with a range check, not a name lookup

The span's unknown-id guard called nameOf; KnownTagCodec.isKnown answers the
same question from the serial and a generated SERIAL_LIMIT, so it folds
away for a constant id. Also note that serials are not stable across
releases.

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

Pin interceptTag(long) above C2's inlining size

DDSpanContext.setTag calls interceptTag only for intercepted tags. Kept out
of line (over FreqInlineSize, 325 bytes of bytecode), it never brings the
profiled handler bodies into setTag's compiled code, which would push setTag
past InlineSmallCode and stop callers inlining it -- the inlining a constant
id needs to fold its interception test. A test now fails if the switch
shrinks below the limit.

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

Tighten comments on the id-keyed interceptor

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

Treat overlay tags as intercepted; tidy from review

An overlay tag exists only to be routed, so the generator now marks it
intercepted without a second listing; intercepted: lists only the tags
declared in tag-conventions.yaml that the tracer also routes. Also restore
the doc comment and @Suppress that the overlay helper displaced, read the
serial limit one way, inline isUnknownTag, and drop an unused default.

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

Reject shared-name tag ids on the id-keyed TagMap and span paths

A tag declared once per direction (peer.port) has two ids but one
shared Datadog name, and keyOf resolves that name to neither. An entry
keyed by one of those ids hashed by the id while name-based access
hashed the name as a custom tag, so get/remove/containsKey by name
missed it and a later set by name produced a duplicate key.

Encode a SHARED_NAME flag in the reserved id bit 0 so the check folds
for a constant id. TagMap's id-keyed create/get/remove reject such ids,
and DDSpanContext's id-keyed setters ignore them, as they do unknown
ids, until name resolution knows the span's direction.

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

Test rejection of shared-name tag ids

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

Build custom tags by name and known tags by id

Entry's package-private name factories are now custom-only: they skip the
registry and assert the name is not a known tag. Callers that start from a
name resolve it once -- TagMap.set(String, ...) forwards a known tag to
set(long, ...), while getAndSet, put, Ledger.set, putAll and the public
Entry.create(String, ...) API go through anyEntryFor and friends -- so a
custom tag pays one registry lookup instead of two.

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

Resolve direction-dependent tag names by the span's kind

Builds on the registry directions (#12713) and id-keyed setters
(#12715, included here): KnownTagCodec resolves names by direction,
TagMap finds a shared name's tag whichever direction it holds, and
DDSpanContext resolves direction-dependent names by the span's kind.
Squashed from the review history of #12731.

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

Key shared-name tags by direction now that resolution knows it

#12817 rejected shared-name ids (peer.port inbound/outbound) on the id
paths until name resolution could see a span's direction. With direction
resolution in place, accept them again: a read or removal by the shared
name already checks each direction's id. To keep one entry per tag, a write
by the shared name stores under whichever direction's tag the map holds
(otherwise under the name, which the span re-keys once it has a
direction), and a write by id drops a value held under the name alone.
isKeyableById goes; the SHARED_NAME bit stays and keeps the new check
foldable for a constant id.

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

Merge master into id-tag-interceptor (#12817)

# Conflicts:
#	.github/CODEOWNERS

Merge id-tag-interceptor (#12817) into tag-registry-direction-resolution

Give the tracer core a restricted, trusted path into TagMap

TagMap.internals() returns a small Internals view whose setKnown(...) skips
the id validation the checked set(long, ...) repeats; DDSpanContext's
id-keyed setters, which have already checked isKeyableById, now use it.
Internals is created per call, so escape analysis removes it.

internals() is marked with a new @restricted(allowedIn = ...) annotation,
and gradle/forbiddenApiFilters/instrumentation.txt bans it in
instrumentation modules, so calling it there fails forbiddenApisMain.
Internals is also where the batched setTagsFrom/TagSink writes will go.

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

Mark TagMap.Internals @NoEscape

It exists to be created, used within the expression, and scalar-replaced;
storing one would force a real allocation.

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

Merge id-tag-interceptor (#12817) into tag-registry-direction-resolution

Point Internals' trust note at isKnown, the span's check here

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

Store each direction-dependent tag once and apply direction on read

peer.port gets one direction-neutral id (PEER_PORT_ID); only its
OpenTelemetry name (client.port inbound, server.port outbound) depends on
the span's direction. The generator merges per-direction declarations
into one tag, and the SHARED_NAME bit and shared-name sentinel go away.

Direction-scoped OpenTelemetry names (server.address, client.port, ...)
are stored as written. SpanTags resolves them by the rule
(name, direction) -> tag when a span reads or removes a tag, with the
tag winning over its scoped name. processTagsAndBaggage then folds them
into their tags once the kind is final. This removes the
unresolvedDirectionalTags deferral and re-keying from DDSpanContext, and
with them:
- bulk/builder writes bypassing resolution
- name-vs-id hash mismatch and parent shadowing in TagMap
- Entry equals/hashCode disagreement (equals now compares tagHash)
- the race between a deferred write and the first kind
- alias write order and kind changes not re-keying

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

Name direction-dependent tags by span kind in OTLP export

The OTLP JSON and protobuf writers compute the span's direction once from
its span.kind (SpanTags.directionOf) and name each tag for it, so
peer.hostname exports as server.address on a client span, and
network.client.ip as network.peer.address on a server span.

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

Apply last write wins between a tag and its direction-scoped alias

With no ordering state (a TagMap has no insertion order):
- Once the span has a kind, an alias write (server.address on a client
  span) is stored as its tag, overwriting it.
- Before then, the alias is stored as written. A write of a tag that has
  an alias removes every pending alias that could denote it, so a
  pending alias still present is the newer write, and it wins on read
  and when processTagsAndBaggage folds it into the tag.

Every DDSpanContext write path (by name, by id, by entry, and in bulk)
goes through SpanTags.prepareWrite. Names that don't depend on direction
cost one small map probe; ids that don't, one array load. The accepted
corner (a pre-kind write of either possible tag drops a pending alias) is
documented in SpanTags and DDSpanContext.

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

Carry the direction role in tag ids so plain setTag pays nothing extra

748a8af added a map probe to every write by name and routed bulk
copies entry by entry. Now:
- The generator sets a HAS_SCOPED_ALIAS bit (bit 0) on each tag with a
  direction-scoped OpenTelemetry name (peer.hostname, http.hostname,
  network.client.ip, peer.port). A mask tests it, and that folds away for
  a constant id.
- A write by name does one lookup, KnownTagCodec.keyOrAliasOf, which
  returns the id (bit included) or the existing alias sentinel. It then
  stores through the new TagMap.setResolved without a second lookup. Only
  ids with the bit, or the alias sentinel, go through SpanTags.
- Bulk copies use putAll again. needsIntercept(TagMap), computed once per
  tracer-level map, is also true for a map with a direction-dependent
  name. Per-entry paths test the id bit, or a 64-bit hash filter of the
  aliases for a custom entry.

The last-write-wins semantics and their tests are unchanged.

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

Move alias superseding off the setTag store fast path

The HAS_SCOPED_ALIAS test in the id setters cost about 5 ns on setTag by
name. That chain sits at C2's InlineSmallCode limit; with the extra code,
DDSpan.setTag "failed to inline: already compiled into a big method".

TagInterceptor.needsIntercept(long) now tests INTERCEPTED and
HAS_SCOPED_ALIAS with one mask, the same instruction count as before.
interceptTag, which stays out of line, calls
DDSpanContext.supersedeAliases for a tag with the bit and still lets
the tag be stored. The store fast path is back to exactly its pre-alias
shape. Tags with an alias pay a second lock acquisition, and primitive
writes of them (peer.port) box, as an intercepted tag's do.

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

Cover SpanTags and the KnownTagCodec direction helpers in internal-api

internal-api's jacocoTestCoverageVerification failed. SpanTags had no
internal-api tests (0% instructions and branches), and KnownTagCodec
fell to 70% instructions and 50% branches against its 80%/70%
thresholds. SpanTagsTest exercises get, remove, prepareWrite,
prepareAliasWrite, isDirectionDependent, resolveScopedNames and
openTelemetryTagOf across every direction, with the alias present and
absent and the last-write-wins cases. KnownTagsTest covers the
direction-scoped-name, flag and range helpers. SpanTags is now at
99%/96% and KnownTagCodec at 100%/94%.

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

Route alias-target writes only on spans holding a pending alias

dc06725 routed every write of a tag with a direction-scoped alias
(peer.hostname, peer.port, ...) through interception: a second lock, and
boxing for primitive writes. That's about 31 ns for setTag(peer.hostname).

Each span now keeps a routing mask, routedTagFlags, which holds
INTERCEPTED until an alias is stored as written; only then does it gain
HAS_SCOPED_ALIAS. The id setters test it with the same single mask test
as before (TagInterceptor.needsIntercept(tagId, flags)). So on a span
that never held a pending alias, the common case, these writes take the
plain store path, unboxed. The flag is sticky, set for any alias stored
as written, so whenever a pending alias could exist the write is routed
exactly as before. It's written under the tag lock and read without it:
a setter that misses it is racing an unordered alias write.

Adds SetTagBenchmark arms for the routed path and for a primitive
peer.port write by id.

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

Merge tag-registry-direction-resolution into the single-representation rework

# Conflicts:
#	internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java

Cover the remaining TagMap.Internals setKnown overloads

jacocoTestCoverageVerification flagged TagMap.Internals at 0.6: the
existing test exercised the CharSequence, int, long and boolean setters;
this adds the Object, float and double ones.

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

Add datadog.apicontract to CODEOWNERS

Restricted lives in the new datadog.apicontract package, which had no owner.
Give it the same owner as its sibling datadog.perfcontract.

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

Merge branch 'master' into dougqh/id-tag-interceptor

Give peer.port one direction-neutral id

peer.port was modeled as one tag per direction: PEER_PORT_INBOUND_ID and
PEER_PORT_OUTBOUND_ID, sharing the Datadog name and marked with a
SHARED_NAME id bit. Two ids under one name kept producing lookup and
supersede bugs: keyOf("peer.port") resolved to neither id, so a value set
by name and one set by id landed in different entries, and tombstones and
getAndSet by name could not find what an id had stored.

The generator now merges a name's per-direction declarations into one tag
with one id (PEER_PORT_ID), and only its OpenTelemetry name depends on the
span's direction: openTelemetryNameOf(id, direction) returns client.port
inbound and server.port outbound. The SHARED_NAME bit, hasSharedName and
isKeyableById go away (bit 0 is left unused), and keyOf("peer.port")
returns PEER_PORT_ID.

KnownTagCodec gains the DIRECTION_* constants and
openTelemetryTagOf(id, direction). Direction-scoped OpenTelemetry names
are still not resolved when written by name.

This is the direction-neutral-id part of #12731's redesign; its
direction-scoped aliases and read-time resolution are left to that PR.

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

Name peer.port by the span's kind in OTLP export

With peer.port now one direction-neutral tag, its OpenTelemetry name
depends on the span: server.port on a client span, client.port on a
server span. The OTLP JSON and protobuf writers compute the span's
direction once from its span.kind and name each tag for it, so
direction-scoped renames apply too: peer.hostname exports as
server.address on a client span, and network.client.ip as
network.peer.address on a server span.

KnownTagCodec gains directionOf(TagMap) / directionOfKind(String), and
TagMap.EntryReader gains openTelemetryTag(direction), standing in for
the SpanTags helpers #12731 introduces.

Ported from #12731's redesign.

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

Merge id-tag-interceptor (#12817, before the peer.port move) into tag-registry-direction-resolution

Brings #12817's master merge and its CODEOWNERS entry for
datadog.apicontract.

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

Merge id-tag-interceptor (#12817) into tag-registry-direction-resolution

#12817 now carries the direction-neutral peer.port id and naming peer.port
by span kind in OTLP export (22d0fd1, ba715de), moved down from this
branch's redesign. This branch already has the final versions of those
changes (06dec0f, d469c66, with SpanTags in place of #12817's
stand-ins), so the merge keeps this branch's tree.

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

Let C2 inline the constructors of TagMap's nested classes

Entry's full constructor and the Internals and Ledger constructors were
private, so javac (targeting Java 8, without nestmates) gave each one a
synthetic access constructor taking the never-loaded class TagMap$1. C2
refuses to inline a call whose signature names an unloaded class
("unloaded signature classes"). So the Internals view the core creates
for each id write could not be scalar-replaced, and every Entry built
through that constructor went through a real call. Making the
constructors package-private removes the accessors and TagMap$1.

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

Remove TagMap's remaining synthetic accessors and guard against them

Several private members were used across TagMap's nested classes, so
javac (targeting Java 8) generated access$ methods for them: Entry's
*2Prim helpers and requireKnownName, TagMap.parentEntryVisible,
TagMap.putEntry and the TagMap.buckets field. Each accessor adds a level
of inlining depth. putEntry's accessor sits on Internals.setKnown, the
core's trusted id-write path. All of these are now package-private.

TagMapSyntheticAccessorTest fails if TagMap or any of its nested classes
gains a synthetic accessor again. It is the prototype for the
@NoSyntheticAccess contract (APMLP-1976).

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

Merge id-tag-interceptor (#12817) into tag-registry-direction-resolution

Brings #12817's removal of TagMap's synthetic accessors: package-private
constructors for its nested classes, plus putEntry, buckets and the Entry
helpers, guarded by TagMapSyntheticAccessorTest.

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

Merge master into id-tag-interceptor

Master now has #12715 (set-by-id, squashed) and #12037 (decorator
SpanPrototype). Conflicts resolve to this branch's newer id-keyed code,
plus:
- getAndSet finds its inherited prior by the entry's id (#12715's last fix);
  the shared-name test for it is dropped, since peer.port now has one id.
- #12037's resolveSplitByTagsPrecedence checks component by id and language
  by name against the serial-indexed split-by-tags table.

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

Cover TagMap.Internals, KnownTagCodec and EntryReader paths

Brings the three classes back over jacoco's thresholds: the trusted
setters' Object/float/double overloads, isIntercepted, isKnown's range
edges, canonicalTagName, span-kind direction, and an entry's directional
OpenTelemetry name.

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

Format the tag-registry generator to its spotless rules

build-logic is a separate Gradle build, so the root spotlessApply never
formats it. Three generator lines were wrapped where its Kotlin formatter
keeps them on one line.

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

Merge id-tag-interceptor (#12817) into tag-registry-direction-resolution

Brings master, including #12715 (now merged) and Spotless 8.10.4, plus
#12817's new tests and its tag-registry formatting fix.

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

Drop #12817's direction stand-in tests, covered by SpanTagsTest

#12817 tests KnownTagCodec.directionOf/directionOfKind and
EntryReader.openTelemetryTag(int), stand-ins that this branch replaces
with SpanTags. SpanTagsTest already covers the same cases.

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

Order the tag-id carve bits by position

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

Merge master into id-tag-interceptor

Brings #12814 (split-by-tags by OpenTelemetry name). Id matching
already covers OpenTelemetry names, so this PR's TagInterceptor wins and
#12814's withCanonicalNames goes, as planned. #12814's tests stay; one
expectation changes: with db.type configured, setTag("db.system", ...)
now splits too, since a known tag matches under any of its names.

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

Merge id-tag-interceptor (#12817) into tag-registry-direction-resolution

Brings master, including #12814, and #12817's carve-bit reordering,
applied here with HAS_SCOPED_ALIAS: bits 0, 1, 2 in order.

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

Re-apply split-by-tags language by its id

resolveSplitByTagsPrecedence checked language through the custom-tag
overload, which only matches names that resolve to no known tag.
language is known, so the check was always false and a prototype's
language was never re-applied over another split-by-tags candidate the
prototype set. Check and read it by LANGUAGE_ID, as for component.

Found by Codex, via a review comment from AlexeyKuznetsov-DD.

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

Say what a tag id's intercepted bit does and doesn't decide

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

Merge #12817's final commits into tag-registry-direction-resolution

Merge remote-tracking branch 'origin/master' into dougqh/tag-registry-direction-resolution

Restore the conventions' one-tag wording for peer.port

An earlier merge kept the comments from before peer.port got a single,
direction-neutral id. The generated registry already has one
PEER_PORT_ID; the comments now say so again.

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

Tidy the direction-resolution code from review

- TagMap: getAndSet(String, ...) and put use the existing entry
  helpers again instead of inlining them; writeKeyOf is gone.
- DDSpanContext: build the span-kind direction table from
  SpanTags.directionOfKind so the two can't drift, and keep the
  supersede-pending-aliases guard in one helper.
- KnownTagCodec: drop keyOf(String, int), used only by tests, and the
  package-private lookup that duplicated keyOrAliasOf. The tests check
  the same behaviour through directionalKeyOf and keyOrAliasOf.
- TagConventions: drop the unused sharedNameDirection.

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

Keep the alias slow paths to spans that really hold a pending alias

- Serialization resolves pending aliases only on a span that stored
  one, instead of walking every tag of every span, and skips internal
  spans, where no alias names a tag.
- A custom tag that merely passes the alias filter no longer marks the
  span as holding a pending alias. prepareAliasWrite now tells a real
  alias left pending (the registry's sentinel) from a name that is no
  alias at all, and only the former sets the flag.

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

Make a removal before the span's kind supersede every meaning

Removing a direction-dependent name before the span had a kind removed
only its literal spelling, so an older value could resurface once the
kind arrived: a removed server.address left peer.hostname visible on what
became a client span, and removing peer.hostname left a pending
server.address that then resolved to it. A removal now counts as a
write: removing a tag also removes the pending aliases that could denote
it, and removing an alias also removes every tag it could denote.

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

Extend the pending-alias gate to reads, removals and entry routing

- getTag of a tag with an alias probes for a pending alias only when
  the span holds one; SpanTags.get takes the span's flag.
- Removal by name does one keyOrAliasOf and dispatches on it, with a
  new TagMap.removeResolved, instead of two registry lookups.
- Entries are routed through interceptTag only for real routes;
  storeEntry already supersedes pending aliases.

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

Encapsulate direction handling in a package-private DirectionalTags

SpanTags was a public internal-api class, though only DDSpanContext used
its span behavior. It is now DirectionalTags, package-private beside
DDSpanContext in dd-trace-core, and reads everything through
KnownTagCodec and TagMap.

The naming facts other code needs go back to KnownTagCodec, where master
had them: directionOf, directionOfKind, and a new isDirectionDependent
for TagInterceptor. TagMap.EntryReader.openTelemetryTag(int) is restored,
so the OTLP writers are unchanged from master.

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

Write and read known tags by id without building an Entry per call

- TagMap.setResolved now calls set(tagId, ...) for a known id, as
  set(String, ...) already does, so every name-keyed span write takes the
  id path. Same result on today's store; a store that keeps known tags
  without an Entry per tag no longer allocates one here.
- TagMap gains a package-private getKnownValue(long), exposed to the core
  as Internals.getKnownValue. DirectionalTags' id reads and
  KnownTagCodec.directionOf use it instead of getEntry(id).objectValue().

Both pulled forward from the dense store work (#12856) so it overrides
them rather than reworking these call sites.

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

Resolve aliases when the kind is set, and route them as their tag

- Pending aliases fold into their tags as soon as the span learns its
  kind, in setSpanKindOrdinal, instead of at serialization. Readers that
  run first, such as peer service naming in processServiceTags, now see
  the tag. The serialization-time pass is gone.
- Applying a prototype checks presence by the tag an entry resolves to,
  so an alias default can't overwrite an explicit value of its tag.
- A name-keyed or bulk write of an alias, once the span has a
  direction, is intercepted as the tag it denotes, so split-by-tags on
  peer.hostname sees server.address.

Accepted corner, documented on DirectionalTags: an alias written before
the kind is resolved without passing through the interceptor again.

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

Close three more gaps between the write paths and direction handling

- Builder ledger removals go through DirectionalTags.remove, as
  removeTag does, so removing an alias also removes the tag a
  direction-aware write stored it as.
- Split-by-tags that names an alias itself keeps that alias on the
  custom path, so the configured name still matches after aliases
  started routing as the tag they denote.
- setSpanKindOrdinal checks for pending aliases under the lock alias
  registration takes, so a concurrent alias write can't be left
  unresolved.

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

Let a Datadog tag name double as another tag's direction-scoped alias

The registry rejected any OpenTelemetry name that matched a Datadog name,
even for direction-scoped renames. That guarded only against misuse (a tag
landing on a span of the wrong direction and colliding at export), and it
forced invented names such as http.port for the inbound server port.

A scoped rename may now be another tag's Datadog name. The owning tag keeps
the name in every direction no alias claims: server.port is peer.port on
outbound spans and its own tag (the server's port) otherwise. The name goes
through the existing direction-scoped alias handling, so writes resolve by
the span's kind. The one remaining collision, the tag written by id on a
span where its name belongs to another tag, is handled at OTLP export: the
alias's tag wins, so each attribute name is emitted once.

Direction-free renames still may not match a Datadog name, since without a
direction such a name would resolve to two tags.

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

Drop the OTLP export check for a tag written by id on the wrong side

Only a by-id write can put a shared-name tag on a span where its name
belongs to another tag (server.port's own tag beside peer.port on a client
span); name writes always resolve by the span's kind. Ids are trusted, so
that case is exported as written rather than reconciled on every span.

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

Merge remote-tracking branch 'origin/master' into dougqh/tag-registry-direction-resolution

Resolve direction-scoped names at the OpenTelemetry boundary, not in the core

Replaces the span-level direction handling (DirectionalTags, pending
aliases folded in when the kind arrives, last-write-wins between a tag and
its alias, the HAS_SCOPED_ALIAS id bit and the routing it drove) with a
smaller rule: in Datadog terms a tag's name never depends on the span's
direction, so the core resolves names without one, as on master. Only some
OpenTelemetry names depend on direction, and direction is applied where the
OpenTelemetry side knows it:

- Export already names each tag by the span's direction.
- The generator emits KnownTags.Inbound, Outbound and Internal: each
  OpenTelemetry name that names a tag in that direction, as the tag's id
  (Outbound.SERVER_ADDRESS_ID is PEER_HOSTNAME_ID). Instrumentation that
  knows its direction writes in OpenTelemetry terms by id.
- KnownTagCodec.directionalKeyOf resolves such a name for a direction.
- The OpenTelemetry shim stores a direction-scoped attribute as the tag it
  denotes for its span's kind; the builder holds one set before the kind
  until the span starts.

Nothing in the tracer writes these names by string, so the pending-alias
machinery only served user code writing an alias before setting the kind;
such a value now stays as written. server.port stays a Datadog name too:
the server's own port, and peer.port on outbound spans.

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

Merge master (#12882 slot coloring) into tag-registry-direction-resolution

Clean textual merge. Also formats TagRegistryGeneratorTest's holder
helper, which build-logic's spotless flagged (the root spotlessApply
skips build-logic).

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

Resolve held OTel attributes as soon as the span kind is set

An attribute set before the kind was replayed at start, over any later
write to the same attribute. Resolving the held attributes when the kind
is set keeps them in write order.

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

Ignore null OTel attribute keys again

A null key reached the generated directional switch and threw into
application code. Resolve it as no known tag, so the span builder drops
it as it did before.

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

Merge branch 'master' into dougqh/tag-registry-direction-resolution

Merge branch 'master' into dougqh/tag-registry-direction-resolution

Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
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: experimental Experimental changes tag: performance Performance related changes type: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant