Skip to content

Split TagMap into id and name stores, owned directly by the span - #12918

Open
dougqh wants to merge 117 commits into
masterfrom
dougqh/tagmap-split-stores
Open

dougqh wants to merge 117 commits into
masterfrom
dougqh/tagmap-split-stores

Conversation

@dougqh

@dougqh dougqh commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What Does This Do

Splits tag storage into strict id and name stores, and has the span own them directly. TagMap becomes a thin view for everyone else. No behavior change.

The idea: classify a tag name once, at the boundary (known tag → id, custom tag → name, direction-scoped alias → the span's call), then keep the two kinds apart inside.

Layers

Layer Responsibility
KnownTagCodec Facts: ids, names, aliases, direction-dependence. keyOf classifies a name once
IdStore Known tags by id. IdStoreFactory picks one implementation per JVM from trace.experimental.dense.tags.enabled (off by default): HashIdStore (flag off) or DenseIdStore (flag on: insertion-ordered parallel arrays with one occupancy word per level, no per-tag Entry, point reads answer with a reused flyweight)
HashStore Custom tags by name, created lazily on the first custom write; until then the owner borrows its parent's custom store read-only
DDSpanContext Owns its IdStore (also its lock) and its HashStore. Routes each write to a store (routeSet), and holds all setting logic, with DirectionalTags and TagInterceptor
TagMap A thin, final view over a store pair (knownStore, nameStore). Standalone maps (tracer tags, DD_TAGS, prototypes, baggage) have their own stores; a span's view is direction-aware

Notable changes

  • TagMap.Internals is gone. The strict store API is the direct path, so core no longer needs a trusted side door around TagMap. Its tests move to TagStoresTest.
  • One IdStore implementation per JVM. The factory chooses once, so only one implementation loads and class hierarchy analysis devirtualizes every IdStore call. The dense store replaces Store known span tags densely, behind an experimental flag (phase 3b - dense tag storage) #12856, which is closed; per-operation sizing and bulk prototype seeding follow in Presize dense tag stores and seed span prototypes in bulk #12926.
  • Internal readers (post-processors, serialization) get a cached, live, direction-aware TagMap view over the span's stores. getTags() is still a snapshot copy.
  • Read-through is per store. Known and custom tags never share a key, so precedence never crosses stores. Semantics are unchanged.
  • Entry sharing is preserved. Copies share Entry objects and clone only BucketGroup chains, as before.

Motivation

TagMap had accreted name canonicalization, known-versus-custom classification and, with #12731, direction awareness. That forced core to reach around it through Internals, and every write route repeated the known-versus-custom and direction logic. Most of #12731's review findings were one write route missing a rule another route had.

This puts policy back in the span layer, keeps storage simple and fast, and gives the dense store a clean place as the known-tag half. That matters for the biggest remaining allocation sites: point reads building an Entry per read, primitive metrics stored as boxed Entrys, and the dense store's id arrays.

Additional Notes

Performance (JDK 17, -f 2, -prof gc; base is #12731's tip 6aa6d4f1e2):

Benchmark Base This PR
SetTag.knownTag_byId 13.48 ns, 48 B 13.44 ns, 48 B
SetTag.knownTag_byName 13.30 ns, 48 B 13.46 ns, 48 B
SetTag.customTag_byName 13.42 ns, 48 B 13.40 ns, 48 B
GetTag.knownTag_unsafeGetTag 5.21 ns, 0 B 5.19 ns, 0 B
GetTag.knownTag_getTag 9.15 ns, 0 B 9.12 ns, 0 B
SpanCreation.webServerSpan 1.343 ops/µs, 1912 B 1.315 ops/µs, 1952 B
SpanCreation.bareStartSpan (4 forks) 992 B 1032 B

Throughput is within noise. These numbers were measured before the store factory replaced the earlier compile-time HASH_MODE constant, and they cover only the flag-off store; a re-run on the current tip, flag off and on, is pending.

Known cost: +40 B per span. The span's view, handed to the tag post-processor chain, escapes there and isn't scalar-replaced, so the view is cached per span (32 B plus a field). This is a stopgap: the post-processors are due for rework, and once they read the span's stores directly, the view and the field go away. Without the cache it was +32 B on the app thread and another +32 B at serialization.

Semantic note: name-keyed reads through a span's view resolve aliases by the span's direction, where today's raw unsafeTags reads don't. No current post-processor reads an alias name.

Follow-up: a later commit moves pending aliases into a small lazily created holder: the custom store then holds only custom tags, and the pending-alias check becomes a null check that resets after resolution.

🤖 Generated with Claude Code

dougqh and others added 30 commits October 9, 2026 08:28
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>
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>
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>
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>
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>
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>
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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
#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>
# Conflicts:
#	.github/CODEOWNERS
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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
dougqh and others added 4 commits October 17, 2026 12:17
- isPresent reads the reader's own tagId instead of resolving the name
  again.
- createFromParent delegates to readThroughParent, which now carries the
  exact-emptiness comment; TagMap(TagMap) uses IdStore.create and
  HashStore.inheritedFrom; nameStoreForWrite uses HashStore.create.
- HashSupport.checkBuckets indexes a bucket group's entries by j, not
  the bucket index i, so the integrity check really inspects groups
  (copied as-is from the old TagMap.checkIntegrity).
- DDSpanContext's constructors drop the tagsSize parameter, which nothing
  used once the span owned its stores, and CoreTracer no longer computes
  it per span (that included a size() walking the read-through parent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only its own test called it; the span reads values from its stores
directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- isPresent moves above setTag(String, Object)'s Javadoc, which it had
  been inserted under, so setTag is documented again.
- A leftover constructor doc stacked on HashStore.inheritedFrom's is
  removed.
- TagMap.EMPTY's comment described the old length-1 bucket array; it now
  points at IdStoreFactory, which explains why building EMPTY is safe
  during class initialization.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Span-level and trace-level tags are colored independently, so their slot
numbers overlap, and a local root span holds both in one store. With one
occupancy word, a trace-level tag (runtime-id) made the slot of an
unrelated span-level tag (http.method) look occupied, so a miss on the
root span fell through to a scan: GetTagBenchmark's missing known tag
read was 6.1 ns dense against 3.2 ns hash.

IdStore keeps spanOccupancy and traceOccupancy and picks the word by the
tag's level (a select that folds for a constant id). Tombstones keep one
shared mask with their own level set. Costs 8 bytes per store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dougqh
dougqh marked this pull request as ready for review October 18, 2026 20:58
@dougqh
dougqh requested review from a team as code owners October 18, 2026 20:59
@dougqh
dougqh requested review from PerfectSlayer, jordan-wong and khanayan123 and removed request for a team October 18, 2026 20:59
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 18, 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-18T21:12:45.445926Z 0f1e5ac 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: 0f1e5acd2a

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

while (level != null) {
for (++this.index; this.index < level.used; ++this.index) {
if (this.leaf.readIfVisible(level, this.index, this)) {
return this;

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 stable readers from the public iterator

When DD_TRACE_EXPERIMENTAL_DENSE_TAGS_ENABLED is enabled, TagMap.iterator() exposes this cursor directly, and every advance returns the same mutable DenseCursor. Consumers that retain iterator results—such as collecting map.stream() into a list—therefore receive multiple aliases that all report the last visited tag and value, unlike the stable entries returned in hash mode. Materialize a stable reader or Entry at the public iterator boundary.

Useful? React with 👍 / 👎.

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.

👍 Good point, the reuse is problematic. I'll address that.

Comment on lines +394 to +395
if (value == null) {
return null;

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 null mappings in the dense store

When dense tags are enabled, writing a known key with a null value through the Map API—for example TagMap.put(name, null) or TagMap.fromMap(...)—stores a slot and increments the map size, but this method then treats that stored value as if the key were absent. The resulting map can report size() == 1 while containsKey(name) is false, and removal returns null despite deleting the slot; hash mode previously retained the null mapping. Use a distinct null-value sentinel or reject null before mutating the store.

Useful? React with 👍 / 👎.

Comment thread dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java

@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: FAIL

Two critical correctness gaps exist in the dense-storage TagMap implementation: concurrent reads on a frozen map share a mutable reader, allowing tag values to bleed across threads; and stored null values are indistinguishable from absent entries, breaking containsKey, getOrDefault, and removal semantics relative to hash mode. A third issue affects sequential traversal: the iterator exposes a single reused cursor, so buffering or collecting the stream loses all but the final entry.

Open Bits AI session

🤖 Bits Code Review · Commit 0f1e5ac

Comment thread internal-api/src/main/java/datadog/trace/api/DenseIdStore.java Outdated
DenseIdStore level = this.level;
while (level != null) {
for (++this.index; this.index < level.used; ++this.index) {
if (this.leaf.readIfVisible(level, this.index, this)) {

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.

P2 Keep public traversal entries stable in dense mode

With dense storage enabled, TagMap.iterator() exposes the same mutable cursor for every known tag. Collecting a two-tag stream into a list therefore retains two references to the final tag, and buffering operations such as sorted() lose earlier entries. Materialize stable entries at public traversal boundaries, including the iterator and related consumer paths, while keeping flyweights internal.

React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it

What each reaction means
  • 👍 Useful: correct, and you're fixing it or will.
  • 👎 Wrong: the finding is incorrect, for example the bug can't happen or the code was misread.
  • 😕 Unclear: you can't tell what it means or why it matters.
  • 👀 Right, not acting: correct, but intended, out of scope for this PR, a known risk, or too minor.

🤖 Bits Code Review · Open Bits AI session


/** The flyweight over {@code value}, or null when there is none. */
private EntryReader readerOf(long tagId, Object value) {
if (value == null) {

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.

P2 Distinguish null-valued mappings from absent tags

With dense storage enabled, TagMap.put("http.route", null) stores and counts the mapping, but readerOf() treats its null value as absence. The map consequently reports size 1 and exposes the entry while containsKey("http.route") returns false, unlike the base and hash implementations. Preserve presence separately from the value throughout lookup, replacement, and removal handling.

React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it

What each reaction means
  • 👍 Useful: correct, and you're fixing it or will.
  • 👎 Wrong: the finding is incorrect, for example the bug can't happen or the code was misread.
  • 😕 Unclear: you can't tell what it means or why it matters.
  • 👀 Right, not acting: correct, but intended, out of scope for this PR, a known risk, or too minor.

🤖 Bits Code Review · Open Bits AI session

dougqh and others added 2 commits October 19, 2026 12:42
…ution

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>
…-split-stores

#12882 landed on master squashed, so this branch's copy of its earlier
coloring commits is replaced by the merged version:

- TagRegistry.kt and TagRegistryGeneratorTest.kt: master's (this branch
  only carried the older coloring there).
- KnownTagCodec: keeps this branch's notes (bit 63 kept clear, trace-level
  tags colored separately) and master's slotOf wording on overflow sharing.
- TagStoresTest: the span-level/trace-level slot test no longer assumes
  runtime-id and http.method share a slot (the merged coloring orders tags
  differently); it finds a span-level tag in runtime-id's slot at run time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dougqh added a commit that referenced this pull request Oct 19, 2026
…ype-fast-path

Clean merge; picks up #12918's reconciliation with the squashed #12882.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dougqh and others added 2 commits October 19, 2026 15:50
Patch coverage was 75%, mostly DenseIdStore: the dense store is behind a
flag that is off by default, so the shared TagMap suites only reached the
hash store and the dense store ran in one small forked test.

- IdStoreContractTest runs the IdStore contract against HashIdStore and
  DenseIdStore in the normal test JVM: typed setters and readers, put and
  getAndPut priors, removing and reviving inherited tags, size and
  emptiness through parents, every iteration form, copy, putAllLocal
  (including colliding buckets), the map fills, growth, multi-level reads
  and id validation.
- DDSpanContextStoresTest covers the span's routing: custom tags by name
  with each value type, primitive sets by id (intercepted, declined and
  plain), setMetric with known names, map writes, and the custom store
  borrowed from the tracer-level tags until the span's first custom write.
- The by-name setters' custom branches write straight to the custom
  store: they already know keyOf(tag) is 0, so going through routeSet
  repeated the lookup. routeSet's boolean overloads lost their last
  callers and are removed.

Local JaCoCo patch coverage (vs #12731): 75% -> 98%. The rest is mostly
checkIntegrity's error branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A reader whose entry() is null (only possible from an EntryReader
  outside the tracer) made the span's direct store routes throw, where
  TagMap.set treats it as a no-op. HashIdStore.put/getAndPut and the
  custom branch of routePut now skip it; DenseIdStore, which never builds
  an Entry, skips a reader with nothing to store. Tested on both stores and
  through DDSpanContext.setTag / setMetric.
- makeTagId(serial) built only serial << 48, without slot and flag bits:
  for a slotted tag that id passed isKnown but differed from the KnownTags
  constant, so a hash store filed it under the partial id. It now returns
  the real id from the generated serial-to-id table; a serial outside the
  known range still gets a serial-only id that isn't known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from dougqh/tag-registry-direction-resolution to master October 20, 2026 17:02
dougqh and others added 2 commits October 20, 2026 13:06
DenseIdStore answered every point read with one reader cached on the
store. Frozen stores are shared between threads (a span prototype's
tags, the tracer-level tags), so concurrent TagMap.getEntry calls could
overwrite each other's reader before copying it: a new test reproduces
it, one thread reading _dd.base_service as _dd.git.commit.sha.

- IdStore.getEntry(long) returns an Entry the caller may keep and shares
  no state: HashIdStore returns its stored Entry (getEntryReader now
  delegates to it), DenseIdStore builds one from the value. TagMap.getEntry
  uses it.
- DenseIdStore.getEntryReader returns a fresh reader per call.
- The store's reused reader now only answers writes' prior values: a store
  being written has a single writer, while a shared frozen store is only
  read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- OtelSpanBuilder, OpenTelemetry14DirectionalAttributesTest, and
  KnownTagCodec.directionalKeyOf: master's versions, carrying #12731's
  late fixes (held attributes resolved at setSpanKind, null keys ignored).
- tag-conventions.yaml: #12893 already declares process_id and
  _dd.trace_span_attribute_schema as required local-root trace tags, so
  this branch's duplicate declarations go; _dd.agent_psr joins that group.

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

@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: FAIL

The two most critical issues are: (1) in OtelSpanBuilder, directional attributes are not consistently deferred and re-resolved against the final span kind, so interleaved setAttribute/setSpanKind calls produce stale or wrong attribute mappings at startSpan(); (2) in DenseIdStore, concurrent reads of a frozen TagMap mutate a shared DenseReader flyweight, creating a race where getObject and typed getters can return another tag's value despite no map writes occurring.

Open Bits AI session

🤖 Bits Code Review · Commit ac96cd7

String kind = toSpanKindTagValue(spanKind);
this.delegate.withTag(SPAN_KIND, kind);
this.spanKindSet = true;
this.direction = KnownTagCodec.directionOfKind(kind);

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.

P2 Resolve directional attributes against the final span kind

Calling setSpanKind(CLIENT), setAttribute("server.address", "example.com"), then setSpanKind(SERVER) leaves peer.hostname on the server span instead of http.hostname. Updating direction does not remap attributes already sent to the delegate. Retain enough original attribute state to resolve against the final kind while preserving write order; changing this assignment alone cannot repair existing writes.

React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it

What each reaction means
  • 👍 Useful: correct, and you're fixing it or will.
  • 👎 Wrong: the finding is incorrect, for example the bug can't happen or the code was misread.
  • 😕 Unclear: you can't tell what it means or why it matters.
  • 👀 Right, not acting: correct, but intended, out of scope for this PR, a known risk, or too minor.

🤖 Bits Code Review · Open Bits AI session

Comment on lines +404 to +408
DenseReader reader = this.reader;
if (reader == null) {
reader = new DenseReader();
this.reader = reader;
}

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.

P2 Preserve concurrent reads of frozen tag maps

With dense tags enabled, concurrent reads of the same frozen TagMap mutate this shared reader. Another lookup can replace its ID or value before TagMap.getEntry() materializes the result, causing getObject() to return another key’s value despite no map writes. The child reproduced this race. Each lookup needs an independent result.

Suggested change
DenseReader reader = this.reader;
if (reader == null) {
reader = new DenseReader();
this.reader = reader;
}
DenseReader reader = new DenseReader();

React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it

What each reaction means
  • 👍 Useful: correct, and you're fixing it or will.
  • 👎 Wrong: the finding is incorrect, for example the bug can't happen or the code was misread.
  • 😕 Unclear: you can't tell what it means or why it matters.
  • 👀 Right, not acting: correct, but intended, out of scope for this PR, a known risk, or too minor.

🤖 Bits Code Review · Open Bits AI session

TagMap.getObject and getString went through getEntry, so on the dense
store every value read, by the serializer or a post-processor, built an
Entry only to unwrap it. A known tag is now read with
IdStore.getValue / getString, which build nothing on the dense store.

IdStore.getString is new so the hash store can keep answering with its
Entry's cached string: a numeric tag the serializer reads repeatedly is
still formatted once.

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: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant