Conversation
An OpenTelemetry name can mean different tags depending on direction:
server.address is the local host on an inbound span and the remote one on
an outbound span. Span types may now declare span-kind, which sets their
direction; a rename on such a type applies only in that direction. A
frame: relative mixin (tags naming the other end of the connection) can
give each direction its own name with otel-name: { outbound, inbound }.
OpenTelemetry names are now unique per direction rather than globally.
Name resolution is not direction-aware yet, so only renames that apply in
every direction feed the generated tables; direction-scoped ones are
validated and listed in tag-assignment.txt. span-kind-neutral now widens
a typed rename to every direction, and is still required on a concrete
type with no span-kind. Generated KnownTags is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds span-kind to every concrete span type and moves peer.hostname/ipv4/ ipv6/port into a frame: relative peer_endpoint mixin, now also included by http.server (server spans carry peer.* for the client's address). Declares the renames #12354 had to drop as direction-scoped: server.address (http.hostname inbound, peer.hostname outbound), server.port/client.port on peer.port, and network.peer.address (network.client.ip, inbound). They are recorded but not yet applied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
Replaces frame: relative and the { outbound, inbound } otel-name map with
directional mixins. A mixin may declare span-kind; its renames then apply
only in that direction, and only span types of that direction may receive
it. A Datadog name may be declared once per direction: each declaration is
its own tag, <dd-name>@<direction>, with fixed names in both namespaces,
so export needs no context. A ref to such a name resolves by the
referencing scope's direction. A Datadog name shared this way is left out
of the direction-free keyOf table, since it names no single tag without a
direction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaces the peer_endpoint mixin with peer_address (peer.ipv4/ipv6, shared) and directional outbound_peer (peer.hostname -> server.address, peer.port -> server.port) and inbound_peer (peer.port -> client.port) mixins. peer.port becomes peer.port@outbound and peer.port@inbound; the bare name no longer resolves to a single tag until resolution is direction-aware. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
peer.port names two tags, peer.port@outbound and peer.port@inbound. KnownTags now emits one PEER_PORT_NAME constant for the shared name, alongside each tag's own ID, instead of two NAME constants with the same value. tag-assignment.txt lists shared Datadog names: emitting one needs no context, but resolving it to a tag needs the span's direction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shared name like peer.port names no single tag, so a String constant for it would only invite direction-blind use. KnownTags now emits just the unambiguous per-direction IDs, each documented with its name and direction; nameOf returns the shared name as a literal. The registry tag carries its declaring direction for that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tions Says that <dd-name>@<direction> is the generator's report label, not YAML syntax; notes that server spans also carry the client address in peer.ipv4/ipv6, so mapping those inbound too would export the attribute twice; and points the address-family TODO at the planned resolved-by marker. Comments only; generated output is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Separates when to set span-kind-neutral from its interim behavior, states that a type without a span-kind cannot receive a directional mixin, and fixes two sentences whose reasoning read out of order. Comments only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SpanType and Mixin carried the raw span-kind string, and five call sites looked it up again to get a Direction (one with !!). They now store the Direction, parsed once. validateMixinDirections reuses appliedMixins instead of re-implementing which mixins reach a type, and each span type's direction is computed once while assigning identities. Generated output is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The emitter and the assignment report each recomputed shared Datadog names
with groupBy { ddName }, though every tag already carries the answer in
sharedNameDirection. Use that. Generated output is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TagRegistry.build walked otelMappings twice: once for the per-direction map and again inside TagConventions.directionFreeOtelNames. A rename that covers every direction is now read off the per-direction map, and the second method -- with its always-true distinct-names check -- is gone. validateOtelNames also drops a prev == t.name escape that could never hold, since each tag has one declaration. Generated output is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
The two most critical issues are: (1) inherited shared-name tags from a directional parent are not validated against a child's differing direction, allowing ambiguous resolved tag sets containing both inbound and outbound identities; and (2) shared-name aliases marked span-kind-neutral are incorrectly included in direction-free runtime tables, causing their resolved IDs to be lost and OTLP to emit the wrong tag name.
🤖 Bits Code Review · Commit d1d526e · @DataDog review to ask questions
| private fun validateMixinDirections(spanTypes: Map<String, SpanType>, mixins: Map<String, Mixin>) { | ||
| for (st in spanTypes.values.filter { !it.abstract }) { | ||
| val chain = chainOf(spanTypes, st.name) | ||
| val reaching = chain.flatMap { it.include }.mapNotNull { mixins[it] } + appliedMixins(mixins, chain) |
There was a problem hiding this comment.
Validate inherited shared tags against the child's direction
An outbound parent referencing peer.port and an inbound child including inbound_peer pass validation, but resolve(child) returns both peer.port@outbound and peer.port@inbound. Inherited references are fixed to the parent's direction, while this validation checks only mixins. The generator therefore accepts an ambiguous resolved tag set. Validate inherited shared identities against the receiving direction after identity assignment, or reject incompatible direction overrides.
Was this helpful? React 👍 or 👎
🤖 Bits Code Review · @DataDog review to ask questions · Open Bits AI session
There was a problem hiding this comment.
Same root cause as the thread at :306, fixed in dc8009a: a span type can no longer change the direction it inherits, so a parent's refs and a child's mixins always resolve in the same direction. Your scenario is now a test case.
| traceLevel, | ||
| id = encode(serial, traceLevel), | ||
| otelName = t.otelName | ||
| otelName = directionFree[t.name], |
There was a problem hiding this comment.
Keep shared-name aliases out of direction-free tables
Marking a shared-name declaration span-kind-neutral promotes its alias into the runtime table. For example, server.port resolves to peer.port@outbound, but TagMap.Entry canonicalizes it to peer.port and subsequently resolves that shared name to zero. OTLP then emits peer.port instead of server.port. Exclude shared-name aliases from direction-free tables until entries retain their resolved IDs, or reject this flag combination.
| otelName = directionFree[t.name], | |
| otelName = if (t.sharedNameDirection == null) directionFree[t.name] else null, |
Was this helpful? React 👍 or 👎
🤖 Bits Code Review · @DataDog review to ask questions · Open Bits AI session
There was a problem hiding this comment.
Fixed in e6be534 by rejecting the combination instead of leaving the alias out of the table. Declaring a tag per direction says its meaning flips with direction, and span-kind-neutral says it doesn't, so the generator now refuses that config. span-kind-neutral itself goes away in phase 2, once resolution knows the span's direction.
| * The OpenTelemetry name per span direction. A superset of [otelName]: a rename scoped to one | ||
| * direction appears only here, and is not applied until name resolution knows the direction. | ||
| */ | ||
| val otelByDirection: Map<TagConventions.Direction, String> = emptyMap(), |
There was a problem hiding this comment.
Can we simplify this from a map to a singular TagConventions.Direction? ? Either this tag will be for one direction or all, so this can be represented by a nullable value:
| val otelByDirection: Map<TagConventions.Direction, String> = emptyMap(), | |
| val otelByDirection: TagConventions.Direction? = null, |
There was a problem hiding this comment.
Agreed, done in 4fb37ef. A tag has one declaration, so its rename covers every direction or exactly one; it's now otelDirection: Direction? (null = every direction), and the direction-free otelName is derived from that rather than from the map's size. Generated output is unchanged.
| require(refList(traceLevelRaw).isEmpty()) { "trace_level tags must be declarations, not refs" } | ||
| validateSingleDeclaration(spanTypes, mixins, traceLevel) | ||
|
|
||
| validateMixinDirections(parsedSpanTypes, parsedMixins) |
There was a problem hiding this comment.
Codex revealed to me that while we do validate mixin directions, inheritance is not validated. We could theoretically have an abstract parent declare span-kind: client while a subtype declares span-kind: server and the system would not throw an error
There was a problem hiding this comment.
Yes, that's a good catch. Since I'm assuming we're happy with this approach to solving the naming ambiguities, I went ahead and added the enforcement in dc8009a.
One nice aspect of the tag registry is that it makes implementing these checks easy. That wasn't something I set out to do, but I agree with adding the checks.
| "consumer" to Direction.INBOUND, | ||
| "client" to Direction.OUTBOUND, | ||
| "producer" to Direction.OUTBOUND, | ||
| "internal" to Direction.NONE, |
There was a problem hiding this comment.
Codex asserts that a rename on an internal type is silently dropped. For example, if view.render with span-kind: internal declares {dd-name: view.name, otel-name: something}, then otelMappings().add generates a List<OtelMapping with size 1, then in TagRegistry.build() it is filtered out because 1 != TagConventions.Direction.entries.size. This means that no corresponding _OTEL_NAME is generated for this entry
There was a problem hiding this comment.
Not dropped, deferred: internal is a directional scope (NONE), so the rename is scoped to direction-less spans and listed under DIRECTION-SCOPED in tag-assignment.txt, like any scoped rename in phase 1. Treating it as direction-free would also claim the OTel name on inbound and outbound spans. Since 4fb37ef that's explicit (otelDirection = NONE) rather than a side effect of the size filter, and phase 2 applies scoped renames once resolution knows the direction. Fair point on the "silent" part though — 59a6a2a calls out internal in the span-kind-neutral docs and the YAML header.
A child type's own span-kind must agree with the nearest ancestor's. Otherwise the ancestor's refs resolve in the ancestor's direction while the child's mixins apply in its own, so one type could receive both sides of a tag declared per direction (e.g. peer.port@outbound and peer.port@inbound). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Declaring a tag per direction says its meaning flips with direction; span-kind-neutral says it doesn't. Accepting both put the alias into the direction-free table, where TagMap canonicalizes it to the shared Datadog name, which keyOf resolves to 0, so OTLP emitted the shared name instead of the OpenTelemetry one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tag has one declaration, so its OpenTelemetry rename applies in every direction or in exactly one. The per-direction map could express states the model rules out; a nullable Direction (null = every direction) says exactly what is possible. The direction-free otelName is now derived from it rather than from the map's size. Generated output is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ones Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ougqh/tag-registry-frames # Conflicts: # build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagConventions.kt
…ougqh/tag-registry-frames


What Does This Do
Follow-on to #12354 (stacked on it; the diff here is only the follow-on). #12354 had to drop several OpenTelemetry renames because their meaning depends on span direction, and a flat rename cannot express that. This PR teaches the tag registry about direction. Phase 1 is registry-only. Every rename
TagMapand OTLP apply today is unchanged; the one generated change is thatpeer.portbecomes one tag per direction (see below).The problem
OpenTelemetry names fall into two frames:
server.address,server.port,client.address,client.port.network.peer.address,network.peer.port.Datadog mixes the two as well.
http.hostnameandhttp.client_ipare absolute, butpeer.hostname,peer.ipv4/6, andpeer.portare relative.HttpServerDecoratorsetspeer.portto the client's port, and the client decorators set it to the server's. Soserver.addressishttp.hostnameon an inbound span andpeer.hostnameon an outbound one, andpeer.portisclient.portinbound butserver.portoutbound.Same-frame mappings never need context. Mapping a relative name to an absolute one needs exactly one bit: the span's direction.
What changes (phase 1)
The conventions are arranged like an object model: span types are classes, mixins are traits. Direction now fits that model.
span-kindon span types and mixins (server|client|producer|consumer|internal, inherited throughextends). It sets the direction: server/consumer are inbound, client/producer are outbound, internal has none. A directional mixin may only reach span types of its direction, and a span type may not change the direction it inherits throughextends; the generator checks both, so no type can receive both sides of a tag declared per direction.Where a rename is declared scopes it. In a scope without a
span-kind(trace_level, an abstract type, a plain mixin) it applies in every direction; in a directional scope, only in that direction.A tag whose meaning flips with direction is declared once per direction, each declaration in its own directional mixin. Each becomes its own tag,
<dd-name>@<direction>, with fixed names in both namespaces:peer.port@outboundandpeer.port@inboundshare the Datadog name, but each has one OTel name, so the reverse translation needs no context: input resolves to the specific tag, and output is a plain id→name lookup per namespace. Any other repeated declaration is still an error, and arefto a shared name resolves by the referencing scope's direction.Shared Datadog names are explicit.
peer.portis one name for two tags. It isn't 1-to-1, so the mapping from name to tag needs context, while the mapping from tag to name doesn't.KnownTagsgenerates no name constant for it, since a bare"peer.port"would only invite direction-blind use. Callers use the unambiguous IDs (PEER_PORT_OUTBOUND_ID,PEER_PORT_INBOUND_ID), each documented with its name and direction.tag-assignment.txtlists shared names:Names are unique per direction, in both namespaces.
server.addressishttp.hostnameinbound andpeer.hostnameoutbound;peer.portis one tag per direction.Only direction-free renames feed the generated tables, because
TagMapand OTLP do not know a span's direction yet. Direction-scoped renames are validated and listed intag-assignment.txt:The one interim runtime change: the bare
peer.portnames two tags, so the direction-freekeyOf("peer.port")returns 0 (unknown) instead of guessing. Its output is unchanged: it had no direction-free rename, so it still exports aspeer.port.span-kind-neutralnarrows to "apply this directional rename in every direction" (for example,db.systemonly ever names a database). It is still required on a concrete type with nospan-kind, and is rejected on a tag declared per direction, whose meaning flips with direction by definition. Phase 2 removes it.YAML: every concrete type declares a
span-kind.peer.*is split intopeer_address(peer.ipv4/6, shared),outbound_peer, andinbound_peer, sohttp.servernow carries its client'speer.*, fixing Add the tag registry and map OpenTelemetry tag names through it (otel/otlp) #12354's client-only modeling.Planned phases
TagMapstays direction-agnostic and serializers emit by id with no context (Set known tags by id on TagMap and spans #12715 adds id-keyed setters and makesTagMap.Entrycarry its tag id). Where resolution happens is still open: in the core span (DDSpanContext) for every writer, or at each namespace's entry point (OTel names in the OTel API layer, Datadog names at instrumentation call sites). The direction would come from the span'sspan.kindor, for decorator-built spans, theSpanPrototype. The per-direction lookup is generated intoKnownTagsalongside that first consumer. This retiresspan-kind-neutral.network.peer.addressforpeer.ipv4/6, which splits by address family rather than direction, so the registry can't resolve it. The plan is aresolved-by: <layer>marker naming the layer responsible (e.g. the OTel API layer), which allows the mutually exclusive shared output name and keeps it out of every lookup table. Also: messaging consumers, where it needs checking whetherserver.addressmeans the broker; and proxies, which may carry both frames on one span.Phase 2 is a separate PR, not more commits here. It builds on this PR and on #12715, which adds setting known tags by id to
TagMapand spans (a sibling of this PR, also stacked on #12354):Motivation
Resolution belongs at the writer, which has the context to pick the right concept. Datadog instrumentation writes by Datadog name, the OTel bridge knows its span kind, and a serializer knows its target namespace. Each tag needs one identity and unique input names, while output names may collide across directions. dd-trace-dotnet reaches the same result with typed
ClientTags/ServerTagsclasses; here the registry carries that context instead.Additional Notes
span-kind-neutralwidening, plus 10 invalid configurations.peer.portbecomes two tags; ids are not a stable contract. The direction-free rename table is identical to Add the tag registry and map OpenTelemetry tag names through it (otel/otlp) #12354's.Generated report changes
The generated reports now live under
internal-api/build/generated/tag-registry/(regenerate with./gradlew :internal-api:generateKnownTags), so they no longer show up in the PR diff. Here they are against #12354. Labels likepeer.port@inboundare the generator's derived identities for a name declared per direction, not YAML syntax.resolved-tags.txt:http.servergains its client'speer.*;peer.portbecomes one tag per directiontag-assignment.txt:peer.portsplits in two (serials after it shift by one); new direction-scoped and shared-name sectionsContributor Checklist
build-logic:tag-registry:test(71 tests) and:tag-registry:spotlessCheckgreen:internal-api:test(KnownTags*,TagMap*),:dd-trace-core:test(*otlp*,*taginterceptor*),OpenTelemetry14ConventionsTestgreenJira ticket
N/A
🤖 Generated with Claude Code