From 885bdb5f20eefa35a2b5c2960b0a0bc07809da06 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 9 Oct 2026 08:28:28 -0400 Subject: [PATCH] Scope OpenTelemetry tag names by span direction in the tag registry Adds span-kind directions to span types and mixins, per-direction tag declarations (peer.port@inbound / peer.port@outbound), direction-scoped renames, and the validation that keeps names unambiguous per direction. Squashed from the review history of #12713. Co-Authored-By: Brice Dutheil Co-Authored-By: Claude Opus 5.5 --- .../tagRegistry/KnownTagsEmitter.kt | 76 ++-- .../buildlogic/tagRegistry/TagConventions.kt | 331 ++++++++++++++---- .../buildlogic/tagRegistry/TagRegistry.kt | 65 ++-- .../tagRegistry/TagRegistryGenerator.kt | 22 ++ .../tagRegistry/TagRegistryGeneratorTest.kt | 189 +++++++++- .../java/datadog/trace/api/KnownTagsTest.java | 15 +- tag-conventions.yaml | 76 +++- 7 files changed, 635 insertions(+), 139 deletions(-) diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt index 0b59976fc4d..1f808d8d357 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt @@ -39,27 +39,35 @@ object KnownTagsEmitter { return u } - val nameOfConst = HashMap() - val idOfConst = HashMap() - val serialOfConst = HashMap() - val otelNameOfConst = HashMap() + val nameOfConst = HashMap() + val idOfConst = HashMap() + val serialOfConst = HashMap() + val otelNameOfConst = HashMap() for (t in reg.tags) { val base = sanitize(t.name) - nameOfConst[t.name] = unique(withSuffix(base, "_NAME")) - idOfConst[t.name] = unique(withSuffix(base, "_ID")) - serialOfConst[t.name] = unique(withSuffix(base, "_SERIAL_NUM")) + // Shared Datadog names get no NAME constant because they identify several tags. + // Callers use a direction-specific ID (e.g. PEER_PORT_OUTBOUND_ID); nameOf emits the + // shared Datadog name as a literal. + if (t.sharedNameDirection == null) nameOfConst[t.identity] = unique(withSuffix(base, "_NAME")) + idOfConst[t.identity] = unique(withSuffix(base, "_ID")) + serialOfConst[t.identity] = unique(withSuffix(base, "_SERIAL_NUM")) // Suffix the pre-suffix base (not nameC), same as the other three: suffixing an // already-suffixed identifier would produce a redundant compound like NAME_OTEL_NAME. - if (t.otelName != null) otelNameOfConst[t.name] = unique(withSuffix(base, "_OTEL_NAME")) + if (t.otelName != null) otelNameOfConst[t.identity] = unique(withSuffix(base, "_OTEL_NAME")) } - fun nameC(name: String) = nameOfConst.getValue(name) - fun idC(name: String) = idOfConst.getValue(name) - fun serialC(name: String) = serialOfConst.getValue(name) - fun otelNameC(name: String) = otelNameOfConst.getValue(name) + fun nameC(identity: TagConventions.TagIdentity) = nameOfConst.getValue(identity) - val order = reg.tags.map { it.name } // stable emit order + // The expression nameOf returns: the NAME constant, or the shared name's literal. + fun nameExpr(t: TagRegistry.Tag) = nameOfConst[t.identity] ?: "\"${escape(t.ddName)}\"" + fun idC(identity: TagConventions.TagIdentity) = idOfConst.getValue(identity) + fun serialC(identity: TagConventions.TagIdentity) = serialOfConst.getValue(identity) + fun otelNameC(identity: TagConventions.TagIdentity) = otelNameOfConst.getValue(identity) + + val order = reg.tags.map { it.identity } // stable emit order + // keyOf has no direction argument, so omit shared names and let them resolve to 0 (unknown). + val keyOfOrder = reg.tags.filter { it.sharedNameDirection == null }.map { it.identity } // canonical name -> OpenTelemetry name, for the reverse (openTelemetryNameOf) switch. - val otelName = reg.tags.mapNotNull { t -> t.otelName?.let { t.name to it } }.toMap() + val otelName = reg.tags.mapNotNull { t -> t.otelName?.let { t.identity to it } }.toMap() return buildString { // Public API first (name + encoded id couplets), so readers see the useful parts up top; the // serial ids and keyOf/resolver machinery follow below. Derivation is in the trailing comment. @@ -78,14 +86,18 @@ object KnownTagsEmitter { ) for (t in reg.tags) { - appendLine( - """ - public static final String ${nameC(t.name)} = "${escape(t.name)}"; - public static final long ${idC(t.name)} = ${hex(t.id)}; - """.trimIndent() - ) + val direction = t.sharedNameDirection + if (direction == null) { + appendLine(" public static final String ${nameC(t.identity)} = \"${escape(t.ddName)}\";") + } else { + appendLine( + " /** {@code ${escape(t.ddName)}} on ${direction.yamlKey} spans. This ID identifies the direction; " + + "the Datadog name is shared across directions. */" + ) + } + appendLine(" public static final long ${idC(t.identity)} = ${hex(t.id)};") if (t.otelName != null) { - appendLine(" public static final String ${otelNameC(t.name)} = \"${escape(t.otelName)}\";") + appendLine(" public static final String ${otelNameC(t.identity)} = \"${escape(t.otelName)}\";") } append("// makeTagId(serial=${t.serial})") if (t.traceLevel) append(" + trace-level") @@ -97,14 +109,14 @@ object KnownTagsEmitter { // Serial numbers (globalSerial per tag) — package-private, consumed by the resolver switch. appendLine(" // ---- serial numbers ----") for (t in reg.tags) { - appendLine(" static final int ${serialC(t.name)} = ${t.serial};") + appendLine(" static final int ${serialC(t.identity)} = ${t.serial};") } // OpenTelemetry name -> canonical tag name. Validation ensures aliases are distinct from all // canonical names. Sort by OTel name to keep output deterministic. val otelByCanonical = reg.tags - .mapNotNull { t -> t.otelName?.let { it to t.name } } + .mapNotNull { t -> t.otelName?.let { it to t.identity } } .sortedBy { it.first } // keyOf table (open-addressed, via StringIndex.EmbeddingSupport). Canonical names first, then @@ -116,7 +128,7 @@ object KnownTagsEmitter { private static final String[] KEYOF_NAMES = { """.trimIndent() ) - order.forEach { appendLine(" ${nameC(it)},") } + keyOfOrder.forEach { appendLine(" ${nameC(it)},") } otelByCanonical.forEach { (otel, _) -> appendLine(" \"${escape(otel)}\",") } @@ -126,7 +138,7 @@ object KnownTagsEmitter { private static final long[] KEYOF_VALUES = { """.trimIndent() ) - order.forEach { appendLine(" ${idC(it)},") } + keyOfOrder.forEach { appendLine(" ${idC(it)},") } otelByCanonical.forEach { (_, canonical) -> appendLine(" ${idC(canonical)},") } @@ -163,11 +175,11 @@ object KnownTagsEmitter { switch (KnownTagCodec.serialNum(tagId)) { """.trimIndent() ) - for (name in order) { + for (t in reg.tags) { appendLine( """ - case ${serialC(name)}: - return ${nameC(name)}; + case ${serialC(t.identity)}: + return ${nameExpr(t)}; """.trimIndent() ) } @@ -185,12 +197,12 @@ object KnownTagsEmitter { switch (KnownTagCodec.serialNum(tagId)) { """.trimIndent() ) - for (name in order) { - if (otelName[name] == null) continue + for (identity in order) { + if (otelName[identity] == null) continue appendLine( """ - case ${serialC(name)}: - return ${otelNameC(name)}; + case ${serialC(identity)}: + return ${otelNameC(identity)}; """.trimIndent() ) } diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagConventions.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagConventions.kt index 75eaba1fe70..54fef064dfb 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagConventions.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagConventions.kt @@ -10,9 +10,25 @@ class TagConventions private constructor( private val mixins: Map, private val traceLevel: List, ) { - /** One tag declaration: its Datadog name, type, requirement level, and optional rename. */ + /** + * A tag's identity: its Datadog name, plus the direction when that name is declared once per + * direction, as `peer.port` is. Two declarations of a name in different directions are two tags. + * A value, not a string, so no declared name can be mistaken for a derived identity. + */ + data class TagIdentity(val ddName: String, val direction: Direction? = null) { + /** + * How reports and generated constant names show the identity: the Datadog name, or + * `@` for a name declared per direction. Display only, not YAML syntax. + */ + val label: String + get() = if (direction == null) ddName else "$ddName@${direction.yamlKey}" + + override fun toString() = label + } + + /** One tag declaration: its identity, type, requirement level, and optional rename. */ data class Tag( - val name: String, + val identity: TagIdentity, val type: String, val required: String, /** @@ -24,24 +40,54 @@ class TagConventions private constructor( */ val otelName: String? = null, /** - * Set `span-kind-neutral: true` for a rename declared on a concrete span type only - * when the Datadog and OpenTelemetry names denote the same value on every span kind. - * Name resolution ignores span kind, so the rename also applies outside that type. + * Applies a rename in every direction. Set `span-kind-neutral: true` only when both names + * denote the same value on every span kind, such as `db.type` and `db.system`. * - * For example, `db.type` -> `db.system` on `db.client` qualifies: `db.system` only - * ever describes a database. Renames in `trace_level`, abstract types, and mixins need - * no flag. Setting this flag without a configured rename is invalid. + * A span type or mixin with `span-kind` otherwise scopes its renames to that direction. + * This includes `internal` (spans with no direction). Scoped renames are recorded but kept + * out of the generated lookup tables until resolution supports directions. This flag + * will be removed then. * - * The flag is interim: a follow-on replaces it with span-kind-aware name resolution. + * Renames in scopes without a direction already apply everywhere. A concrete type with no + * declared or inherited `span-kind` must still set this flag for a rename. + * + * The flag requires an `otel-name` and cannot be used on a tag declared per direction. */ val spanKindNeutral: Boolean = false, - ) + ) { + /** The identity's label, as reports show it; see [TagIdentity.label]. */ + val name: String + get() = identity.label + + /** The Datadog-namespace name, shared by the tags of a name declared per direction. */ + val ddName: String + get() = identity.ddName + + /** The direction of a tag declared per direction, or null for every other tag. */ + val sharedNameDirection: Direction? + get() = identity.direction + } + + /** + * Span direction, derived from `span-kind`: `server` and `consumer` are inbound, `client` and + * `producer` are outbound, and `internal` has no direction. + */ + enum class Direction(val yamlKey: String) { + INBOUND("inbound"), + OUTBOUND("outbound"), + NONE("none"), + } + + /** The OpenTelemetry name for [tag] on spans of [direction], or of every direction when null. */ + data class OtelMapping(val tag: TagIdentity, val otelName: String, val direction: Direction?) /** * A `{ ref: , required: }` entry. It reuses a tag declared elsewhere, optionally * at a different requirement level; its type and otel-name always come from that declaration. + * [name] is the referenced tag's identity, resolved by direction when the name is declared per + * direction. */ - data class Ref(val name: String, val required: String?) + data class Ref(val identity: TagIdentity, val required: String?) data class SpanType( val name: String, @@ -50,23 +96,32 @@ class TagConventions private constructor( val include: List, val tags: List, val refs: List = emptyList(), + /** The direction set by this type's own `span-kind`, or null; see [directionOf] for inheritance. */ + val direction: Direction? = null, ) + /** + * A reusable tag set. When `span-kind` is present, receiving concrete span types must have + * the same declared or inherited direction. Renames are scoped to that direction unless + * `span-kind-neutral` widens them to every direction. + */ data class Mixin( val name: String, val appliesAll: Boolean, val appliesTo: Set, val tags: List, val refs: List = emptyList(), + /** The direction set by this mixin's `span-kind`, or null for a mixin of any direction. */ + val direction: Direction? = null, ) - private val declarations: Map by lazy { - allDeclaredTags().associateBy { it.name } + private val declarations: Map by lazy { + allDeclaredTags().associateBy { it.identity } } /** The tag a [Ref] names, at the ref's requirement level when it overrides one. */ private fun materialize(ref: Ref): Tag { - val decl = declarations.getValue(ref.name) + val decl = declarations.getValue(ref.identity) return if (ref.required == null) decl else decl.copy(required = ref.required) } @@ -82,12 +137,12 @@ class TagConventions private constructor( * from `applies` mixins add only missing tags. */ fun resolve(typeName: String): List { - val result = LinkedHashMap() - fun add(t: Tag) = result.putIfAbsent(t.name, t) + val result = LinkedHashMap() + fun add(t: Tag) = result.putIfAbsent(t.identity, t) fun applyRef(r: Ref) { - val current = result[r.name] + val current = result[r.identity] // Re-putting an existing key keeps its LinkedHashMap position. - result[r.name] = + result[r.identity] = when { current == null -> materialize(r) r.required == null -> current @@ -95,12 +150,7 @@ class TagConventions private constructor( } } - val chain = ArrayList() - var cur: SpanType? = spanTypes[typeName] - while (cur != null) { - chain.add(cur) - cur = cur.extends?.let { spanTypes[it] } - } + val chain = chainOf(spanTypes, typeName) for (st in chain.asReversed()) { st.tags.forEach { add(it) } for (mixinName in st.include) { @@ -111,12 +161,9 @@ class TagConventions private constructor( } st.refs.forEach { applyRef(it) } } - val chainNames = chain.map { it.name }.toSet() - for (mx in mixins.values) { - if (mx.appliesAll || mx.appliesTo.any { it in chainNames }) { - mx.tags.forEach { add(it) } - mx.refs.forEach { if (it.name !in result) add(materialize(it)) } - } + for (mx in appliedMixins(mixins, chain)) { + mx.tags.forEach { add(it) } + mx.refs.forEach { if (it.identity !in result) add(materialize(it)) } } return result.values.toList() } @@ -129,7 +176,7 @@ class TagConventions private constructor( addAll(traceLevel) spanTypes.toSortedMap().values.forEach { addAll(it.tags) } mixins.toSortedMap().values.forEach { addAll(it.tags) } - }.distinctBy { it.name } + }.distinctBy { it.identity } /** * Returns mixins with `applies` targets missing from `span_types`, paired with the missing names. @@ -144,14 +191,72 @@ class TagConventions private constructor( if (missing.isEmpty()) null else mx.name to missing } + /** + * Returns each tag's OpenTelemetry name and the direction it applies in. A rename in a scope + * without a direction (`trace_level`, a span type or mixin without a `span-kind`), or marked + * `span-kind-neutral`, applies in every direction (a null direction). An unmarked rename in a + * directional scope applies only in that scope's direction. + */ + fun otelMappings(): List = buildList { + fun add(t: Tag, direction: Direction?) { + val otel = t.otelName ?: return + add(OtelMapping(t.identity, otel, direction.takeUnless { t.spanKindNeutral })) + } + traceLevel.forEach { add(it, null) } + for (st in spanTypes.toSortedMap().values) { + val direction = directionOf(spanTypes, st) + st.tags.forEach { add(it, direction) } + } + for (mx in mixins.toSortedMap().values) { + mx.tags.forEach { add(it, mx.direction) } + } + } + companion object { + private val SPAN_KIND_DIRECTIONS = + mapOf( + "server" to Direction.INBOUND, + "consumer" to Direction.INBOUND, + "client" to Direction.OUTBOUND, + "producer" to Direction.OUTBOUND, + "internal" to Direction.NONE, + ) + + private fun chainOf(spanTypes: Map, typeName: String): List { + val chain = ArrayList() + var current: SpanType? = spanTypes[typeName] + while (current != null) { + chain.add(current) + current = current.extends?.let { spanTypes[it] } + } + return chain + } + + /** The mixins whose `applies` matches a type in [chain]. */ + private fun appliedMixins(mixins: Map, chain: List): List { + val chainNames = chain.map { it.name }.toSet() + return mixins.values.filter { mx -> mx.appliesAll || mx.appliesTo.any { it in chainNames } } + } + + /** The type's own or nearest inherited `span-kind` direction, or null when none is declared. */ + private fun directionOf(spanTypes: Map, st: SpanType): Direction? = chainOf(spanTypes, st.name).firstNotNullOfOrNull { it.direction } + + /** Reads `span-kind` as the direction it sets, or null when absent. */ + private fun parseDirection(m: Map, owner: String): Direction? { + val spanKind = m["span-kind"] + require(spanKind == null || spanKind in SPAN_KIND_DIRECTIONS) { + "$owner span-kind must be one of ${SPAN_KIND_DIRECTIONS.keys}" + } + return (spanKind as String?)?.let { SPAN_KIND_DIRECTIONS.getValue(it) } + } + @Suppress("UNCHECKED_CAST") fun parse(root: Map): TagConventions { for (section in listOf("span_types", "mixins", "trace_level")) { require(root[section] == null || root[section] is Map<*, *>) { "$section must be a mapping" } } val spanTypesRaw = (root["span_types"] as? Map) ?: emptyMap() - val spanTypes = + val parsedSpanTypes = spanTypesRaw.mapValues { (name, v) -> require(v is Map<*, *>) { "span type '$name' must be a mapping" } val m = v as Map @@ -172,11 +277,12 @@ class TagConventions private constructor( include = (m["include"] as? List) ?: emptyList(), tags = tagList(m["tags"]), refs = refList(m["tags"]), + direction = parseDirection(m, "span type '$name'"), ) } val mixinsRaw = (root["mixins"] as? Map) ?: emptyMap() - val mixins = + val parsedMixins = mixinsRaw.mapValues { (name, v) -> require(v is Map<*, *>) { "mixin '$name' must be a mapping" } val m = v as Map @@ -190,12 +296,13 @@ class TagConventions private constructor( appliesTo = if (applies is List<*>) (applies as List).toSet() else emptySet(), tags = tagList(m["tags"]), refs = refList(m["tags"]), + direction = parseDirection(m, "mixin '$name'"), ) } - for (spanType in spanTypes.values) { + for (spanType in parsedSpanTypes.values) { for (included in spanType.include) { - require(included in mixins) { + require(included in parsedMixins) { "span type '${spanType.name}' includes unknown mixin '$included'" } } @@ -205,7 +312,7 @@ class TagConventions private constructor( val name = current.name require(visited.add(name)) { "span type '${spanType.name}' has cyclic extends at '$name'" } current = current.extends?.let { parent -> - requireNotNull(spanTypes[parent]) { "span type '$name' extends unknown span type '$parent'" } + requireNotNull(parsedSpanTypes[parent]) { "span type '$name' extends unknown span type '$parent'" } } } } @@ -215,45 +322,132 @@ class TagConventions private constructor( val traceLevelRaw = (root["trace_level"] as? Map)?.get("tags") val traceLevel = tagList(traceLevelRaw) require(refList(traceLevelRaw).isEmpty()) { "trace_level tags must be declarations, not refs" } - validateSingleDeclaration(spanTypes, mixins, traceLevel) + + validateInheritedDirections(parsedSpanTypes) + validateMixinDirections(parsedSpanTypes, parsedMixins) + val identities = assignIdentities(parsedSpanTypes, parsedMixins, traceLevel) + val spanTypes = + parsedSpanTypes.mapValues { (_, st) -> + val direction = directionOf(parsedSpanTypes, st) + st.copy( + tags = st.tags.map { identities.rename(it, direction) }, + refs = st.refs.map { identities.resolve(it, "span type '${st.name}'", direction) }, + ) + } + val mixins = + parsedMixins.mapValues { (_, mx) -> + mx.copy( + tags = mx.tags.map { identities.rename(it, mx.direction) }, + refs = mx.refs.map { identities.resolve(it, "mixin '${mx.name}'", mx.direction) }, + ) + } validateOtelNameScope(spanTypes, mixins, traceLevel) return TagConventions(spanTypes, mixins, traceLevel) } /** - * Rejects multiple declarations of the same `dd-name`, including identical declarations. - * Declare shared tags once on a parent or mixin and reuse them through `ref` entries, - * which may override only `required`. References must name a declared tag. + * Rejects a type whose declared direction differs from its nearest directional ancestor. + * Ancestor tags retain their direction, so changing it could give a type both identities + * of a Datadog name declared per direction. */ - private fun validateSingleDeclaration( + private fun validateInheritedDirections(spanTypes: Map) { + for (st in spanTypes.values) { + val direction = st.direction ?: continue + val ancestor = st.extends?.let { chainOf(spanTypes, it) }?.firstOrNull { it.direction != null } ?: continue + require(direction == ancestor.direction) { + "span type '${st.name}' (${direction.yamlKey}) changes the direction it inherits from " + + "'${ancestor.name}' (${ancestor.direction!!.yamlKey})" + } + } + } + + /** + * Restricts directional mixins to concrete types with the same declared or inherited + * direction. Checks inherited `include` entries and `applies` targeting any ancestor. + * This prevents mixins from contributing both directions of a shared Datadog name. + */ + private fun validateMixinDirections(spanTypes: Map, mixins: Map) { + 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) + val direction = directionOf(spanTypes, st) + for (mx in reaching.distinct()) { + val mixinDirection = mx.direction ?: continue + require(direction == mixinDirection) { + "span type '${st.name}' (${direction?.yamlKey ?: "no span-kind"}) receives mixin '${mx.name}', " + + "which is ${mixinDirection.yamlKey}" + } + } + } + } + + /** + * Uses `dd-name` as the identity for a single declaration. A name declared in multiple + * directions gets `@` for each declaration; each must have a distinct + * declared or inherited direction. + * + * All other duplicate declarations fail, including identical ones. Reuse a tag with + * `{ ref: , required: }`; a reference may override only `required`. + */ + private fun assignIdentities( spanTypes: Map, mixins: Map, traceLevel: List, - ) { - val home = HashMap() // name -> declaring container - val declare = { container: String, t: Tag -> - val prev = home.putIfAbsent(t.name, container) - require(prev == null) { - "tag '${t.name}' is declared in both '$prev' and '$container'. Declare it once and use " + - "`{ ref: ${t.name}, required: }` elsewhere; a ref may override only `required`." + ): Identities { + data class Declaration(val container: String, val direction: Direction?) + val byName = LinkedHashMap>() + fun declare(container: String, direction: Direction?, t: Tag) { + byName.getOrPut(t.name) { ArrayList() }.add(Declaration(container, direction)) + } + traceLevel.forEach { declare("", null, it) } + spanTypes.values.forEach { st -> st.tags.forEach { declare(st.name, directionOf(spanTypes, st), it) } } + mixins.values.forEach { mx -> + mx.tags.forEach { declare("mixin ${mx.name}", mx.direction, it) } + } + + val perDirection = HashMap>() + for ((name, decls) in byName) { + if (decls.size == 1) continue + val first = decls[0] + val second = decls[1] + val directions = decls.map { it.direction } + require(directions.none { it == null } && directions.distinct().size == directions.size) { + "tag '$name' is declared in both '${first.container}' and '${second.container}'. Declare it " + + "once and use `{ ref: $name, required: }` elsewhere (a ref may override only " + + "`required`), or declare it once per direction in scopes with different span-kinds." } + perDirection[name] = directions.filterNotNull().toSet() } - traceLevel.forEach { declare("", it) } - spanTypes.values.forEach { st -> st.tags.forEach { declare(st.name, it) } } - mixins.values.forEach { mx -> mx.tags.forEach { declare("mixin ${mx.name}", it) } } - - val refs = - spanTypes.values.flatMap { st -> st.refs.map { st.name to it } } + - mixins.values.flatMap { mx -> mx.refs.map { "mixin ${mx.name}" to it } } - for ((container, r) in refs) { - require(r.name in home) { "'$container' refs undeclared tag '${r.name}'" } + return Identities(byName.keys, perDirection) + } + + private class Identities(val names: Set, val perDirection: Map>) { + fun rename(t: Tag, direction: Direction?): Tag { + if (t.ddName !in perDirection) return t + // Declaring a tag per direction says its meaning flips with direction; neutral says it doesn't. + require(!t.spanKindNeutral) { + "tag '${t.name}' is declared per direction, so its otel-name '${t.otelName}' cannot be " + + "span-kind-neutral; each direction's declaration names its own" + } + return t.copy(identity = TagIdentity(t.ddName, direction)) + } + + fun resolve(r: Ref, container: String, direction: Direction?): Ref { + val name = r.identity.ddName + require(name in names) { "'$container' refs undeclared tag '$name'" } + val directions = perDirection[name] ?: return r + require(direction != null && direction in directions) { + "'$container' refs '$name', which is declared per direction, but has no matching " + + "direction (${direction?.yamlKey ?: "no span-kind"})" + } + return r.copy(identity = TagIdentity(name, direction)) } } /** - * Requires `span-kind-neutral: true` for renames on concrete span types because name - * resolution ignores span kind. Validation checks the flag and requires a configured - * rename, relying on the author's semantic check. Shared scopes do not require the flag. + * Checks where `span-kind-neutral` is required and where it is allowed. A rename on a concrete + * span type without a `span-kind` requires it, because nothing scopes that rename to a + * direction. The flag itself requires a rename to apply to. */ private fun validateOtelNameScope( spanTypes: Map, @@ -266,13 +460,13 @@ class TagConventions private constructor( "tag '${t.name}' sets span-kind-neutral without an otel-name" } } - for (st in spanTypes.values.filter { !it.abstract }) { + for (st in spanTypes.values.filter { !it.abstract && directionOf(spanTypes, it) == null }) { for (t in st.tags) { require(t.otelName == null || t.spanKindNeutral) { - "tag '${t.name}' renames to otel-name '${t.otelName}' on concrete span type '${st.name}'. " + - "Canonicalization ignores span kind, so either declare it in a shared scope (an " + - "abstract parent or a mixin) or, if '${t.otelName}' means '${t.name}' on every span " + - "kind, add `span-kind-neutral: true`." + "tag '${t.name}' renames to otel-name '${t.otelName}' on concrete span type '${st.name}', " + + "which has no span-kind. Declare a span-kind to scope the rename to that direction, declare " + + "it in a shared scope (an abstract parent or a mixin), or, if '${t.otelName}' means " + + "'${t.name}' on every span kind, add `span-kind-neutral: true`." } } } @@ -292,7 +486,7 @@ class TagConventions private constructor( val name = m["ref"] require(name is String && name.isNotBlank()) { "ref has no valid tag name: $m" } require(m["required"] == null || m["required"] is String) { "ref '$name' required must be a string" } - Ref(name, m["required"] as? String) + Ref(TagIdentity(name), m["required"] as? String) } ?: emptyList() @Suppress("UNCHECKED_CAST") @@ -306,7 +500,7 @@ class TagConventions private constructor( require(m["type"] == null || m["type"] is String) { "tag '$name' type must be a string" } require(m["required"] == null || m["required"] is String) { "tag '$name' required must be a string" } Tag( - name = name, + identity = TagIdentity(name), type = (m["type"] as? String) ?: "string", required = (m["required"] as? String) ?: "optional", otelName = parseOtelName(m), @@ -338,7 +532,8 @@ class TagConventions private constructor( val raw = m["otel-name"] require(raw is String && raw.isNotBlank()) { "tag '${m["dd-name"]}' has an invalid otel-name: '$raw'. Use a non-empty name, the literal " + - "`none`, or omit the key entirely for pass-through under the Datadog name." + "`none`, or omit the key entirely for pass-through under the Datadog name. To name each " + + "direction, declare the tag once per direction in mixins with different span-kinds." } return raw.takeUnless { it == "none" } } diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt index 5f12adee3b9..664ee9f6687 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt @@ -18,14 +18,31 @@ package datadog.buildlogic.tagRegistry */ class TagRegistry private constructor(val tags: List) { data class Tag( - val name: String, + val identity: TagConventions.TagIdentity, val type: String, val required: String, val serial: Int, val traceLevel: Boolean, val id: Long, - val otelName: String? = null, - ) + /** The tag's OpenTelemetry name, in [otelDirection] or every direction; null when not renamed. */ + val declaredOtelName: String? = null, + /** + * The one direction [declaredOtelName] applies in, or null when it applies in every direction. A + * tag has one declaration, so its rename covers either every direction or exactly one. + */ + val otelDirection: TagConventions.Direction? = null, + ) { + /** The identity's label, as reports and constant names show it. */ + val name: String = identity.label + val ddName: String = identity.ddName + val sharedNameDirection: TagConventions.Direction? = identity.direction + + /** + * The rename included in the direction-free lookup tables, or null when absent or scoped + * to one direction. Scoped renames remain in `declaredOtelName` for later resolution. + */ + val otelName: String? = declaredOtelName.takeIf { otelDirection == null } + } companion object { const val FIRST_SERIAL = 1 @@ -42,21 +59,23 @@ class TagRegistry private constructor(val tags: List) { } fun build(conv: TagConventions): TagRegistry { - val traceNames = conv.traceLevelTags().map { it.name }.toSet() + val traceLevel = conv.traceLevelTags().map { it.identity }.toSet() + val renames = conv.otelMappings().associateBy { it.tag } // Stable order (by name) so serials -- and therefore ids -- are a pure function of the input. val tags = conv.allDeclaredTags().sortedBy { it.name }.mapIndexed { i, t -> val serial = FIRST_SERIAL + i - val traceLevel = t.name in traceNames + val isTraceLevel = t.identity in traceLevel Tag( - t.name, + t.identity, t.type, t.required, serial, - traceLevel, - id = encode(serial, traceLevel), - otelName = t.otelName + isTraceLevel, + id = encode(serial, isTraceLevel), + declaredOtelName = renames[t.identity]?.otelName, + otelDirection = renames[t.identity]?.direction, ) } @@ -65,21 +84,25 @@ class TagRegistry private constructor(val tags: List) { } /** - * An OpenTelemetry name must be unambiguous: it may not collide with any canonical tag name, nor - * be claimed by two different tags. Otherwise keyOf(otelName) would have no single right answer. - * Fail the build loudly rather than silently pick a winner. + * Rejects OpenTelemetry names that collide with any Datadog name. Within each direction, + * a rename must also belong to a single tag. Direction-free renames reserve their name + * in every direction. Scoped renames may share a name across directions: `server.address` + * maps to `http.hostname` inbound and `peer.hostname` outbound. */ private fun validateOtelNames(tags: List) { - val canonical = tags.map { it.name }.toSet() - val owner = HashMap() + val canonical = tags.map { it.ddName }.toSet() + val owner = HashMap, String>() for (t in tags) { - val otel = t.otelName ?: continue - require(otel !in canonical) { - "OpenTelemetry name '$otel' (of '${t.name}') collides with canonical tag name '$otel'" - } - val prev = owner.put(otel, t.name) - require(prev == null) { - "OpenTelemetry name '$otel' is claimed by both '$prev' and '${t.name}'" + val otel = t.declaredOtelName ?: continue + for (direction in t.otelDirection?.let { listOf(it) } ?: TagConventions.Direction.entries) { + require(otel !in canonical) { + "OpenTelemetry name '$otel' (of '${t.name}') collides with canonical tag name '$otel'" + } + val prev = owner.put(direction to otel, t.name) + require(prev == null) { + "OpenTelemetry name '$otel' is claimed by both '$prev' and '${t.name}' on " + + "${direction.yamlKey} spans" + } } } } diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt index 3d2885b3113..9b69f0fe0b0 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt @@ -94,5 +94,27 @@ object TagRegistryGenerator { for ((otel, canonical) in otelPairs) { appendLine(" %-30s -> %s".format(Locale.ROOT, otel, canonical)) } + appendLine( + """ + + # DIRECTION-SCOPED OPENTELEMETRY NAMES. Each applies only on spans of the given direction, so + # it is not in the tables above; name resolution does not use it until it knows the direction. + """.trimIndent() + ) + val scoped = + reg.tags.filter { it.otelDirection != null }.sortedWith(compareBy({ it.declaredOtelName }, { it.otelDirection })) + for (t in scoped) { + appendLine(" %-30s %-9s -> %s".format(Locale.ROOT, t.declaredOtelName, t.otelDirection!!.yamlKey, t.name)) + } + appendLine( + """ + + # SHARED DATADOG NAMES. One Datadog name for a tag per direction: emitting it needs no context, + # but resolving the name to a tag needs the span's direction, so keyOf does not resolve it yet. + """.trimIndent() + ) + for ((ddName, shared) in reg.tags.filter { it.sharedNameDirection != null }.groupBy { it.ddName }.toSortedMap()) { + appendLine(" %-30s -> %s".format(Locale.ROOT, ddName, shared.joinToString(", ") { it.name })) + } } } diff --git a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt index e05b4ce75a6..1ff22853571 100644 --- a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt +++ b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt @@ -131,6 +131,8 @@ class TagRegistryGeneratorTest { nonstring reference required | span_types: {base: {tags: [{ref: foo, required: 42}]}} | required must be a string scalar trace tag | trace_level: {tags: [foo]} | tag declaration must be a mapping trace reference | trace_level: {tags: [{ref: foo}]} | trace_level tags must be declarations + unknown span kind | span_types: {base: {span-kind: sideways}} | span-kind must be one of + unknown mixin span kind | mixins: {peer: {span-kind: sideways}} | span-kind must be one of """ ) fun `invalid composition preserves previous output`(domain: String, message: String) { @@ -221,7 +223,7 @@ class TagRegistryGeneratorTest { } @Test - fun `rename on a concrete span type requires span-kind-neutral`() { + fun `rename on a concrete span type with no span-kind requires span-kind-neutral`() { val yaml = directory.conventionsFile( """ span_types: @@ -276,6 +278,191 @@ class TagRegistryGeneratorTest { .contains("HTTP_METHOD_OTEL_NAME", "PEER_PORT_OTEL_NAME") } + @Test + fun `rename on a span type with a span-kind applies only in its direction`() { + val yaml = directory.conventionsFile( + """ + span_types: + http.server: + span-kind: server + tags: [{dd-name: http.hostname, otel-name: server.address}] + """ + ) + val output = File(directory, "generated") + + TagRegistryGenerator.generate(yaml, output) + + val generated = contents(output) + assertThat(generated.getValue("java/datadog/trace/api/KnownTags.java")).doesNotContain("server.address") + assertThat(generated.getValue("tag-assignment.txt")) + .containsPattern("server\\.address +inbound +-> http\\.hostname") + } + + @Test + fun `one OpenTelemetry name can map to a different tag in each direction`() { + val conventions = tagConventions( + """ + span_types: + http.server: + span-kind: server + include: [inbound_peer] + tags: [{dd-name: http.hostname, otel-name: server.address}] + http.client: + span-kind: client + include: [outbound_peer] + mixins: + outbound_peer: + span-kind: client + tags: + - {dd-name: peer.hostname, otel-name: server.address} + - {dd-name: peer.port, type: int, otel-name: server.port} + inbound_peer: + span-kind: server + tags: [{dd-name: peer.port, type: int, otel-name: client.port}] + """ + ) + + val tags = TagRegistry.build(conventions).tags.associateBy { it.name } + + assertThat(tags.getValue("http.hostname").let { it.declaredOtelName to it.otelDirection }) + .isEqualTo("server.address" to TagConventions.Direction.INBOUND) + assertThat(tags.getValue("peer.hostname").let { it.declaredOtelName to it.otelDirection }) + .isEqualTo("server.address" to TagConventions.Direction.OUTBOUND) + assertThat(tags.getValue("peer.port@outbound").let { it.declaredOtelName to it.otelDirection }) + .isEqualTo("server.port" to TagConventions.Direction.OUTBOUND) + assertThat(tags.getValue("peer.port@inbound").let { it.declaredOtelName to it.otelDirection }) + .isEqualTo("client.port" to TagConventions.Direction.INBOUND) + assertThat(tags.values.filter { it.name.startsWith("peer.port") }.map { it.ddName }) + .containsOnly("peer.port") + assertThat(tags.values.map { it.otelName }).containsOnlyNulls() + assertThat(conventions.resolve("http.client").map { it.name }).contains("peer.port@outbound") + assertThat(conventions.resolve("http.server").map { it.name }).contains("peer.port@inbound") + } + + @Test + fun `a Datadog name declared per direction is not resolvable without a direction`() { + val yaml = directory.conventionsFile( + """ + span_types: + http.client: {span-kind: client, include: [outbound_peer]} + http.server: {span-kind: server, include: [inbound_peer]} + mixins: + outbound_peer: {span-kind: client, tags: [{dd-name: peer.port, type: int}]} + inbound_peer: {span-kind: server, tags: [{dd-name: peer.port, type: int}]} + """ + ) + val output = File(directory, "generated") + + TagRegistryGenerator.generate(yaml, output) + + val generated = contents(output) + val source = generated.getValue("java/datadog/trace/api/KnownTags.java") + assertThat(source) + .contains("PEER_PORT_INBOUND_ID", "PEER_PORT_OUTBOUND_ID") + .contains("{@code peer.port} on outbound spans.", "{@code peer.port} on inbound spans.") + .doesNotContain("PEER_PORT_NAME", "PEER_PORT_INBOUND_NAME", "PEER_PORT_OUTBOUND_NAME") + assertThat(source.substringAfter("KEYOF_NAMES = {").substringBefore("};")).doesNotContain("PEER_PORT") + assertThat(generated.getValue("tag-assignment.txt")) + .containsPattern("peer\\.port +-> peer\\.port@inbound, peer\\.port@outbound") + } + + @Test + fun `a ref to a Datadog name declared per direction resolves by the referencing direction`() { + val conventions = tagConventions( + """ + span_types: + db.client: {span-kind: client, tags: [{ref: peer.port, required: required}]} + mixins: + outbound_peer: {span-kind: client, tags: [{dd-name: peer.port, type: int}]} + inbound_peer: {span-kind: server, tags: [{dd-name: peer.port, type: int}]} + """ + ) + + assertThat(conventions.resolve("db.client").map { it.name to it.required }) + .containsExactly("peer.port@outbound" to "required") + } + + @Test + fun `span-kind-neutral widens a typed rename to every direction`() { + val conventions = tagConventions( + """ + span_types: + db.client: + span-kind: client + tags: [{dd-name: db.type, otel-name: db.system, span-kind-neutral: true}] + """ + ) + + val dbType = TagRegistry.build(conventions).tags.single() + + assertThat(dbType.otelName).isEqualTo("db.system") + assertThat(dbType.otelDirection).isNull() + } + + @TableTest( + """ + scenario | domain | message + same name in one direction | span_types: {a: {span-kind: server, tags: [{dd-name: x, otel-name: o}]}, b: {span-kind: consumer, tags: [{dd-name: y, otel-name: o}]}} | claimed by both 'x' and 'y' on inbound spans + neutral contradicts scoped | span_types: {a: {span-kind: server, tags: [{dd-name: x, otel-name: o, span-kind-neutral: true}]}, b: {span-kind: client, tags: [{dd-name: y, otel-name: o}]}} | on outbound spans + map-form otel-name | span_types: {a: {span-kind: client, tags: [{dd-name: x, otel-name: {outbound: o}}]}} | invalid otel-name + mixin of other direction | '{span_types: {c: {span-kind: client, include: [m]}}, mixins: {m: {span-kind: server}}}' | receives mixin 'm', which is inbound + mixin on undirected type | '{span_types: {c: {include: [m]}}, mixins: {m: {span-kind: server}}}' | (no span-kind) receives mixin 'm' + child changes direction | '{span_types: {p: {abstract: true, span-kind: client}, c: {extends: p, span-kind: server}}}' | span type 'c' (inbound) changes the direction it inherits from 'p' (outbound) + inherited ref other side | '{span_types: {p: {span-kind: client, tags: [{ref: x}]}, c: {extends: p, span-kind: server, include: [i]}}, mixins: {o: {span-kind: client, tags: [{dd-name: x}]}, i: {span-kind: server, tags: [{dd-name: x}]}}}' | changes the direction it inherits from 'p' + neutral per-direction tag | mixins: {a: {span-kind: server, tags: [{dd-name: x, otel-name: o}]}, b: {span-kind: client, tags: [{dd-name: x, otel-name: p, span-kind-neutral: true}]}} | tag 'x' is declared per direction, so its otel-name 'p' cannot be span-kind-neutral + repeated in one direction | mixins: {a: {span-kind: server, tags: [{dd-name: x}]}, b: {span-kind: consumer, tags: [{dd-name: x}]}} | declared in both + repeated without direction | '{span_types: {base: {abstract: true, tags: [{dd-name: x}]}}, mixins: {m: {span-kind: server, tags: [{dd-name: x}]}}}' | declared in both + ref without matching side | '{span_types: {t: {span-kind: internal, tags: [{ref: x}]}}, mixins: {a: {span-kind: server, tags: [{dd-name: x}]}, b: {span-kind: client, tags: [{dd-name: x}]}}}' | declared per direction, but has no matching direction + """ + ) + fun `direction rules for OpenTelemetry names are enforced`(domain: String, message: String) { + val yaml = directory.conventionsFile(domain) + + assertThatIllegalArgumentException() + .isThrownBy { TagRegistryGenerator.generate(yaml, File(directory, "generated")) } + .withMessageContaining(message) + } + + @Test + fun `a declared name that looks like a derived identity is a separate tag`() { + val conventions = tagConventions( + """ + span_types: + http.client: {span-kind: client, include: [outbound_peer]} + http.server: {span-kind: server, include: [inbound_peer]} + mixins: + outbound_peer: {span-kind: client, tags: [{dd-name: x}]} + inbound_peer: {span-kind: server, tags: [{dd-name: x}]} + literal: {tags: [{dd-name: x@inbound}]} + """ + ) + + assertThat(TagRegistry.build(conventions).tags.map { it.identity }) + .containsExactlyInAnyOrder( + TagConventions.TagIdentity("x", TagConventions.Direction.INBOUND), + TagConventions.TagIdentity("x", TagConventions.Direction.OUTBOUND), + TagConventions.TagIdentity("x@inbound"), + ) + } + + @Test + fun `a declared name containing @ is allowed when it collides with no derived identity`() { + val conventions = tagConventions( + """ + span_types: + http.client: {span-kind: client, include: [outbound_peer]} + http.server: {span-kind: server, include: [inbound_peer]} + mixins: + outbound_peer: {span-kind: client, tags: [{dd-name: peer.port}]} + inbound_peer: {span-kind: server, tags: [{dd-name: peer.port}]} + misc: {tags: [{dd-name: user@domain}]} + """ + ) + + assertThat(TagRegistry.build(conventions).tags.map { it.name }) + .containsExactlyInAnyOrder("peer.port@inbound", "peer.port@outbound", "user@domain") + } + @Test fun `span-kind-neutral without an otel-name fails`() { val yaml = directory.conventionsFile( diff --git a/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java index ecd9bb61e50..f2022391203 100644 --- a/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -41,7 +42,6 @@ static Stream knownTags() { Arguments.of(Tags.PEER_HOSTNAME, KnownTags.PEER_HOSTNAME_ID), Arguments.of(Tags.PEER_HOST_IPV4, KnownTags.PEER_IPV4_ID), Arguments.of(Tags.PEER_HOST_IPV6, KnownTags.PEER_IPV6_ID), - Arguments.of(Tags.PEER_PORT, KnownTags.PEER_PORT_ID), Arguments.of(Tags.COMPONENT, KnownTags.COMPONENT_ID), Arguments.of(Tags.SPAN_KIND, KnownTags.SPAN_KIND_ID), Arguments.of(DDTags.LANGUAGE_TAG_KEY, KnownTags.LANGUAGE_ID), @@ -159,6 +159,19 @@ void unknownNamesResolveToZero() { assertEquals(0L, KnownTagCodec.keyOf("")); } + /** + * {@code peer.port} means the server's port on outbound spans and the client's on inbound ones, + * so it is one tag per direction. Both share the Datadog name, which therefore resolves to no tag + * until name resolution knows the span's direction. + */ + @Test + void aNameDeclaredPerDirectionIsOneTagPerDirection() { + assertEquals(Tags.PEER_PORT, KnownTagCodec.nameOf(KnownTags.PEER_PORT_INBOUND_ID)); + assertEquals(Tags.PEER_PORT, KnownTagCodec.nameOf(KnownTags.PEER_PORT_OUTBOUND_ID)); + assertNotEquals(KnownTags.PEER_PORT_INBOUND_ID, KnownTags.PEER_PORT_OUTBOUND_ID); + assertEquals(0L, KnownTagCodec.keyOf(Tags.PEER_PORT)); + } + @Test void unknownIdsResolveToNullName() { assertNull(KnownTagCodec.nameOf(0L)); diff --git a/tag-conventions.yaml b/tag-conventions.yaml index f35498e980b..b6cdfe5deb4 100644 --- a/tag-conventions.yaml +++ b/tag-conventions.yaml @@ -16,6 +16,10 @@ # include — a span type PULLS in a mixin it intrinsically has (has-a; core-owned). # applies — a mixin PUSHES itself onto span types, gated by `enabled_by`. # resolved_tags(type) = own + extends-chain (incl base) + included mixins + applied mixins (de-duped). +# A span type may declare `span-kind` (server|client|producer|consumer|internal), inherited through +# `extends`. It sets the type's DIRECTION: server/consumer spans are inbound, client/producer spans are +# outbound, internal spans have none. A mixin may declare one too: a directional mixin may only reach +# span types of its direction. # # tag fields (DOMAIN only): dd-name | type (string|int|long|boolean|double) # | required (required|conditional|recommended|optional|opt_in) | otel-name. @@ -31,13 +35,24 @@ # A tag is one identity across span types/mixins, so it is DECLARED exactly once — a second declaration # fails the build. Put a tag shared by several span types on their common parent or a mixin; any other # span type that carries it uses `{ ref: , required: }`, which may override only -# `required` (type and otel-name come from the one declaration). A span-kind-dependent mapping is a -# derivation, not a rename. -# An otel-name is canonicalized to its tag on EVERY span kind, so a rename declared on a concrete span -# type must also set `span-kind-neutral: true`: the author's assertion that the OpenTelemetry attribute -# means this tag wherever OTel uses it. (Renames in a shared scope -- an abstract parent or a mixin -- -# need no flag.) OTel's server.address and network.peer.address fail that test: on a client span they -# name the remote server. +# `required` (type and otel-name come from the one declaration). +# OpenTelemetry names can depend on direction: `server.address` is the local host on an inbound span +# but the remote one on an outbound span. So an OpenTelemetry name is unique PER DIRECTION, not +# globally, and 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, like peer.port (the server's port on an outbound span, the +# client's on an inbound one), is declared once per direction, each in a directional mixin. Each +# declaration becomes its own tag, with fixed names in both namespaces; the Datadog name is shared. +# (The generator labels these tags `@` in its reports; that label is not YAML +# syntax.) Any other repeated declaration is still an error. +# Until name resolution knows a span's direction, only renames that apply in every direction are used. +# A directional rename, including one under `span-kind: internal` (spans with no direction), is +# listed in tag-assignment.txt but not yet applied. +# `span-kind-neutral: true` widens a directional rename to every direction -- the author's assertion +# that OTel only uses that name for this tag (db.system, say, only ever names a database). It is +# interim, and goes away once resolution is direction-aware. A rename on a concrete type with no +# span-kind still requires it. # The id coordinate (group-decl / field-decl) is NOT authored here — the generator assigns it: each # declaration source (the trace-level tier, each span type, each mixin) is a group, and within a # group `field-decl` numbers the dense (required/conditional/recommended) tags; the rest are @@ -91,25 +106,31 @@ span_types: http.server: extends: http + span-kind: server + include: [ peer_address, inbound_peer ] tags: - { dd-name: http.route, type: string, required: conditional } # passes through: dd-name already is the OTel name - - { dd-name: http.hostname, type: string, required: required } # not a rename: OTel server.address is the remote server on client spans; needs the span-kind-aware derivation layer + - { dd-name: http.hostname, type: string, required: required, otel-name: server.address } # inbound only; on outbound spans server.address is peer.hostname - { dd-name: http.useragent, type: string, required: recommended, otel-name: user_agent.original, span-kind-neutral: true } - { dd-name: http.query.string, type: string, required: recommended } # not a rename: url.query is also carried inside url.full, while http.url excludes the query (QueryObfuscator re-appends it) - { dd-name: servlet.path, type: string, required: optional } - { dd-name: servlet.context, type: string, required: optional } - { dd-name: http.client_ip, type: string, required: recommended, otel-name: client.address, span-kind-neutral: true } - - { dd-name: network.client.ip, type: string, required: recommended } # not a rename: OTel network.peer.address is the remote end on every span kind (the server on client spans) + # Inbound only: the client's socket address. Server spans also carry that address in peer.ipv4 / + # peer.ipv6, so do not map those inbound too, or the attribute is exported twice. + - { dd-name: network.client.ip, type: string, required: recommended, otel-name: network.peer.address } http.client: extends: http - include: [ peer ] + span-kind: client + include: [ peer, peer_address, outbound_peer ] tags: - { dd-name: http.resend_count, type: int, required: recommended } db.client: extends: base - include: [ peer ] + span-kind: client + include: [ peer, peer_address, outbound_peer ] tags: - { dd-name: db.type, type: string, required: required, otel-name: db.system, span-kind-neutral: true } - { dd-name: db.instance, type: string, required: recommended } # TODO(otel): db.namespace @@ -120,20 +141,43 @@ span_types: view.render: extends: base + span-kind: internal tags: - { dd-name: view.name, type: string, required: recommended } mixins: - # peer — outbound/remote-peer capability, PULLED via `include` by client span types. + # peer — the downstream service a client span calls, PULLED via `include` by client span types. peer: tags: - { dd-name: peer.service, type: string, required: recommended } - { dd-name: _dd.peer.service.source, type: string, required: recommended } - { dd-name: _dd.peer.service.remapped_from, type: string, required: recommended } - - { dd-name: peer.hostname, type: string, required: recommended } - - { dd-name: peer.ipv4, type: string } - - { dd-name: peer.ipv6, type: string } - - { dd-name: peer.port, type: int } + + # peer_address — the other end's IP address, on both client and server spans. + peer_address: + tags: + # TODO(otel): both are network.peer.address, split by address family -- input needs the value to + # pick one, which the registry cannot model. Planned: a `resolved-by: ` marker that names + # the layer responsible (e.g. otel-api) and allows the mutually exclusive shared output name. + - { dd-name: peer.ipv4, type: string } + - { dd-name: peer.ipv6, type: string } + + # outbound_peer / inbound_peer declare peer.port once per direction. `peer.port` is then a SHARED + # Datadog name for two tags (reported as peer.port@outbound and peer.port@inbound): emitting it needs no context, + # but resolving the bare name needs the span's direction. + + # outbound_peer — the other end of an outbound connection: the server. + outbound_peer: + span-kind: client + tags: + - { dd-name: peer.hostname, type: string, required: recommended, otel-name: server.address } + - { dd-name: peer.port, type: int, otel-name: server.port } + + # inbound_peer — the other end of an inbound connection: the client. + inbound_peer: + span-kind: server + tags: + - { dd-name: peer.port, type: int, otel-name: client.port } # ci_visibility — per-span test tags. Its capability flag (_dd.civisibility.enabled) lives in # trace_level, outside this mixin (general rule: capability flags are trace-level, mixins hold the