From 1042303ca4ce7130d2f8e6f9075581568d892d8d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 2 Sep 2026 08:50:03 -0400 Subject: [PATCH 1/5] Add the tag registry: generated tag ids + name resolution Introduces a language-agnostic tag registry. tag-conventions.yaml declares each known tag once; a buildSrc Gradle plugin generates KnownTags.java from it into internal-api/src/generated, committed and on the main compile path, with a verifyKnownTags task wired into check so a stale checkout fails the build rather than drifting. A tag id is IDENTITY, not storage: a globally unique 16-bit serial in bits [63-48] plus the trace/span level bit. It says what a tag is, never how it is stored or set. Bits [47-32] are documented and held vacant for the co-occurrence slot the dense tag store assigns by graph coloring, so that store lands as a purely additive change. KnownTagCodec is the hand-written half of the registry -- bit layout and naming policy -- and generated KnownTags is the data half. Java has no partial classes, so the codec's Installed holder names KnownTags.RESOLVER directly: reading a tag name is what initializes the registry, with no registration call and no ordering to get wrong. The holder keeps the resolver in a static final of an initialized class, which the JIT constant-folds to an exact klass, so keyOf/nameOf devirtualize and inline with no lock, volatile read, null check or virtual call. The nesting is load-bearing: KnownTags calls back into the codec, so a field on the codec itself would re-enter a half-initialized class. keyOf is many-to-one -- a Datadog name or an OpenTelemetry name both resolve to the one id -- and datadogNameOf/openTelemetryNameOf take the name back out per namespace. openTelemetryTagOf is the single home for the pass-through policy (declared rename, else the Datadog name), so no serializer re-decides it. Co-Authored-By: Claude Opus 5 --- buildSrc/build.gradle.kts | 6 + .../plugin/tags/GenerateKnownTagsTask.kt | 35 ++ .../gradle/plugin/tags/KnownTagsEmitter.kt | 160 ++++++ .../gradle/plugin/tags/TagConventions.kt | 249 +++++++++ .../datadog/gradle/plugin/tags/TagRegistry.kt | 87 +++ .../plugin/tags/TagRegistryGenerator.kt | 80 +++ .../plugin/tags/TagRegistryGeneratorPlugin.kt | 38 ++ .../gradle/plugin/tags/VerifyKnownTagsTask.kt | 64 +++ gradle/spotless.gradle | 3 +- internal-api/build.gradle.kts | 15 + .../java/datadog/trace/api/KnownTags.java | 526 ++++++++++++++++++ internal-api/src/generated/resolved-tags.txt | 81 +++ internal-api/src/generated/tag-assignment.txt | 63 +++ .../java/datadog/trace/api/KnownTagCodec.java | 148 +++++ tag-conventions.yaml | 152 +++++ 15 files changed, 1706 insertions(+), 1 deletion(-) create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/KnownTagsEmitter.kt create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGeneratorPlugin.kt create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/VerifyKnownTagsTask.kt create mode 100644 internal-api/src/generated/java/datadog/trace/api/KnownTags.java create mode 100644 internal-api/src/generated/resolved-tags.txt create mode 100644 internal-api/src/generated/tag-assignment.txt create mode 100644 internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java create mode 100644 tag-conventions.yaml diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index b31a2fde05c..19bfe847ce7 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -59,6 +59,11 @@ gradlePlugin { implementationClass = "datadog.gradle.plugin.config.SupportedConfigPlugin" } + create("tag-registry-generator") { + id = "dd-trace-java.tag-registry-generator" + implementationClass = "datadog.gradle.plugin.tags.TagRegistryGeneratorPlugin" + } + create("supported-config-linter") { id = "dd-trace-java.config-inversion-linter" implementationClass = "datadog.gradle.plugin.config.ConfigInversionLinter" @@ -107,6 +112,7 @@ dependencies { implementation("com.fasterxml.jackson.core:jackson-databind") implementation("com.fasterxml.jackson.core:jackson-annotations") implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") compileOnly(libs.develocity) diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt new file mode 100644 index 00000000000..fadac53acf9 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt @@ -0,0 +1,35 @@ +package datadog.gradle.plugin.tags + +import javax.inject.Inject +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Generates the committed tag registry (KnownTags.java + assignment reports) from the language-agnostic + * {@code tag-conventions.yaml}. The actual emit lives in [TagRegistryGenerator]; + * this task just wires the inputs/outputs so Gradle can cache and up-to-date-check it. + */ +@CacheableTask +abstract class GenerateKnownTagsTask @Inject constructor(objects: ObjectFactory) : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + val domainYaml: RegularFileProperty = objects.fileProperty() + + + @get:OutputDirectory val destinationDirectory: DirectoryProperty = objects.directoryProperty() + + @TaskAction + fun generate() { + val outDir = destinationDirectory.get().asFile + TagRegistryGenerator.generate(domainYaml.get().asFile, outDir) + logger.lifecycle("tag-registry: generated -> $outDir") + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/KnownTagsEmitter.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/KnownTagsEmitter.kt new file mode 100644 index 00000000000..1e5a80992e0 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/KnownTagsEmitter.kt @@ -0,0 +1,160 @@ +package datadog.gradle.plugin.tags + +import java.util.Locale + +/** + * Emits the generated `KnownTags.java` from a [TagRegistry]. Public API first — per-tag + * `_NAME` (string) + `_ID` (encoded long, literal) couplets with a trailing `// makeTagId(...)` + * derivation comment — then the package-private `_SERIAL_NUM` constants, the + * `StringIndex.EmbeddingSupport` keyOf table, the `serialNum` switch `nameOf`, and resolver + * registration. + */ +object KnownTagsEmitter { + + fun emit(reg: TagRegistry, pkg: String, className: String): String { + // Sanitize tag names into unique Java constant identifiers. + val used = HashSet() + val cname = HashMap() + fun mk(name: String): String { + var c = name.uppercase().replace(Regex("[^A-Za-z0-9]"), "_").replace(Regex("_+"), "_").trim('_') + if (c.isEmpty() || c[0].isDigit()) c = "T_$c" + var u = c + var n = 2 + while (u in used) { + u = "${c}_$n"; n++ + } + used.add(u) + cname[name] = u + return u + } + reg.tags.forEach { mk(it.name) } + + // Constant names. Collapse a duplicated trailing token so e.g. "resource.name" yields NAME + // (not NAME_NAME) and "_dd.parent_id" yields ID (not ID_ID); the non-duplicating pairs + // (ID + _NAME -> ID_NAME, NAME + _ID -> NAME_ID) are kept as-is. + fun withSuffix(base: String, suffix: String) = if (base.endsWith(suffix)) base else "$base$suffix" + fun nameC(name: String) = withSuffix(cname[name]!!, "_NAME") + fun idC(name: String) = withSuffix(cname[name]!!, "_ID") + fun serialC(name: String) = withSuffix(cname[name]!!, "_SERIAL_NUM") + + val order = reg.tags.map { it.name } // stable emit order + // canonical name -> OpenTelemetry name, for the reverse (openTelemetryNameOf) switch. + val otelName = reg.tags.mapNotNull { t -> t.otelName?.let { t.name to it } }.toMap() + val b = StringBuilder() + b.appendLine("package $pkg;") + b.appendLine() + b.appendLine("import datadog.trace.util.StringIndex;") + b.appendLine() + b.appendLine("// GENERATED by the tag-registry code generator (dd-trace-java.tag-registry-generator).") + b.appendLine("// DO NOT EDIT. Source: tag-conventions.yaml.") + b.appendLine("public final class $className {") + b.appendLine() + + // 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. + b.appendLine(" // ---- tags ----") + for (t in reg.tags) { + b.appendLine(" public static final String ${nameC(t.name)} = \"${t.name}\";") + b.appendLine(" public static final long ${idC(t.name)} = ${hex(t.id)};") + b.appendLine(" // makeTagId(serial=${t.serial})${if (t.traceLevel) " + trace-level" else ""}${if (t.otelName != null) " -> ${t.otelName}" else ""} <${t.required}>") + b.appendLine() + } + + // Serial numbers (globalSerial per tag) — package-private, consumed by the resolver switch. + b.appendLine(" // ---- serial numbers ----") + for (t in reg.tags) { + b.appendLine(" static final int ${serialC(t.name)} = ${t.serial};") + } + b.appendLine() + + // OpenTelemetry name -> canonical tag name, for the tags that declare a DISTINCT one. A same-name + // dual (otel-name == dd-name) is already resolvable via the canonical row, so it is skipped here + // to keep the keyOf table free of redundant entries. Deterministic order (by OTel name) so + // output stays byte-identical. + val otelByCanonical = + reg.tags + .mapNotNull { t -> t.otelName?.let { it to t.name } } + .filter { (otel, canonical) -> otel != canonical } + .sortedBy { it.first } + + // keyOf table (open-addressed, via StringIndex.EmbeddingSupport). Canonical names first, then + // OpenTelemetry names -- an OTel name resolves to its canonical tag's id (there is no distinct id + // for it), so keyOf(otelName) == keyOf(canonical); nameOf still returns the canonical name. + b.appendLine(" private static final String[] KEYOF_NAMES = {") + order.forEach { b.appendLine(" ${nameC(it)},") } + otelByCanonical.forEach { (otel, _) -> b.appendLine(" \"$otel\",") } + b.appendLine(" };") + b.appendLine(" private static final long[] KEYOF_VALUES = {") + order.forEach { b.appendLine(" ${idC(it)},") } + otelByCanonical.forEach { (_, canonical) -> b.appendLine(" ${idC(canonical)},") } + b.appendLine(" };") + b.appendLine(" private static final int[] KEYOF_HASHES;") + b.appendLine(" private static final String[] KEYOF_KEYS;") + b.appendLine(" private static final long[] KEYOF_IDS;") + b.appendLine() + b.appendLine(" static {") + b.appendLine(" StringIndex.Data data = StringIndex.EmbeddingSupport.create(KEYOF_NAMES);") + b.appendLine(" long[] ids = new long[data.names.length];") + b.appendLine(" for (int j = 0; j < KEYOF_NAMES.length; j++) {") + b.appendLine(" ids[StringIndex.EmbeddingSupport.indexOf(data.hashes, data.names, KEYOF_NAMES[j])] =") + b.appendLine(" KEYOF_VALUES[j];") + b.appendLine(" }") + b.appendLine(" KEYOF_HASHES = data.hashes;") + b.appendLine(" KEYOF_KEYS = data.names;") + b.appendLine(" KEYOF_IDS = ids;") + b.appendLine(" }") + b.appendLine() + + // Resolver. KnownTagCodec.Installed links to this field directly, so merely resolving a tag + // name initializes this class -- there is no registration step and no ordering to get wrong. + b.appendLine(" /**") + b.appendLine( + " * The registry's name↔id tables, as a {@link KnownTagCodec.Resolver}. {@code KnownTagCodec}") + b.appendLine( + " * reads this field from its own holder, so the two classes complete each other: the codec owns") + b.appendLine( + " * the bit layout and the naming policy, this class owns the data. Nothing has to be called first.") + b.appendLine(" */") + b.appendLine(" static final KnownTagCodec.Resolver RESOLVER =") + b.appendLine(" new KnownTagCodec.Resolver() {") + b.appendLine(" @Override") + b.appendLine(" public String nameOf(long tagId) {") + b.appendLine(" switch (KnownTagCodec.serialNum(tagId)) {") + for (name in order) { + b.appendLine(" case ${serialC(name)}:") + b.appendLine(" return ${nameC(name)};") + } + b.appendLine(" default:") + b.appendLine(" return null;") + b.appendLine(" }") + b.appendLine(" }") + b.appendLine() + // openTelemetryNameOf: canonical id -> OTel-namespace name, null when the tag has none. The + // caller (a serializer) owns any fall-back-to-Datadog-name policy; this stays a pure lookup. + b.appendLine(" @Override") + b.appendLine(" public String openTelemetryNameOf(long tagId) {") + b.appendLine(" switch (KnownTagCodec.serialNum(tagId)) {") + for (name in order) { + val otel = otelName[name] ?: continue + b.appendLine(" case ${serialC(name)}:") + b.appendLine(" return \"$otel\";") + } + b.appendLine(" default:") + b.appendLine(" return null;") + b.appendLine(" }") + b.appendLine(" }") + b.appendLine() + b.appendLine(" @Override") + b.appendLine(" public long keyOf(String name) {") + b.appendLine(" int slot = StringIndex.EmbeddingSupport.indexOf(KEYOF_HASHES, KEYOF_KEYS, name);") + b.appendLine(" return slot < 0 ? 0L : KEYOF_IDS[slot];") + b.appendLine(" }") + b.appendLine(" };") + b.appendLine() + b.appendLine(" private $className() {}") + b.appendLine("}") + return b.toString() + } + + private fun hex(id: Long): String = "0x%016XL".format(Locale.ROOT, id) +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt new file mode 100644 index 00000000000..65da0844f04 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt @@ -0,0 +1,249 @@ +package datadog.gradle.plugin.tags + +/** + * Parsed tag-conventions domain model + the per-type tag-set resolver. Language-agnostic: it knows + * only structure (extends / include / applies) and per-tag semantics (name / type / required / + * source). Id assignment and emission are layered on top of the resolved sets. + */ +class TagConventions +private constructor( + private val spanTypes: Map, + private val mixins: Map, + private val traceLevel: List, +) { + /** A tag declaration (domain semantics only). */ + data class Tag( + val name: String, + val type: String, + val required: String, + /** + * The tag's OpenTelemetry-namespace RENAME, or null when it has none. otel-name is optional and + * tri-state in the YAML: absent => the OpenTelemetry name is implicitly the dd-name (pass-through + * under the Datadog name; the RFC "retain" default) and this field is null; a name => a rename to + * that OpenTelemetry-namespace name; the literal `none` => Datadog-only (no OpenTelemetry name) + * and this field is null — a reserved value with no tags today (suppression is a follow-on), so + * it currently behaves as pass-through, indistinguishable from absent. keyOf resolves a rename + * to this tag's canonical id (inbound, many->one); openTelemetryNameOf recovers it (outbound). + */ + val otelName: String? = null, + ) + + data class SpanType( + val name: String, + val abstract: Boolean, + val extends: String?, + val include: List, + val tags: List, + ) + + data class Mixin( + val name: String, + val appliesAll: Boolean, + val appliesTo: Set, + val tags: List, + ) + + /** Concrete (instantiable) span types — the ones a layout is computed for. */ + fun concreteTypes(): List = + spanTypes.values.filter { !it.abstract }.map { it.name }.sorted() + + /** + * resolved(type) = own tags + tags up the `extends` chain (incl. base) + tags of every mixin the + * type or an ancestor `include`s + tags of every mixin whose `applies` matches. De-duped by tag + * name (first occurrence wins). Base-first order, so it is stable across runs. + */ + fun resolve(typeName: String): List { + val result = LinkedHashMap() + fun add(t: Tag) = result.putIfAbsent(t.name, t) + + val chain = ArrayList() + var cur: SpanType? = spanTypes[typeName] + while (cur != null) { + chain.add(cur) + cur = cur.extends?.let { spanTypes[it] } + } + for (st in chain.asReversed()) { + st.tags.forEach { add(it) } + for (mixinName in st.include) mixins[mixinName]?.tags?.forEach { add(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) } + } + return result.values.toList() + } + + /** The explicit trace-level tier tags (their own TagMap "type" on the TraceSegment). */ + fun traceLevelTags(): List = traceLevel + + /** A declaration group: the source that *declares* a set of tags (its own `tags:` list). */ + data class Group(val name: String, val kind: String, val tags: List) + + /** + * The declaration groups, in a stable order: the trace-level tier first, then every span type + * (abstract included — `base`/`http` declare real tags) sorted by name, then every mixin sorted by + * name. Each maps to one `group-decl`. A tag is *declared* once (in its own container's `tags:`); + * the same tag reached via extends/include/applies is not re-declared, so first-declaration (in + * this order) is its home group. Groups with no declared tags are omitted. + */ + fun declarationGroups(): List { + val groups = ArrayList() + if (traceLevel.isNotEmpty()) groups.add(Group(TRACE_LAYER, "trace", traceLevel)) + for (name in spanTypes.keys.sorted()) { + val st = spanTypes.getValue(name) + if (st.tags.isNotEmpty()) groups.add(Group(name, "span_type", st.tags)) + } + for (name in mixins.keys.sorted()) { + val mx = mixins.getValue(name) + if (mx.tags.isNotEmpty()) groups.add(Group(name, "mixin", mx.tags)) + } + return groups + } + + /** Full stored-tag universe (concrete span types' resolves + trace-level), de-duped by name. */ + fun allStoredTags(): List { + val union = LinkedHashMap() + for (type in concreteTypes()) for (t in resolve(type)) union.putIfAbsent(t.name, t) + for (t in traceLevel) union.putIfAbsent(t.name, t) + return union.values.toList() + } + + /** + * Full composition for a type as (origin, tag) pairs, in composition order and NOT de-duped, so a + * tag contributed by more than one source shows up more than once. Origin is the contributing + * span type (via extends), `incl:` (via include), or `appl:` (via applies). + */ + fun compose(typeName: String): List> { + val out = ArrayList>() + val chain = ArrayList() + var cur: SpanType? = spanTypes[typeName] + while (cur != null) { + chain.add(cur) + cur = cur.extends?.let { spanTypes[it] } + } + for (st in chain.asReversed()) { + st.tags.forEach { out.add(st.name to it) } + for (mixinName in st.include) mixins[mixinName]?.tags?.forEach { out.add("incl:$mixinName" to 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 { out.add("appl:${mx.name}" to it) } + } + } + return out + } + + companion object { + /** Group name of the trace-level tier (its own TagMap layer on the TraceSegment). */ + const val TRACE_LAYER = "" + + @Suppress("UNCHECKED_CAST") + fun parse(root: Map): TagConventions { + val spanTypesRaw = (root["span_types"] as? Map) ?: emptyMap() + val spanTypes = + spanTypesRaw.mapValues { (name, v) -> + val m = v as Map + SpanType( + name = name, + abstract = (m["abstract"] as? Boolean) ?: false, + extends = m["extends"] as? String, + include = (m["include"] as? List) ?: emptyList(), + tags = tagList(m["tags"]), + ) + } + + val mixinsRaw = (root["mixins"] as? Map) ?: emptyMap() + val mixins = + mixinsRaw.mapValues { (name, v) -> + val m = v as Map + val applies = m["applies"] + Mixin( + name = name, + appliesAll = applies == "all", + appliesTo = if (applies is List<*>) applies.map { it.toString() }.toSet() else emptySet(), + tags = tagList(m["tags"]), + ) + } + + // Trace-level tags pass through under their Datadog name for now; their OTel mapping (resource + // attributes) is a follow-on. TODO(otel follow-on). + val traceLevel = tagList((root["trace_level"] as? Map)?.get("tags")) + validateOtelNameConsistency(spanTypes, mixins, traceLevel) + return TagConventions(spanTypes, mixins, traceLevel) + } + + /** + * A tag is de-duped by name across span types / mixins (see [resolve] / [allStoredTags]), so its + * whole identity — including the OpenTelemetry name — must be declared consistently everywhere it + * appears. `http.url` on `http.server` and `http.client`, for instance, is ONE tag: it can carry + * exactly one otel-name. Without this check, two conflicting declarations would silently collapse + * to whichever the dedup happened to keep. Fail the build loudly instead. (A span-kind-dependent + * mapping is a derivation, not a rename, and belongs to the derivation layer — not two otel-names + * on one identity.) + */ + private fun validateOtelNameConsistency( + spanTypes: Map, + mixins: Map, + traceLevel: List, + ) { + val declared = HashMap() // name -> otelName from its first declaration + val declaredKeys = HashSet() + val check = { t: Tag -> + if (declaredKeys.add(t.name)) { + declared[t.name] = t.otelName + } else { + require(declared[t.name] == t.otelName) { + "tag '${t.name}' declares conflicting otel-name: '${declared[t.name] ?: "none"}' vs " + + "'${t.otelName ?: "none"}'. A tag is one identity across span types/mixins and may " + + "carry only one otel-name; a span-kind-dependent mapping belongs to the derivation layer." + } + } + } + spanTypes.values.forEach { it.tags.forEach(check) } + mixins.values.forEach { it.tags.forEach(check) } + traceLevel.forEach(check) + } + + @Suppress("UNCHECKED_CAST") + private fun tagList(tags: Any?): List = + (tags as? List>)?.map { m -> + Tag( + name = parseDdName(m), + type = (m["type"] as? String) ?: "string", + required = (m["required"] as? String) ?: "optional", + otelName = parseOtelName(m), + ) + } ?: emptyList() + + /** + * The mandatory `dd-name` of one tag -- its canonical Datadog name, and the key everything else + * hangs off. A missing key or a non-string value must fail the build: `toString()` on it would + * yield the literal "null" (or a number's rendering), which then flows on as a real tag name and + * gets an id, a slot and an entry in the generated registry. A typo here is silent otherwise. + */ + private fun parseDdName(m: Map): String { + val raw = m["dd-name"] + require(raw is String && raw.isNotBlank()) { "tag declaration has no valid dd-name: $m" } + return raw + } + + /** + * Parse the optional, tri-state `otel-name` of one tag. Absent (key not present) => implicit + * dd-name (pass-through) => null; the literal `none` => Datadog-only (reserved) => null; any other + * non-blank string => a rename => that value. A present-but-invalid value (empty/blank, or a + * non-string such as a number or an unquoted YAML `null`) is a typo that would otherwise slip + * through the `as? String` cast into a silent pass-through or an empty rename — fail the build + * loudly instead. + */ + private fun parseOtelName(m: Map): String? { + if (!m.containsKey("otel-name")) return null // absent => pass-through + 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." + } + return raw.takeUnless { it == "none" } + } + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt new file mode 100644 index 00000000000..726295a1203 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt @@ -0,0 +1,87 @@ +package datadog.gradle.plugin.tags + +/** + * Assigns tag ids from a parsed [TagConventions]. The id encoding mirrors KnownTagCodec: [63-48 + * serial][47-32 reserved][31-0 flags]. + * + *

An id is IDENTITY only: a globally unique serial plus the trace-level classification bit. It + * carries no storage-layout coordinate -- bits [47-32] are held vacant for the co-occurrence slot + * that the dense tag store assigns by graph coloring, which lands with the dense store itself. + * Nothing here needs to know how (or whether) a tag is stored. + * + *

Nor does anything here know how a tag is SET. Whether the tracer intercepts a tag on the + * set-path (routing it to a span field or a sampling directive instead of tag storage) is a + * property of TagInterceptor, not of the tag's identity, and modelling it was the source of a whole + * class of drift between this registry and the interceptor's actual switch. It arrives with the + * work that consumes it -- the id->handler dispatch table that retires TagInterceptor -- where the + * interceptor can be the authority. Re-adding a classification bit then is purely additive. + */ +class TagRegistry private constructor(val tags: List) { + data class Tag( + val name: String, + val type: String, + val required: String, + val serial: Int, + val traceLevel: Boolean, + val id: Long, + val otelName: String? = null, + ) + + companion object { + const val FIRST_SERIAL = 1 + const val LEVEL_TRACE = 1L shl 2 // low-32 carve bit 2; mirrors KnownTagCodec.LEVEL_TRACE + const val TRACE_LAYER = "" + + /** + * Mirrors KnownTagCodec.makeTagId(serial) + traceLevel() -- must stay in sync. LEVEL_TRACE at + * bit 2, other low bits and the reserved [47-32] window zero. + */ + fun encode(serial: Int, traceLevel: Boolean): Long { + var id = serial.toLong() shl 48 + if (traceLevel) id = id or LEVEL_TRACE + return id + } + + fun build(conv: TagConventions): TagRegistry { + val traceNames = conv.traceLevelTags().map { it.name }.toSet() + + // Stable order (by name) so serials -- and therefore ids -- are a pure function of the input. + val tags = + conv.allStoredTags().sortedBy { it.name }.mapIndexed { i, t -> + val serial = FIRST_SERIAL + i + val traceLevel = t.name in traceNames + Tag( + t.name, + t.type, + t.required, + serial, + traceLevel, + id = encode(serial, traceLevel), + otelName = t.otelName) + } + + validateOtelNames(tags) + return TagRegistry(tags) + } + + /** + * 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. + */ + private fun validateOtelNames(tags: List) { + val canonical = tags.map { it.name }.toSet() + val owner = HashMap() + 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}'" + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt new file mode 100644 index 00000000000..8f85b6957bb --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt @@ -0,0 +1,80 @@ +package datadog.gradle.plugin.tags + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import java.io.File +import java.util.Locale + +/** + * Turns the language-agnostic {@code tag-conventions.yaml} into the generated tag registry: {@code KnownTags.java} (under {@code java/}) plus verification report dumps + * (resolved-tags / tag-assignment) at the destination root. + * + * Pure function of its inputs (deterministic ordering throughout), so the same inputs always produce + * byte-identical output -- which is what the {@code verifyKnownTags} freshness gate relies on. + */ +object TagRegistryGenerator { + /** Parses the conventions YAML and writes the full generated tree under [outDir]. */ + fun generate(domainYaml: File, outDir: File) { + val mapper = ObjectMapper(YAMLFactory()) + val domain: Map = + domainYaml.inputStream().use { + mapper.readValue(it, object : TypeReference>() {}) + } + + // Clear the owned destination tree first, so a report/source file retired by a later generator + // revision doesn't linger: otherwise verifyKnownTags flags it as stale while telling developers + // to rerun generateKnownTags, which (without this) can't actually remove it. + outDir.deleteRecursively() + outDir.mkdirs() + // KnownTags.java goes under java/ (added as a srcDir); the .txt reports sit at the root. + val javaPkg = File(outDir, "java/datadog/trace/api").apply { mkdirs() } + + val conv = TagConventions.parse(domain) + val reg = TagRegistry.build(conv) + + File(outDir, "resolved-tags.txt").writeText(resolvedReport(conv)) + File(outDir, "tag-assignment.txt").writeText(assignmentReport(reg)) + File(javaPkg, "KnownTags.java") + .writeText(KnownTagsEmitter.emit(reg, "datadog.trace.api", "KnownTags")) + } + + /** resolved-tags.txt — the per-type resolved sets (composition check). */ + private fun resolvedReport(conv: TagConventions): String { + val resolved = StringBuilder() + resolved.appendLine("# Resolved per-type tag sets (concrete span types).") + for (type in conv.concreteTypes()) { + val tags = conv.resolve(type) + resolved.appendLine() + resolved.appendLine("$type (${tags.size} tags):") + for (t in tags) resolved.appendLine(" - ${t.name}") + } + return resolved.toString() + } + + /** tag-assignment.txt — serials, ids, and the OpenTelemetry name mapping (identity check). */ + private fun assignmentReport(reg: TagRegistry): String { + val a = StringBuilder() + a.appendLine("# Tag id assignment. tags=${reg.tags.size}") + a.appendLine() + a.appendLine("# TAGS serial lvl id required name") + for (t in reg.tags) { + a.appendLine( + " %6d %s %-18s %-12s %s".format( + Locale.ROOT, + t.serial, + if (t.traceLevel) "T" else "-", + "0x%016X".format(Locale.ROOT, t.id), + t.required, + t.name)) + } + a.appendLine() + a.appendLine("# OPENTELEMETRY NAMES. keyOf(otelName) resolves to the canonical tag's id; nameOf still") + a.appendLine("# returns the Datadog name, openTelemetryNameOf returns the name below. (No distinct id.)") + val otelPairs = reg.tags.mapNotNull { t -> t.otelName?.let { it to t.name } }.sortedBy { it.first } + for ((otel, canonical) in otelPairs) { + a.appendLine(" %-30s -> %s".format(Locale.ROOT, otel, canonical)) + } + return a.toString() + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGeneratorPlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGeneratorPlugin.kt new file mode 100644 index 00000000000..79f20b3a35e --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGeneratorPlugin.kt @@ -0,0 +1,38 @@ +package datadog.gradle.plugin.tags + +import javax.inject.Inject +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory + +/** Extension configuring the tag-registry generator inputs/outputs. */ +abstract class TagRegistryExtension @Inject constructor(objects: ObjectFactory) { + val domainYaml: RegularFileProperty = objects.fileProperty() + val destinationDirectory: DirectoryProperty = objects.directoryProperty() +} + +/** + * Registers {@code generateKnownTags} (emits the committed tag registry) and {@code verifyKnownTags} + * (a freshness gate that regenerates and byte-compares against the committed output). The verify task + * is wired into {@code check} so stale generated sources fail CI. + */ +class TagRegistryGeneratorPlugin : Plugin { + override fun apply(project: Project) { + val ext = project.extensions.create("tagRegistry", TagRegistryExtension::class.java) + project.tasks.register("generateKnownTags", GenerateKnownTagsTask::class.java) { + domainYaml.set(ext.domainYaml) + destinationDirectory.set(ext.destinationDirectory) + } + val verify = + project.tasks.register("verifyKnownTags", VerifyKnownTagsTask::class.java) { + domainYaml.set(ext.domainYaml) + committedDirectory.set(ext.destinationDirectory) + } + // `check` is contributed by lifecycle-base (via java-library); wait for it before wiring. + project.pluginManager.withPlugin("lifecycle-base") { + project.tasks.named("check").configure { dependsOn(verify) } + } + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/VerifyKnownTagsTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/VerifyKnownTagsTask.kt new file mode 100644 index 00000000000..af64ce7271c --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/VerifyKnownTagsTask.kt @@ -0,0 +1,64 @@ +package datadog.gradle.plugin.tags + +import java.io.File +import javax.inject.Inject +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Freshness gate: regenerates the tag registry into a scratch dir and byte-compares it against the + * committed [committedDirectory]. Fails (pointing at {@code generateKnownTags}) if they differ, so a + * stale commit of the generated sources can't slip through CI. Not cacheable -- it must actually run + * the generator to catch drift, and it is cheap. + */ +abstract class VerifyKnownTagsTask @Inject constructor(objects: ObjectFactory) : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + val domainYaml: RegularFileProperty = objects.fileProperty() + + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + val committedDirectory: DirectoryProperty = objects.directoryProperty() + + @TaskAction + fun verify() { + val committed = committedDirectory.get().asFile + val scratch = File(temporaryDir, "generated") + scratch.deleteRecursively() + TagRegistryGenerator.generate(domainYaml.get().asFile, scratch) + + val diffs = ArrayList() + val freshFiles = scratch.walkTopDown().filter { it.isFile }.toList() + for (fresh in freshFiles) { + val rel = fresh.relativeTo(scratch).path + val committedFile = File(committed, rel) + when { + !committedFile.exists() -> diffs.add("missing (not committed): $rel") + committedFile.readText() != fresh.readText() -> diffs.add("out of date: $rel") + } + } + val freshRel = freshFiles.map { it.relativeTo(scratch).path }.toSet() + for (committedFile in committed.walkTopDown().filter { it.isFile }) { + val rel = committedFile.relativeTo(committed).path + if (rel !in freshRel) diffs.add("stale (no longer generated): $rel") + } + + if (diffs.isNotEmpty()) { + throw GradleException( + buildString { + appendLine("Generated tag registry is out of date with tag-conventions.yaml:") + diffs.forEach { appendLine(" - $it") } + append("Run `./gradlew :internal-api:generateKnownTags` and commit the result.") + }) + } + } +} diff --git a/gradle/spotless.gradle b/gradle/spotless.gradle index 91053fae160..8a9c68c3e78 100644 --- a/gradle/spotless.gradle +++ b/gradle/spotless.gradle @@ -50,7 +50,8 @@ spotless { // set explicit target to workaround https://github.com/diffplug/spotless/issues/1163 target 'src/**/*.java' // ignore embedded test projects and everything in build dir, e.g. generated sources - targetExclude('src/test/resources/**', buildDirectory) + // src/generated/** is emitted by code generators (e.g. the tag registry) — verified by their own freshness gate + targetExclude('src/test/resources/**', 'src/generated/**', buildDirectory) removeUnusedImports() forbidWildcardImports() tableTestFormatter(libs.versions.tabletest.formatter.get()) diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts index 42670eb6bcb..8298146aa09 100644 --- a/internal-api/build.gradle.kts +++ b/internal-api/build.gradle.kts @@ -5,6 +5,7 @@ plugins { `java-library` id("dd-trace-java.module.internal-api") id("dd-trace-java.jmh-conventions") + id("dd-trace-java.tag-registry-generator") } java { @@ -30,6 +31,9 @@ extra["minimumBranchCoverage"] = 0.7 extra["minimumInstructionCoverage"] = 0.8 extra["excludedClassesCoverage"] = listOf( + // Generated by the tag-registry code generator (verified fresh via verifyKnownTags). + "datadog.trace.api.KnownTags", + "datadog.trace.api.KnownTags.*", "datadog.trace.api.ClassloaderConfigurationOverrides", "datadog.trace.api.ClassloaderConfigurationOverrides.Lazy", // Interface @@ -262,6 +266,17 @@ extra["excludedClassesBranchCoverage"] = listOf( extra["excludedClassesInstructionCoverage"] = listOf("datadog.trace.util.stacktrace.StackWalkerFactory") +// Tag registry: generated KnownTags is committed under src/generated (audited via git); the srcDir +// puts it on the main compile path and `verifyKnownTags` (wired into `check`) fails CI if it drifts +// from tag-conventions.yaml. Generation is run on demand (`./gradlew :internal-api:generateKnownTags`), +// not on every build, so the committed source stays the source of truth for the compiler. +tagRegistry { + domainYaml.set(rootProject.layout.projectDirectory.file("tag-conventions.yaml")) + destinationDirectory.set(layout.projectDirectory.dir("src/generated")) +} + +sourceSets["main"].java.srcDir("src/generated/java") + dependencies { // references TraceScope and Continuation from public api api(project(":dd-trace-api")) diff --git a/internal-api/src/generated/java/datadog/trace/api/KnownTags.java b/internal-api/src/generated/java/datadog/trace/api/KnownTags.java new file mode 100644 index 00000000000..f1c9fa4bea1 --- /dev/null +++ b/internal-api/src/generated/java/datadog/trace/api/KnownTags.java @@ -0,0 +1,526 @@ +package datadog.trace.api; + +import datadog.trace.util.StringIndex; + +// GENERATED by the tag-registry code generator (dd-trace-java.tag-registry-generator). +// DO NOT EDIT. Source: tag-conventions.yaml. +public final class KnownTags { + + // ---- tags ---- + public static final String DD_APPSEC_ENABLED_NAME = "_dd.appsec.enabled"; + public static final long DD_APPSEC_ENABLED_ID = 0x0001000000000004L; + // makeTagId(serial=1) + trace-level + + public static final String DD_BASE_SERVICE_NAME = "_dd.base_service"; + public static final long DD_BASE_SERVICE_ID = 0x0002000000000004L; + // makeTagId(serial=2) + trace-level + + public static final String DD_CIVISIBILITY_ENABLED_NAME = "_dd.civisibility.enabled"; + public static final long DD_CIVISIBILITY_ENABLED_ID = 0x0003000000000004L; + // makeTagId(serial=3) + trace-level + + public static final String DD_DJM_ENABLED_NAME = "_dd.djm.enabled"; + public static final long DD_DJM_ENABLED_ID = 0x0004000000000004L; + // makeTagId(serial=4) + trace-level + + public static final String DD_DSM_ENABLED_NAME = "_dd.dsm.enabled"; + public static final long DD_DSM_ENABLED_ID = 0x0005000000000004L; + // makeTagId(serial=5) + trace-level + + public static final String DD_GIT_COMMIT_SHA_NAME = "_dd.git.commit.sha"; + public static final long DD_GIT_COMMIT_SHA_ID = 0x0006000000000004L; + // makeTagId(serial=6) + trace-level + + public static final String DD_GIT_REPOSITORY_URL_NAME = "_dd.git.repository_url"; + public static final long DD_GIT_REPOSITORY_URL_ID = 0x0007000000000004L; + // makeTagId(serial=7) + trace-level + + public static final String DD_INTEGRATION_NAME = "_dd.integration"; + public static final long DD_INTEGRATION_ID = 0x0008000000000000L; + // makeTagId(serial=8) + + public static final String DD_PARENT_ID_NAME = "_dd.parent_id"; + public static final long DD_PARENT_ID = 0x0009000000000000L; + // makeTagId(serial=9) + + public static final String DD_PEER_SERVICE_REMAPPED_FROM_NAME = "_dd.peer.service.remapped_from"; + public static final long DD_PEER_SERVICE_REMAPPED_FROM_ID = 0x000A000000000000L; + // makeTagId(serial=10) + + public static final String DD_PEER_SERVICE_SOURCE_NAME = "_dd.peer.service.source"; + public static final long DD_PEER_SERVICE_SOURCE_ID = 0x000B000000000000L; + // makeTagId(serial=11) + + public static final String DD_PROFILING_ENABLED_NAME = "_dd.profiling.enabled"; + public static final long DD_PROFILING_ENABLED_ID = 0x000C000000000004L; + // makeTagId(serial=12) + trace-level + + public static final String DD_SVC_SRC_NAME = "_dd.svc_src"; + public static final long DD_SVC_SRC_ID = 0x000D000000000000L; + // makeTagId(serial=13) + + public static final String DD_TRACER_HOST_NAME = "_dd.tracer_host"; + public static final long DD_TRACER_HOST_ID = 0x000E000000000004L; + // makeTagId(serial=14) + trace-level + + public static final String COMPONENT_NAME = "component"; + public static final long COMPONENT_ID = 0x000F000000000000L; + // makeTagId(serial=15) + + public static final String DB_INSTANCE_NAME = "db.instance"; + public static final long DB_INSTANCE_ID = 0x0010000000000000L; + // makeTagId(serial=16) + + public static final String DB_OPERATION_NAME = "db.operation"; + public static final long DB_OPERATION_ID = 0x0011000000000000L; + // makeTagId(serial=17) -> db.operation.name + + public static final String DB_POOL_NAME = "db.pool.name"; + public static final long DB_POOL_NAME_ID = 0x0012000000000000L; + // makeTagId(serial=18) + + public static final String DB_STATEMENT_NAME = "db.statement"; + public static final long DB_STATEMENT_ID = 0x0013000000000000L; + // makeTagId(serial=19) -> db.query.text + + public static final String DB_TYPE_NAME = "db.type"; + public static final long DB_TYPE_ID = 0x0014000000000000L; + // makeTagId(serial=20) -> db.system + + public static final String DB_USER_NAME = "db.user"; + public static final long DB_USER_ID = 0x0015000000000000L; + // makeTagId(serial=21) + + public static final String ENV_NAME = "env"; + public static final long ENV_ID = 0x0016000000000004L; + // makeTagId(serial=22) + trace-level + + public static final String ERROR_MESSAGE_NAME = "error.message"; + public static final long ERROR_MESSAGE_ID = 0x0017000000000000L; + // makeTagId(serial=23) + + public static final String ERROR_STACK_NAME = "error.stack"; + public static final long ERROR_STACK_ID = 0x0018000000000000L; + // makeTagId(serial=24) + + public static final String ERROR_TYPE_NAME = "error.type"; + public static final long ERROR_TYPE_ID = 0x0019000000000000L; + // makeTagId(serial=25) + + public static final String HTTP_HOSTNAME_NAME = "http.hostname"; + public static final long HTTP_HOSTNAME_ID = 0x001A000000000000L; + // makeTagId(serial=26) -> server.address + + public static final String HTTP_METHOD_NAME = "http.method"; + public static final long HTTP_METHOD_ID = 0x001B000000000000L; + // makeTagId(serial=27) -> http.request.method + + public static final String HTTP_QUERY_STRING_NAME = "http.query.string"; + public static final long HTTP_QUERY_STRING_ID = 0x001C000000000000L; + // makeTagId(serial=28) -> url.query + + public static final String HTTP_RESEND_COUNT_NAME = "http.resend_count"; + public static final long HTTP_RESEND_COUNT_ID = 0x001D000000000000L; + // makeTagId(serial=29) + + public static final String HTTP_ROUTE_NAME = "http.route"; + public static final long HTTP_ROUTE_ID = 0x001E000000000000L; + // makeTagId(serial=30) + + public static final String HTTP_STATUS_CODE_NAME = "http.status_code"; + public static final long HTTP_STATUS_CODE_ID = 0x001F000000000000L; + // makeTagId(serial=31) -> http.response.status_code + + public static final String HTTP_URL_NAME = "http.url"; + public static final long HTTP_URL_ID = 0x0020000000000000L; + // makeTagId(serial=32) -> url.full + + public static final String HTTP_USERAGENT_NAME = "http.useragent"; + public static final long HTTP_USERAGENT_ID = 0x0021000000000000L; + // makeTagId(serial=33) -> user_agent.original + + public static final String LANGUAGE_NAME = "language"; + public static final long LANGUAGE_ID = 0x0022000000000004L; + // makeTagId(serial=34) + trace-level + + public static final String NETWORK_PROTOCOL_VERSION_NAME = "network.protocol.version"; + public static final long NETWORK_PROTOCOL_VERSION_ID = 0x0023000000000000L; + // makeTagId(serial=35) + + public static final String PEER_HOSTNAME_NAME = "peer.hostname"; + public static final long PEER_HOSTNAME_ID = 0x0024000000000000L; + // makeTagId(serial=36) + + public static final String PEER_IPV4_NAME = "peer.ipv4"; + public static final long PEER_IPV4_ID = 0x0025000000000000L; + // makeTagId(serial=37) + + public static final String PEER_IPV6_NAME = "peer.ipv6"; + public static final long PEER_IPV6_ID = 0x0026000000000000L; + // makeTagId(serial=38) + + public static final String PEER_PORT_NAME = "peer.port"; + public static final long PEER_PORT_ID = 0x0027000000000000L; + // makeTagId(serial=39) + + public static final String PEER_SERVICE_NAME = "peer.service"; + public static final long PEER_SERVICE_ID = 0x0028000000000000L; + // makeTagId(serial=40) + + public static final String RUNTIME_ID_NAME = "runtime-id"; + public static final long RUNTIME_ID = 0x0029000000000004L; + // makeTagId(serial=41) + trace-level + + public static final String SERVICE_NAME = "service"; + public static final long SERVICE_ID = 0x002A000000000000L; + // makeTagId(serial=42) -> service.name + + public static final String SERVLET_CONTEXT_NAME = "servlet.context"; + public static final long SERVLET_CONTEXT_ID = 0x002B000000000000L; + // makeTagId(serial=43) + + public static final String SERVLET_PATH_NAME = "servlet.path"; + public static final long SERVLET_PATH_ID = 0x002C000000000000L; + // makeTagId(serial=44) + + public static final String SPAN_KIND_NAME = "span.kind"; + public static final long SPAN_KIND_ID = 0x002D000000000000L; + // makeTagId(serial=45) + + public static final String VERSION_NAME = "version"; + public static final long VERSION_ID = 0x002E000000000004L; + // makeTagId(serial=46) + trace-level + + public static final String VIEW_NAME = "view.name"; + public static final long VIEW_NAME_ID = 0x002F000000000000L; + // makeTagId(serial=47) + + // ---- serial numbers ---- + static final int DD_APPSEC_ENABLED_SERIAL_NUM = 1; + static final int DD_BASE_SERVICE_SERIAL_NUM = 2; + static final int DD_CIVISIBILITY_ENABLED_SERIAL_NUM = 3; + static final int DD_DJM_ENABLED_SERIAL_NUM = 4; + static final int DD_DSM_ENABLED_SERIAL_NUM = 5; + static final int DD_GIT_COMMIT_SHA_SERIAL_NUM = 6; + static final int DD_GIT_REPOSITORY_URL_SERIAL_NUM = 7; + static final int DD_INTEGRATION_SERIAL_NUM = 8; + static final int DD_PARENT_ID_SERIAL_NUM = 9; + static final int DD_PEER_SERVICE_REMAPPED_FROM_SERIAL_NUM = 10; + static final int DD_PEER_SERVICE_SOURCE_SERIAL_NUM = 11; + static final int DD_PROFILING_ENABLED_SERIAL_NUM = 12; + static final int DD_SVC_SRC_SERIAL_NUM = 13; + static final int DD_TRACER_HOST_SERIAL_NUM = 14; + static final int COMPONENT_SERIAL_NUM = 15; + static final int DB_INSTANCE_SERIAL_NUM = 16; + static final int DB_OPERATION_SERIAL_NUM = 17; + static final int DB_POOL_NAME_SERIAL_NUM = 18; + static final int DB_STATEMENT_SERIAL_NUM = 19; + static final int DB_TYPE_SERIAL_NUM = 20; + static final int DB_USER_SERIAL_NUM = 21; + static final int ENV_SERIAL_NUM = 22; + static final int ERROR_MESSAGE_SERIAL_NUM = 23; + static final int ERROR_STACK_SERIAL_NUM = 24; + static final int ERROR_TYPE_SERIAL_NUM = 25; + static final int HTTP_HOSTNAME_SERIAL_NUM = 26; + static final int HTTP_METHOD_SERIAL_NUM = 27; + static final int HTTP_QUERY_STRING_SERIAL_NUM = 28; + static final int HTTP_RESEND_COUNT_SERIAL_NUM = 29; + static final int HTTP_ROUTE_SERIAL_NUM = 30; + static final int HTTP_STATUS_CODE_SERIAL_NUM = 31; + static final int HTTP_URL_SERIAL_NUM = 32; + static final int HTTP_USERAGENT_SERIAL_NUM = 33; + static final int LANGUAGE_SERIAL_NUM = 34; + static final int NETWORK_PROTOCOL_VERSION_SERIAL_NUM = 35; + static final int PEER_HOSTNAME_SERIAL_NUM = 36; + static final int PEER_IPV4_SERIAL_NUM = 37; + static final int PEER_IPV6_SERIAL_NUM = 38; + static final int PEER_PORT_SERIAL_NUM = 39; + static final int PEER_SERVICE_SERIAL_NUM = 40; + static final int RUNTIME_ID_SERIAL_NUM = 41; + static final int SERVICE_SERIAL_NUM = 42; + static final int SERVLET_CONTEXT_SERIAL_NUM = 43; + static final int SERVLET_PATH_SERIAL_NUM = 44; + static final int SPAN_KIND_SERIAL_NUM = 45; + static final int VERSION_SERIAL_NUM = 46; + static final int VIEW_NAME_SERIAL_NUM = 47; + + private static final String[] KEYOF_NAMES = { + DD_APPSEC_ENABLED_NAME, + DD_BASE_SERVICE_NAME, + DD_CIVISIBILITY_ENABLED_NAME, + DD_DJM_ENABLED_NAME, + DD_DSM_ENABLED_NAME, + DD_GIT_COMMIT_SHA_NAME, + DD_GIT_REPOSITORY_URL_NAME, + DD_INTEGRATION_NAME, + DD_PARENT_ID_NAME, + DD_PEER_SERVICE_REMAPPED_FROM_NAME, + DD_PEER_SERVICE_SOURCE_NAME, + DD_PROFILING_ENABLED_NAME, + DD_SVC_SRC_NAME, + DD_TRACER_HOST_NAME, + COMPONENT_NAME, + DB_INSTANCE_NAME, + DB_OPERATION_NAME, + DB_POOL_NAME, + DB_STATEMENT_NAME, + DB_TYPE_NAME, + DB_USER_NAME, + ENV_NAME, + ERROR_MESSAGE_NAME, + ERROR_STACK_NAME, + ERROR_TYPE_NAME, + HTTP_HOSTNAME_NAME, + HTTP_METHOD_NAME, + HTTP_QUERY_STRING_NAME, + HTTP_RESEND_COUNT_NAME, + HTTP_ROUTE_NAME, + HTTP_STATUS_CODE_NAME, + HTTP_URL_NAME, + HTTP_USERAGENT_NAME, + LANGUAGE_NAME, + NETWORK_PROTOCOL_VERSION_NAME, + PEER_HOSTNAME_NAME, + PEER_IPV4_NAME, + PEER_IPV6_NAME, + PEER_PORT_NAME, + PEER_SERVICE_NAME, + RUNTIME_ID_NAME, + SERVICE_NAME, + SERVLET_CONTEXT_NAME, + SERVLET_PATH_NAME, + SPAN_KIND_NAME, + VERSION_NAME, + VIEW_NAME, + "db.operation.name", + "db.query.text", + "db.system", + "http.request.method", + "http.response.status_code", + "server.address", + "service.name", + "url.full", + "url.query", + "user_agent.original", + }; + private static final long[] KEYOF_VALUES = { + DD_APPSEC_ENABLED_ID, + DD_BASE_SERVICE_ID, + DD_CIVISIBILITY_ENABLED_ID, + DD_DJM_ENABLED_ID, + DD_DSM_ENABLED_ID, + DD_GIT_COMMIT_SHA_ID, + DD_GIT_REPOSITORY_URL_ID, + DD_INTEGRATION_ID, + DD_PARENT_ID, + DD_PEER_SERVICE_REMAPPED_FROM_ID, + DD_PEER_SERVICE_SOURCE_ID, + DD_PROFILING_ENABLED_ID, + DD_SVC_SRC_ID, + DD_TRACER_HOST_ID, + COMPONENT_ID, + DB_INSTANCE_ID, + DB_OPERATION_ID, + DB_POOL_NAME_ID, + DB_STATEMENT_ID, + DB_TYPE_ID, + DB_USER_ID, + ENV_ID, + ERROR_MESSAGE_ID, + ERROR_STACK_ID, + ERROR_TYPE_ID, + HTTP_HOSTNAME_ID, + HTTP_METHOD_ID, + HTTP_QUERY_STRING_ID, + HTTP_RESEND_COUNT_ID, + HTTP_ROUTE_ID, + HTTP_STATUS_CODE_ID, + HTTP_URL_ID, + HTTP_USERAGENT_ID, + LANGUAGE_ID, + NETWORK_PROTOCOL_VERSION_ID, + PEER_HOSTNAME_ID, + PEER_IPV4_ID, + PEER_IPV6_ID, + PEER_PORT_ID, + PEER_SERVICE_ID, + RUNTIME_ID, + SERVICE_ID, + SERVLET_CONTEXT_ID, + SERVLET_PATH_ID, + SPAN_KIND_ID, + VERSION_ID, + VIEW_NAME_ID, + DB_OPERATION_ID, + DB_STATEMENT_ID, + DB_TYPE_ID, + HTTP_METHOD_ID, + HTTP_STATUS_CODE_ID, + HTTP_HOSTNAME_ID, + SERVICE_ID, + HTTP_URL_ID, + HTTP_QUERY_STRING_ID, + HTTP_USERAGENT_ID, + }; + private static final int[] KEYOF_HASHES; + private static final String[] KEYOF_KEYS; + private static final long[] KEYOF_IDS; + + static { + StringIndex.Data data = StringIndex.EmbeddingSupport.create(KEYOF_NAMES); + long[] ids = new long[data.names.length]; + for (int j = 0; j < KEYOF_NAMES.length; j++) { + ids[StringIndex.EmbeddingSupport.indexOf(data.hashes, data.names, KEYOF_NAMES[j])] = + KEYOF_VALUES[j]; + } + KEYOF_HASHES = data.hashes; + KEYOF_KEYS = data.names; + KEYOF_IDS = ids; + } + + /** + * The registry's name↔id tables, as a {@link KnownTagCodec.Resolver}. {@code KnownTagCodec} + * reads this field from its own holder, so the two classes complete each other: the codec owns + * the bit layout and the naming policy, this class owns the data. Nothing has to be called first. + */ + static final KnownTagCodec.Resolver RESOLVER = + new KnownTagCodec.Resolver() { + @Override + public String nameOf(long tagId) { + switch (KnownTagCodec.serialNum(tagId)) { + case DD_APPSEC_ENABLED_SERIAL_NUM: + return DD_APPSEC_ENABLED_NAME; + case DD_BASE_SERVICE_SERIAL_NUM: + return DD_BASE_SERVICE_NAME; + case DD_CIVISIBILITY_ENABLED_SERIAL_NUM: + return DD_CIVISIBILITY_ENABLED_NAME; + case DD_DJM_ENABLED_SERIAL_NUM: + return DD_DJM_ENABLED_NAME; + case DD_DSM_ENABLED_SERIAL_NUM: + return DD_DSM_ENABLED_NAME; + case DD_GIT_COMMIT_SHA_SERIAL_NUM: + return DD_GIT_COMMIT_SHA_NAME; + case DD_GIT_REPOSITORY_URL_SERIAL_NUM: + return DD_GIT_REPOSITORY_URL_NAME; + case DD_INTEGRATION_SERIAL_NUM: + return DD_INTEGRATION_NAME; + case DD_PARENT_ID_SERIAL_NUM: + return DD_PARENT_ID_NAME; + case DD_PEER_SERVICE_REMAPPED_FROM_SERIAL_NUM: + return DD_PEER_SERVICE_REMAPPED_FROM_NAME; + case DD_PEER_SERVICE_SOURCE_SERIAL_NUM: + return DD_PEER_SERVICE_SOURCE_NAME; + case DD_PROFILING_ENABLED_SERIAL_NUM: + return DD_PROFILING_ENABLED_NAME; + case DD_SVC_SRC_SERIAL_NUM: + return DD_SVC_SRC_NAME; + case DD_TRACER_HOST_SERIAL_NUM: + return DD_TRACER_HOST_NAME; + case COMPONENT_SERIAL_NUM: + return COMPONENT_NAME; + case DB_INSTANCE_SERIAL_NUM: + return DB_INSTANCE_NAME; + case DB_OPERATION_SERIAL_NUM: + return DB_OPERATION_NAME; + case DB_POOL_NAME_SERIAL_NUM: + return DB_POOL_NAME; + case DB_STATEMENT_SERIAL_NUM: + return DB_STATEMENT_NAME; + case DB_TYPE_SERIAL_NUM: + return DB_TYPE_NAME; + case DB_USER_SERIAL_NUM: + return DB_USER_NAME; + case ENV_SERIAL_NUM: + return ENV_NAME; + case ERROR_MESSAGE_SERIAL_NUM: + return ERROR_MESSAGE_NAME; + case ERROR_STACK_SERIAL_NUM: + return ERROR_STACK_NAME; + case ERROR_TYPE_SERIAL_NUM: + return ERROR_TYPE_NAME; + case HTTP_HOSTNAME_SERIAL_NUM: + return HTTP_HOSTNAME_NAME; + case HTTP_METHOD_SERIAL_NUM: + return HTTP_METHOD_NAME; + case HTTP_QUERY_STRING_SERIAL_NUM: + return HTTP_QUERY_STRING_NAME; + case HTTP_RESEND_COUNT_SERIAL_NUM: + return HTTP_RESEND_COUNT_NAME; + case HTTP_ROUTE_SERIAL_NUM: + return HTTP_ROUTE_NAME; + case HTTP_STATUS_CODE_SERIAL_NUM: + return HTTP_STATUS_CODE_NAME; + case HTTP_URL_SERIAL_NUM: + return HTTP_URL_NAME; + case HTTP_USERAGENT_SERIAL_NUM: + return HTTP_USERAGENT_NAME; + case LANGUAGE_SERIAL_NUM: + return LANGUAGE_NAME; + case NETWORK_PROTOCOL_VERSION_SERIAL_NUM: + return NETWORK_PROTOCOL_VERSION_NAME; + case PEER_HOSTNAME_SERIAL_NUM: + return PEER_HOSTNAME_NAME; + case PEER_IPV4_SERIAL_NUM: + return PEER_IPV4_NAME; + case PEER_IPV6_SERIAL_NUM: + return PEER_IPV6_NAME; + case PEER_PORT_SERIAL_NUM: + return PEER_PORT_NAME; + case PEER_SERVICE_SERIAL_NUM: + return PEER_SERVICE_NAME; + case RUNTIME_ID_SERIAL_NUM: + return RUNTIME_ID_NAME; + case SERVICE_SERIAL_NUM: + return SERVICE_NAME; + case SERVLET_CONTEXT_SERIAL_NUM: + return SERVLET_CONTEXT_NAME; + case SERVLET_PATH_SERIAL_NUM: + return SERVLET_PATH_NAME; + case SPAN_KIND_SERIAL_NUM: + return SPAN_KIND_NAME; + case VERSION_SERIAL_NUM: + return VERSION_NAME; + case VIEW_NAME_SERIAL_NUM: + return VIEW_NAME; + default: + return null; + } + } + + @Override + public String openTelemetryNameOf(long tagId) { + switch (KnownTagCodec.serialNum(tagId)) { + case DB_OPERATION_SERIAL_NUM: + return "db.operation.name"; + case DB_STATEMENT_SERIAL_NUM: + return "db.query.text"; + case DB_TYPE_SERIAL_NUM: + return "db.system"; + case HTTP_HOSTNAME_SERIAL_NUM: + return "server.address"; + case HTTP_METHOD_SERIAL_NUM: + return "http.request.method"; + case HTTP_QUERY_STRING_SERIAL_NUM: + return "url.query"; + case HTTP_STATUS_CODE_SERIAL_NUM: + return "http.response.status_code"; + case HTTP_URL_SERIAL_NUM: + return "url.full"; + case HTTP_USERAGENT_SERIAL_NUM: + return "user_agent.original"; + case SERVICE_SERIAL_NUM: + return "service.name"; + default: + return null; + } + } + + @Override + public long keyOf(String name) { + int slot = StringIndex.EmbeddingSupport.indexOf(KEYOF_HASHES, KEYOF_KEYS, name); + return slot < 0 ? 0L : KEYOF_IDS[slot]; + } + }; + + private KnownTags() {} +} diff --git a/internal-api/src/generated/resolved-tags.txt b/internal-api/src/generated/resolved-tags.txt new file mode 100644 index 00000000000..25aa10553d7 --- /dev/null +++ b/internal-api/src/generated/resolved-tags.txt @@ -0,0 +1,81 @@ +# Resolved per-type tag sets (concrete span types). + +db.client (22 tags): + - _dd.parent_id + - service + - component + - span.kind + - _dd.integration + - _dd.svc_src + - error.type + - error.message + - error.stack + - db.type + - db.instance + - db.operation + - db.user + - db.pool.name + - db.statement + - peer.service + - _dd.peer.service.source + - _dd.peer.service.remapped_from + - peer.hostname + - peer.ipv4 + - peer.ipv6 + - peer.port + +http.client (21 tags): + - _dd.parent_id + - service + - component + - span.kind + - _dd.integration + - _dd.svc_src + - error.type + - error.message + - error.stack + - http.method + - http.status_code + - network.protocol.version + - http.url + - http.resend_count + - peer.service + - _dd.peer.service.source + - _dd.peer.service.remapped_from + - peer.hostname + - peer.ipv4 + - peer.ipv6 + - peer.port + +http.server (19 tags): + - _dd.parent_id + - service + - component + - span.kind + - _dd.integration + - _dd.svc_src + - error.type + - error.message + - error.stack + - http.method + - http.status_code + - network.protocol.version + - http.url + - http.route + - http.hostname + - http.useragent + - http.query.string + - servlet.path + - servlet.context + +view.render (10 tags): + - _dd.parent_id + - service + - component + - span.kind + - _dd.integration + - _dd.svc_src + - error.type + - error.message + - error.stack + - view.name diff --git a/internal-api/src/generated/tag-assignment.txt b/internal-api/src/generated/tag-assignment.txt new file mode 100644 index 00000000000..6fb20ec5f8a --- /dev/null +++ b/internal-api/src/generated/tag-assignment.txt @@ -0,0 +1,63 @@ +# Tag id assignment. tags=47 + +# TAGS serial lvl id required name + 1 T 0x0001000000000004 recommended _dd.appsec.enabled + 2 T 0x0002000000000004 required _dd.base_service + 3 T 0x0003000000000004 recommended _dd.civisibility.enabled + 4 T 0x0004000000000004 recommended _dd.djm.enabled + 5 T 0x0005000000000004 recommended _dd.dsm.enabled + 6 T 0x0006000000000004 recommended _dd.git.commit.sha + 7 T 0x0007000000000004 recommended _dd.git.repository_url + 8 - 0x0008000000000000 recommended _dd.integration + 9 - 0x0009000000000000 required _dd.parent_id + 10 - 0x000A000000000000 recommended _dd.peer.service.remapped_from + 11 - 0x000B000000000000 recommended _dd.peer.service.source + 12 T 0x000C000000000004 recommended _dd.profiling.enabled + 13 - 0x000D000000000000 optional _dd.svc_src + 14 T 0x000E000000000004 recommended _dd.tracer_host + 15 - 0x000F000000000000 required component + 16 - 0x0010000000000000 recommended db.instance + 17 - 0x0011000000000000 recommended db.operation + 18 - 0x0012000000000000 optional db.pool.name + 19 - 0x0013000000000000 recommended db.statement + 20 - 0x0014000000000000 required db.type + 21 - 0x0015000000000000 recommended db.user + 22 T 0x0016000000000004 recommended env + 23 - 0x0017000000000000 recommended error.message + 24 - 0x0018000000000000 recommended error.stack + 25 - 0x0019000000000000 recommended error.type + 26 - 0x001A000000000000 required http.hostname + 27 - 0x001B000000000000 required http.method + 28 - 0x001C000000000000 recommended http.query.string + 29 - 0x001D000000000000 recommended http.resend_count + 30 - 0x001E000000000000 conditional http.route + 31 - 0x001F000000000000 conditional http.status_code + 32 - 0x0020000000000000 required http.url + 33 - 0x0021000000000000 recommended http.useragent + 34 T 0x0022000000000004 required language + 35 - 0x0023000000000000 recommended network.protocol.version + 36 - 0x0024000000000000 recommended peer.hostname + 37 - 0x0025000000000000 optional peer.ipv4 + 38 - 0x0026000000000000 optional peer.ipv6 + 39 - 0x0027000000000000 optional peer.port + 40 - 0x0028000000000000 recommended peer.service + 41 T 0x0029000000000004 required runtime-id + 42 - 0x002A000000000000 required service + 43 - 0x002B000000000000 optional servlet.context + 44 - 0x002C000000000000 optional servlet.path + 45 - 0x002D000000000000 required span.kind + 46 T 0x002E000000000004 recommended version + 47 - 0x002F000000000000 recommended view.name + +# OPENTELEMETRY NAMES. keyOf(otelName) resolves to the canonical tag's id; nameOf still +# returns the Datadog name, openTelemetryNameOf returns the name below. (No distinct id.) + db.operation.name -> db.operation + db.query.text -> db.statement + db.system -> db.type + http.request.method -> http.method + http.response.status_code -> http.status_code + server.address -> http.hostname + service.name -> service + url.full -> http.url + url.query -> http.query.string + user_agent.original -> http.useragent diff --git a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java new file mode 100644 index 00000000000..13e18fe6155 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java @@ -0,0 +1,148 @@ +package datadog.trace.api; + +/** + * Registry for generated tag ID ↔ name resolution. This class and the generated {@code KnownTags} + * are two halves of one thing: the codec owns the bit layout and the naming policy, {@code + * KnownTags} owns the name↔id tables. {@code Installed} names {@code KnownTags.RESOLVER} + * directly, so resolving a tag name is what initializes the registry — there is no registration + * call to make and no ordering to get wrong. + * + *

Holding the resolver in a {@code static final} of that holder is what makes {@link + * #nameOf}/{@link #keyOf} effectively zero-overhead: the JIT constant-folds the field to the + * resolver instance, and a constant receiver has an exact klass, so the call devirtualizes and + * inlines with no CHA dependency to invalidate. + * + *

A tag id is IDENTITY, not storage: it names one tag across every namespace the tag is known + * by. {@link #keyOf} is many→one (a Datadog name or an OpenTelemetry name both resolve to the one + * id) and the per-namespace readers — {@link #datadogNameOf}, {@link #openTelemetryNameOf} — take + * it back out. How (or whether) a tag is stored is a separate concern that no part of this class + * decides. + */ +public final class KnownTagCodec { + /* + * tagId bit layout: [63-48 serialNum (16 bits)] [47-32 reserved, zero] [31-0 flags]. serialNum is + * globally unique per known tag and is the whole of the tag's identity — nameOf/ + * openTelemetryNameOf switch on it, and the generator emits each id as a literal. Bits [47-32] + * are RESERVED and always zero here: they are the window the dense tag store uses for its + * co-occurrence slot coordinate, which arrives with that store. Of the low 32 flag bits, bit 2 is + * the trace/span LEVEL bit (set ⟹ trace-level); bits 1-0 are reserved. Unknown (string-only) + * custom tags are NOT known ids — {@code keyOf} returns 0 for them. + * + *

An id says what a tag IS, not how it is SET. Whether the tracer intercepts a tag on the + * set-path — routing it to a span field or a sampling directive instead of tag storage — belongs + * to TagInterceptor, whose {@code needsIntercept} switch is the authority; mirroring it here as a + * classification bit and a serial-range tier only created drift between the two. That + * classification returns with the work that consumes it (the id→handler dispatch table that + * retires TagInterceptor), and re-adding a bit then is purely additive. + * + *

There is deliberately NO OpenTelemetry-applicability flag: an absent otel-name means + * pass-through (the tag is emitted under its Datadog name), so today every known tag has an + * OpenTelemetry name and such a flag would be constant. It returns once a Datadog-only tag exists. + */ + public static int serialNum(long tagId) { + return (int) (tagId >>> 48); + } + + /** + * Trace/span LEVEL bit (low-32 carve, bit 2). Set marks a trace-level tag (lives on the + * TraceSegment's own TagMap); clear marks a span-level tag. Declared in the conventions as the + * {@code trace_level} tier, so it is part of the tag's identity rather than of any storage + * scheme. + */ + public static final long LEVEL_TRACE = 1L << 2; + + /** True if the tagId names a trace-level tag. */ + public static boolean isTraceLevel(long tagId) { + return (tagId & LEVEL_TRACE) != 0L; + } + + /** Returns the tagId with the {@link #LEVEL_TRACE} flag set. */ + public static long traceLevel(long tagId) { + return tagId | LEVEL_TRACE; + } + + /** + * Builds a tagId from its {@code serialNum} (globally unique per known tag). The reserved [47-32] + * window and the low 32 bits are zero, so the id is fully determined by the serial — the + * generator emits it as a literal. Inverse of {@link #serialNum}. Intended for the code generator + * and tests. + */ + public static long makeTagId(int serialNum) { + return (long) serialNum << 48; + } + + public interface Resolver { + /** The tag's Datadog-namespace (canonical) name. */ + String nameOf(long tagId); + + /** The tag's OpenTelemetry-namespace name, or {@code null} when it declares none. */ + String openTelemetryNameOf(long tagId); + + /** The id for {@code name} in ANY namespace (many→one), or 0 when it is not a known tag. */ + long keyOf(String name); + } + + /** + * Holder that hands the codec its generated half. {@code KnownTags} is emitted into this very + * package on the main compile path, so the link is an ordinary compile-time reference: the first + * read of {@code RESOLVER} initializes this holder, which initializes {@code KnownTags}. Nothing + * needs to be poked first, and no reader can observe a registry that is not there yet. + * + *

The nesting is load-bearing. {@code KnownTags} calls back into {@code KnownTagCodec}, so + * were {@code RESOLVER} a field of the codec itself, the codec's own initializer would re-enter + * on the same thread and silently read defaults. Holding it one class down means {@code + * KnownTagCodec}'s initializer is complete before {@code KnownTags}' ever starts. + * + *

The point of the {@code static final} is the read side. The JIT treats it as a true constant + * — it folds the load away entirely, and a constant receiver carries an exact klass, so the + * resolver's switch devirtualizes and inlines outright. So {@link #keyOf} / {@link #nameOf} carry + * no lock, no volatile read, no null check and no virtual call. HotSpot also elides the + * class-init barrier once the class is initialized, so the one-shot cost is paid once, ever, and + * never on a tag path. + */ + private static final class Installed { + static final Resolver RESOLVER = KnownTags.RESOLVER; + } + + /** The tag's canonical (Datadog-namespace) name, or {@code null} when the id is not known. */ + public static String nameOf(long tagId) { + return Installed.RESOLVER.nameOf(tagId); + } + + /** The tag's Datadog-namespace (canonical) name — the same value as {@link #nameOf}. */ + public static String datadogNameOf(long tagId) { + return nameOf(tagId); + } + + /** + * The tag's declared OpenTelemetry RENAME, or {@code null} when it declares none. Raw registry + * data — it does not apply the pass-through default, so most callers want {@link + * #openTelemetryTagOf} instead. + */ + public static String openTelemetryNameOf(long tagId) { + return Installed.RESOLVER.openTelemetryNameOf(tagId); + } + + /** + * The name {@code tagId} is emitted under in the OpenTelemetry namespace: its declared rename + * when it has one, otherwise its Datadog name — pass-through, the default. {@code null} for an + * unknown id, which has no registry name at all; a custom tag falls back to its own key, and only + * the caller holding that key can do so. + * + *

This is the one place the pass-through policy lives, so no serializer re-decides it. Pair it + * with {@link #datadogNameOf} for the same tag under the Datadog namespace; outbound naming is + * per-namespace, never normalized to one of them. + */ + public static String openTelemetryTagOf(long tagId) { + Resolver resolver = Installed.RESOLVER; + String otelName = resolver.openTelemetryNameOf(tagId); + return otelName != null ? otelName : resolver.nameOf(tagId); + } + + /** The id for {@code name} in any namespace, or 0 when it is not a known tag. */ + public static long keyOf(String name) { + return Installed.RESOLVER.keyOf(name); + } + + private KnownTagCodec() {} +} diff --git a/tag-conventions.yaml b/tag-conventions.yaml new file mode 100644 index 00000000000..a2aa808acb7 --- /dev/null +++ b/tag-conventions.yaml @@ -0,0 +1,152 @@ +# Tag conventions — LANGUAGE-AGNOSTIC domain spec (structure + semantics only) +# --------------------------------------------------------------------------- +# The code generator consumes THIS file to emit each language's tag-id constants and its +# id<->name resolver. A tag id is IDENTITY (a globally unique serial + the trace-level bit); +# storage layout (the dense store's slot assignment) and set-path routing (which keys the tracer +# intercepts into span fields or sampling directives) are per-language concerns that arrive with +# the code that consumes them, via a per-language overlay alongside this file. +# +# TRACE-LEVEL is its own thing (its own TagMap "type" on the TraceSegment) — the process/trace +# constants + product flags that are set once per trace, NOT per span. Declared explicitly in the +# `trace_level` section below (a distinct tier), never inferred from `source`. +# +# SPAN TYPES compose three ways: +# extends — structural is-a inheritance (http.server is-a http is-a base). `base` is implicitly +# in every span; abstract layers exist only to be extended. +# 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). +# +# tag fields (DOMAIN only): dd-name | type (string|int|long|boolean|double) +# | required (required|conditional|recommended|optional|opt_in) | otel-name. +# dd-name is the canonical Datadog-namespace name AND the tag's identity. otel-name is OPTIONAL and +# tri-state: +# - absent => the OpenTelemetry name is IMPLICITLY the dd-name (the tag passes through under +# its Datadog name; this is the RFC "retain" default for tags with no rename). +# - a name => rename: the tag is emitted under that OpenTelemetry-namespace name instead. +# - the literal none => Datadog-only: the tag has NO OpenTelemetry name (suppressed from OTel). This +# value is reserved — no tag uses it today (the RFC renames or retains, never +# suppresses), and real suppression is a follow-on; it currently behaves as +# pass-through. +# A tag is one identity across span types/mixins, so it may carry only one otel-name — declaring it +# two different ways fails the build (a span-kind-dependent mapping is a derivation, not a rename). +# 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 +# bucketed. See the design doc. +# --------------------------------------------------------------------------- + +# Trace-level tier: its own TagMap on the TraceSegment. Set once per trace, not per span. +# (Their OTel mapping is a resource-attribute follow-on; they pass through under dd-name for now.) +trace_level: + tags: + - { dd-name: _dd.base_service, type: string, required: required } + - { dd-name: version, type: string, required: recommended } + - { dd-name: env, type: string, required: recommended } + - { dd-name: language, type: string, required: required } + - { dd-name: runtime-id, type: string, required: required } + - { dd-name: _dd.tracer_host, type: string, required: recommended } + - { dd-name: _dd.git.commit.sha, type: string, required: recommended } + - { dd-name: _dd.git.repository_url, type: string, required: recommended } + # product .enabled flags — process-constant; present on the trace segment regardless of whether + # the product is enabled (the flag carries the state), so always-present => recommended. + - { dd-name: _dd.profiling.enabled, type: boolean, required: recommended } + - { dd-name: _dd.dsm.enabled, type: boolean, required: recommended } + - { dd-name: _dd.appsec.enabled, type: boolean, required: recommended } + - { dd-name: _dd.djm.enabled, type: boolean, required: recommended } + - { dd-name: _dd.civisibility.enabled, type: boolean, required: recommended } + +span_types: + # root: per-span tags every span has (incl. the per-span core tags parent_id / integration / svc_src + # — core-set but per-span, so NOT trace-level). + base: + abstract: true + tags: + - { dd-name: _dd.parent_id, type: string, required: required } + - { dd-name: service, type: string, required: required, otel-name: service.name } + - { dd-name: component, type: string, required: required } + - { dd-name: span.kind, type: string, required: required } # OTel span kind is a first-class field, not an attribute + - { dd-name: _dd.integration, type: string, required: recommended } + - { dd-name: _dd.svc_src, type: string, required: optional } + - { dd-name: error.type, type: string, required: recommended } # TODO(otel): map error.* to exception.* semconv + - { dd-name: error.message, type: string, required: recommended } + - { dd-name: error.stack, type: string, required: recommended } + + http: + abstract: true + extends: base + tags: + - { dd-name: http.method, type: string, required: required, otel-name: http.request.method } + - { dd-name: http.status_code, type: int, required: conditional, otel-name: http.response.status_code } + - { dd-name: network.protocol.version, type: string, required: recommended } # passes through: dd-name already is the OTel name + + http.server: + extends: http + tags: + - { dd-name: http.url, type: string, required: required, otel-name: url.full } # single http.url identity (shared w/ http.client) => one otel-name. url.full is the client-correct rename; server's spec mapping (url.path + url.scheme + url.query) is a one-to-many split reserved for the derivation layer (needs span.kind). TODO(otel): server split. + - { 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, otel-name: server.address } + - { dd-name: http.useragent, type: string, required: recommended, otel-name: user_agent.original } + - { dd-name: http.query.string, type: string, required: recommended, otel-name: url.query } + - { dd-name: servlet.path, type: string, required: optional } + - { dd-name: servlet.context, type: string, required: optional } + + http.client: + extends: http + include: [ peer ] + tags: + - { dd-name: http.url, type: string, required: required, otel-name: url.full } + - { dd-name: http.resend_count, type: int, required: recommended } + + db.client: + extends: base + include: [ peer ] + tags: + - { dd-name: db.type, type: string, required: required, otel-name: db.system } + - { dd-name: db.instance, type: string, required: recommended } # TODO(otel): db.namespace + - { dd-name: db.operation, type: string, required: recommended, otel-name: db.operation.name } + - { dd-name: db.user, type: string, required: recommended } + - { dd-name: db.pool.name, type: string, required: optional } + - { dd-name: db.statement, type: string, required: recommended, otel-name: db.query.text } + + view.render: + extends: base + tags: + - { dd-name: view.name, type: string, required: recommended } + +mixins: + # peer — outbound/remote-peer capability, 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 } + + # 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 + # per-span tags). Applies to the `test` span type (not modeled here yet). + ci_visibility: + enabled_by: dd.civisibility.enabled + applies: [ test ] + tags: + - { dd-name: test.name, type: string, required: recommended } + - { dd-name: test.suite, type: string, required: recommended } + - { dd-name: test.status, type: string, required: recommended } + - { dd-name: test.framework, type: string, required: recommended } + +# --------------------------------------------------------------------------- +# Notes +# - Product .enabled flags moved to `trace_level` (process-constant) — the old product mixins held +# only those flags, so they dissolved. `enabled_by`/attachment gating is a runtime concern. +# - span.kind enumerates: server | client | producer | consumer | internal | broker. +# - Some keys (resource.name, error, sampling.priority, ...) are accepted by setTag but routed to a +# span field or a trace directive instead of tag storage. That routing is a per-language tracer +# concern, so it is NOT modelled here; such a key appears above only when it also needs an id and +# a name (service does, for OpenTelemetry's service.name). +# - Tags with no otel-name pass through under their Datadog name (RFC "retain"). A `# TODO(otel)` note +# marks a pending OpenTelemetry-team review of a mapping that is not yet a settled rename. +# --------------------------------------------------------------------------- From e6d5962c1c99b33c673d141fa4f29b9bb15d69c3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 2 Sep 2026 08:51:30 -0400 Subject: [PATCH 2/5] Give TagMap.EntryReader its tag id and OpenTelemetry name tagId() resolves the entry's key through the registry, and openTelemetryTag() layers the namespace projection on top: the tag's declared OpenTelemetry rename when it has one, otherwise its own key. The reader supplies that last fallback because a custom tag has no registry name, and only the holder of the key can name it. Entry.tagId() is deliberately computed rather than memoized, unlike hash(). TagMap$Entry is the tracer's largest allocation source -- one per tag per span, on the app thread -- and it packs to 40 bytes with only 3 bytes of padding, so a long memo widens every entry to 48 and adds a putfield per construction. The only caller is serialization: background thread, once per entry, where keyOf is a single open-addressed probe over a static final table keyed on an already-cached String hash. Co-Authored-By: Claude Opus 5 --- .../main/java/datadog/trace/api/TagMap.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index 39160ae11ff..3aced0c49a3 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -53,6 +53,11 @@ public final class TagMap implements Map, Iterable. public static final TagMap EMPTY = new TagMap(new Object[1], 0); + // Sentinel for a not-yet-resolved lazy tag id. Cannot be 0L: 0L is a valid keyOf result (the tag + // is not a known tag, or the codec is inactive). Used by EntryReadingHelper, which is a single + // reused flyweight -- memoizing there costs no per-entry footprint, unlike in Entry. + static final long TAG_ID_NOT_COMPUTED = Long.MIN_VALUE; + /** Creates a new mutable TagMap that contains the contents of map */ public static final TagMap fromMap(@Nonnull Map map) { TagMap tagMap = TagMap.create(map.size()); @@ -171,6 +176,27 @@ public interface EntryReader { String tag(); + /** + * The known-tag id for this entry's tag, or {@code 0L} when the tag is not a known tag (or the + * {@link KnownTagCodec} is inactive). Resolved via {@link KnownTagCodec#keyOf(String)}. + */ + long tagId(); + + /** + * This entry's tag name in the OpenTelemetry namespace: the rename the registry declares for + * it, else its Datadog name (pass-through, the default), else — for a custom tag, which the + * registry does not name at all — {@link #tag()} itself. + * + *

Never null, which is the point of asking the reader rather than the codec. {@link + * KnownTagCodec#openTelemetryTagOf} owns the naming policy but returns null for an unknown id, + * because only the holder of the entry knows the key to fall back to. This completes that one + * step and nothing more, so the policy still lives in exactly one place. + */ + default String openTelemetryTag() { + String otelTag = KnownTagCodec.openTelemetryTagOf(tagId()); + return otelTag != null ? otelTag : tag(); + } + byte type(); boolean is(byte type); @@ -359,6 +385,20 @@ int hash() { return hash; } + @Override + public long tagId() { + /* + * Deliberately NOT memoized in a field, unlike hash(). An Entry is allocated on the app + * thread for every tag of every span, and TagMap$Entry is the tracer's largest allocation + * source -- a long field costs 8 bytes on all of them (there are only 3 bytes of padding to + * absorb it) plus a putfield per construction. The only caller is serialization, on the + * background thread, once per entry, and keyOf is a single open-addressed probe over a + * static final table keyed on an already-cached String hash. Paying it there beats widening + * every Entry to cache it. + */ + return KnownTagCodec.keyOf(this.tag); + } + @Override public Entry entry() { return this; @@ -2853,17 +2893,20 @@ final class EntryReadingHelper implements TagMap.EntryReader { private Map.Entry mapEntry; private String tag; private Object value; + private long tagId; void set(String tag, Object value) { this.mapEntry = null; this.tag = tag; this.value = value; + this.tagId = TagMap.TAG_ID_NOT_COMPUTED; // resolve lazily via keyOf on first tagId() access } void set(Map.Entry mapEntry) { this.mapEntry = mapEntry; this.tag = mapEntry.getKey(); this.value = mapEntry.getValue(); + this.tagId = TagMap.TAG_ID_NOT_COMPUTED; // resolve lazily via keyOf on first tagId() access } @Override @@ -2871,6 +2914,16 @@ public String tag() { return this.tag; } + @Override + public long tagId() { + long id = this.tagId; + if (id != TagMap.TAG_ID_NOT_COMPUTED) return id; + + id = KnownTagCodec.keyOf(this.tag); + this.tagId = id; + return id; + } + @Override public byte type() { return TagValueConversions.typeOf(this.value); From c655e9653d83bc7b04f304e4da0096e94bda77bf Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 2 Sep 2026 08:51:48 -0400 Subject: [PATCH 3/5] Emit OTLP attributes under OpenTelemetry tag names OTLP is the OpenTelemetry wire format, so each known tag is rendered under its OpenTelemetry name: http.method as http.request.method, http.useragent as user_agent.original, and so on, falling back to the Datadog name for a tag that declares no rename and to the entry's own key for a custom tag the registry does not know. Tags the tracer intercepts into first-class Metadata fields never reach the per-entry projection, so their keys resolve through the same registry policy once each at class init -- otherwise service and http.status_code would keep emitting under Datadog names while every other tag moved. Co-Authored-By: Claude Opus 5 --- .../trace/core/otlp/trace/OtlpTraceJson.java | 33 ++++++++++++--- .../trace/core/otlp/trace/OtlpTraceProto.java | 40 ++++++++++++++++--- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceJson.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceJson.java index d9c5e9c3d90..bf4346673d6 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceJson.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceJson.java @@ -25,6 +25,8 @@ import datadog.json.JsonWriter; import datadog.trace.api.Config; import datadog.trace.api.DDTags; +import datadog.trace.api.KnownTagCodec; +import datadog.trace.api.KnownTags; import datadog.trace.api.TagMap; import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; @@ -39,11 +41,24 @@ /** Provides writers for OpenTelemetry's "trace.proto" JSON encoding. */ public final class OtlpTraceJson { - private static final UTF8BytesString SERVICE_NAME = UTF8BytesString.create("service.name"); private static final UTF8BytesString RESOURCE_NAME = UTF8BytesString.create("resource.name"); private static final UTF8BytesString OPERATION_NAME = UTF8BytesString.create("operation.name"); private static final UTF8BytesString SPAN_TYPE = UTF8BytesString.create("span.type"); + /* + * Same contract as the protobuf encoder: a tag the tracer intercepts into a first-class Metadata + * field never reaches the per-entry projection below, so its OpenTelemetry name is resolved off + * the registry here instead. Both encoders must agree -- a rename that reached only one of them + * would make the emitted attribute name depend on the transport protocol. (http.status_code is + * held back in both for the same reason; see OtlpTraceProto.) + */ + private static final UTF8BytesString SERVICE_NAME_KEY = otelKey(KnownTags.SERVICE_ID); + + /** The OpenTelemetry-namespace key for a known tag, as named by the registry. */ + private static UTF8BytesString otelKey(long tagId) { + return UTF8BytesString.create(KnownTagCodec.openTelemetryTagOf(tagId)); + } + private OtlpTraceJson() {} /** Writes one complete {@code Span} JSON object. */ @@ -85,7 +100,7 @@ public static void writeSpan( writer.name("attributes").beginArray(); if (!Config.get().getServiceName().equals(span.getServiceName())) { - writeSpanTag(writer, SERVICE_NAME, span.getServiceName()); + writeSpanTag(writer, SERVICE_NAME_KEY, span.getServiceName()); } writeSpanTag(writer, RESOURCE_NAME, span.getResourceName()); writeSpanTag(writer, OPERATION_NAME, span.getOperationName()); @@ -140,20 +155,26 @@ public static void writeSpanLink(JsonWriter writer, AgentSpanLink spanLink) { } private static void writeSpanTag(JsonWriter writer, TagMap.EntryReader tagEntry) { + // OTLP is the OpenTelemetry wire format, so ask each entry for its name in that namespace -- + // the same registry policy the fixed metadata keys above resolve through, with the reader + // supplying its own key for a custom tag the registry does not name. This is the straight + // rename projection only: suppressing a Datadog-only tag from OpenTelemetry, per-exporter + // opt-in, and additional namespaces are deferred to the OpenTelemetry follow-on. + String key = tagEntry.openTelemetryTag(); switch (tagEntry.type()) { case TagMap.EntryReader.BOOLEAN: - writeAttribute(writer, BOOLEAN_ATTRIBUTE, tagEntry.tag(), tagEntry.objectValue()); + writeAttribute(writer, BOOLEAN_ATTRIBUTE, key, tagEntry.objectValue()); break; case TagMap.EntryReader.INT: case TagMap.EntryReader.LONG: - writeAttribute(writer, LONG_ATTRIBUTE, tagEntry.tag(), tagEntry.objectValue()); + writeAttribute(writer, LONG_ATTRIBUTE, key, tagEntry.objectValue()); break; case TagMap.EntryReader.FLOAT: case TagMap.EntryReader.DOUBLE: - writeAttribute(writer, DOUBLE_ATTRIBUTE, tagEntry.tag(), tagEntry.objectValue()); + writeAttribute(writer, DOUBLE_ATTRIBUTE, key, tagEntry.objectValue()); break; default: - writeAttribute(writer, STRING_ATTRIBUTE, tagEntry.tag(), tagEntry.stringValue()); + writeAttribute(writer, STRING_ATTRIBUTE, key, tagEntry.stringValue()); } } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceProto.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceProto.java index f97d05c388d..677132d0bd2 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceProto.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceProto.java @@ -37,6 +37,8 @@ import datadog.trace.api.Config; import datadog.trace.api.DDTags; import datadog.trace.api.DDTraceId; +import datadog.trace.api.KnownTagCodec; +import datadog.trace.api.KnownTags; import datadog.trace.api.TagMap; import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; @@ -51,11 +53,31 @@ /** Provides optimized writers for OpenTelemetry's "trace.proto" wire protocol. */ public final class OtlpTraceProto { - private static final UTF8BytesString SERVICE_NAME = UTF8BytesString.create("service.name"); private static final UTF8BytesString RESOURCE_NAME = UTF8BytesString.create("resource.name"); private static final UTF8BytesString OPERATION_NAME = UTF8BytesString.create("operation.name"); private static final UTF8BytesString SPAN_TYPE = UTF8BytesString.create("span.type"); + /* + * Keys for tags the tracer intercepts into first-class Metadata fields rather than leaving in the + * TagMap. Those never reach the per-entry projection in writeSpanTag, so their OpenTelemetry name + * is resolved here instead -- once each, since the set is fixed. The names come from the registry, + * so a rename declared in tag-conventions.yaml reaches OTLP with no second mapping table to keep + * in sync. + * + *

http.status_code is deliberately NOT renamed here yet. Its OpenTelemetry name + * (http.response.status_code) is an INT attribute in semantic conventions, but Metadata carries + * the intercepted status as a UTF8BytesString, so renaming it now would ship the right key with + * the wrong wire type -- worse for a semconv consumer than the un-renamed Datadog name, which + * such a consumer simply ignores. The rename follows the change that makes Metadata carry the + * status as an int and hand out the string only on demand. + */ + private static final UTF8BytesString SERVICE_NAME_KEY = otelKey(KnownTags.SERVICE_ID); + + /** The OpenTelemetry-namespace key for a known tag, as named by the registry. */ + private static UTF8BytesString otelKey(long tagId) { + return UTF8BytesString.create(KnownTagCodec.openTelemetryTagOf(tagId)); + } + private OtlpTraceProto() {} /** Records a scoped spans message after its nested span messages have been recorded. */ @@ -131,7 +153,7 @@ public static int recordSpanMessage( writeI64(buf, span.getStartTime() + PendingTrace.getDurationNano(span)); if (!Config.get().getServiceName().equals(span.getServiceName())) { - writeSpanTag(buf, SERVICE_NAME, span.getServiceName()); + writeSpanTag(buf, SERVICE_NAME_KEY, span.getServiceName()); } writeSpanTag(buf, RESOURCE_NAME, span.getResourceName()); writeSpanTag(buf, OPERATION_NAME, span.getOperationName()); @@ -205,20 +227,26 @@ public static void writeSpanId(StreamingBuffer buf, long spanId) { private static void writeSpanTag(StreamingBuffer buf, TagMap.EntryReader tagEntry) { writeTag(buf, 9, LEN_WIRE_TYPE); + // OTLP is the OpenTelemetry wire format, so ask each entry for its name in that namespace — + // the same registry policy the fixed metadata keys above resolve through, with the reader + // supplying its own key for a custom tag the registry does not name. This is the straight + // rename projection only: suppressing a Datadog-only tag from OpenTelemetry, per-exporter + // opt-in, and additional namespaces are deferred to the OpenTelemetry follow-on. + String key = tagEntry.openTelemetryTag(); switch (tagEntry.type()) { case TagMap.EntryReader.BOOLEAN: - writeAttribute(buf, BOOLEAN_ATTRIBUTE, tagEntry.tag(), tagEntry.objectValue()); + writeAttribute(buf, BOOLEAN_ATTRIBUTE, key, tagEntry.objectValue()); break; case TagMap.EntryReader.INT: case TagMap.EntryReader.LONG: - writeAttribute(buf, LONG_ATTRIBUTE, tagEntry.tag(), tagEntry.objectValue()); + writeAttribute(buf, LONG_ATTRIBUTE, key, tagEntry.objectValue()); break; case TagMap.EntryReader.FLOAT: case TagMap.EntryReader.DOUBLE: - writeAttribute(buf, DOUBLE_ATTRIBUTE, tagEntry.tag(), tagEntry.objectValue()); + writeAttribute(buf, DOUBLE_ATTRIBUTE, key, tagEntry.objectValue()); break; default: - writeAttribute(buf, STRING_ATTRIBUTE, tagEntry.tag(), tagEntry.stringValue()); + writeAttribute(buf, STRING_ATTRIBUTE, key, tagEntry.stringValue()); } } From e2c723c7dc8004c7bedde8ac9b5552c08972f1d0 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 2 Sep 2026 08:52:18 -0400 Subject: [PATCH 4/5] Cover the tag registry: name resolution, namespaces, and id partitioning KnownTagsTest pins the registry itself: keyOf/nameOf round-trip for every known tag, many-to-one resolution from either namespace, serial encoding and the trace-level bit, and unknown names resolving to 0. TagMapNamespaceNamesTest covers EntryReader.openTelemetryTag() -- rename, pass-through, and the custom-tag fallback the codec cannot supply. OtlpTraceProtoTest asserts attributes now land under OpenTelemetry names. Co-Authored-By: Claude Opus 5 --- .../trace/OtlpTraceJsonCollectorTest.java | 27 +++ .../core/otlp/trace/OtlpTraceProtoTest.java | 55 ++++- .../java/datadog/trace/api/KnownTagsTest.java | 208 ++++++++++++++++++ .../trace/api/TagMapNamespaceNamesTest.java | 78 +++++++ 4 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java create mode 100644 internal-api/src/test/java/datadog/trace/api/TagMapNamespaceNamesTest.java diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java index 17ec0c0d17f..4ba5745f643 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java @@ -77,6 +77,33 @@ void singleSpanIsEncodedWithHexIdsAndCamelCaseKeys() throws IOException { assertTrue(attrKeys.contains("operation.name")); } + @Test + void tagsAreEmittedUnderTheirOpenTelemetryName() throws IOException { + // The JSON encoder is a second exporter of the same spans, so it must apply the registry's + // OpenTelemetry naming exactly as the protobuf one does: which transport protocol is configured + // must not change the attribute names a backend receives. + AgentSpan agentSpan = TRACER.startSpan("test", "op.tagged"); + agentSpan.setResourceName("GET /api"); + agentSpan.setTag("http.method", "GET"); + agentSpan.setTag("custom.unregistered", "value"); + agentSpan.setSamplingPriority(PrioritySampling.USER_KEEP, SamplingMechanism.DEFAULT); + agentSpan.finish(); + + OtlpTraceJsonCollector collector = new OtlpTraceJsonCollector(); + collector.addTrace(asList((CoreSpan) agentSpan)); + Set attrKeys = attributeKeys(onlySpan(collector.collectTraces())); + + assertTrue( + attrKeys.contains("http.request.method"), + "renamed tag must use its OpenTelemetry name; got " + attrKeys); + assertFalse( + attrKeys.contains("http.method"), + "renamed tag must not also appear under its Datadog name; got " + attrKeys); + assertTrue( + attrKeys.contains("custom.unregistered"), + "a tag the registry does not name passes through unchanged; got " + attrKeys); + } + @Test void spanKindIsEncodedAsInteger() throws IOException { DDSpan span = startAndFinish("op.server", "GET /api", SPAN_KIND_SERVER); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java index aa9d7c7022b..34443582257 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java @@ -25,6 +25,7 @@ import com.google.protobuf.WireFormat; import datadog.trace.api.DD128bTraceId; import datadog.trace.api.DDTraceId; +import datadog.trace.api.KnownTagCodec; import datadog.trace.api.TracePropagationStyle; import datadog.trace.api.sampling.PrioritySampling; import datadog.trace.api.sampling.SamplingMechanism; @@ -1027,11 +1028,47 @@ private static void verifySpan( "attributes must include 'service.name' when service is overridden [" + caseName + "]"); } - // extra user tags must appear as attributes + // extra user tags must appear as attributes, under their OpenTelemetry name when the registry + // declares a rename (e.g. http.method -> http.request.method) and under their Datadog name + // otherwise (pass-through, the default). Asserted EXACTLY, on the one name we expect: accepting + // either would let a rename silently stop firing -- which is precisely how the http.status_code + // rename hid, since that tag is intercepted into span metadata rather than left in the tag map. for (String key : spec.extraTags.keySet()) { + if ("http.status_code".equals(key)) { + // Not a tag-map entry by the time it is serialized: the set path intercepts it into + // Metadata.httpStatusCode, so it never reaches the per-entry projection and keeps the + // Datadog name until Metadata carries the status as an int (see the intercepted-status + // assertion below). + assertTrue( + attrKeys.contains("http.status_code"), + "intercepted status must still be emitted as 'http.status_code' [" + + caseName + + "]; got " + + attrKeys); + continue; + } + long id = KnownTagCodec.keyOf(key); + String otelName = id != 0L ? KnownTagCodec.openTelemetryNameOf(id) : null; + String expected = otelName != null ? otelName : key; assertTrue( - attrKeys.contains(key), - "attributes must include extra tag '" + key + "' [" + caseName + "]"); + attrKeys.contains(expected), + "attributes must include extra tag '" + + key + + "' as '" + + expected + + "' [" + + caseName + + "]; got " + + attrKeys); + if (otelName != null) { + assertFalse( + attrKeys.contains(key), + "renamed tag '" + + key + + "' must not also appear under its Datadog name [" + + caseName + + "]"); + } } if (spec.measured) { @@ -1040,9 +1077,21 @@ private static void verifySpan( "attributes must include '_dd.measured' for measured spans [" + caseName + "]"); } if (spec.httpStatusCode != 0) { + // Intercepted into Metadata.httpStatusCode rather than left in the tag map, so its name comes + // from a key constant in OtlpTraceProto and not from the per-entry projection. Deliberately + // still the DATADOG name: the OpenTelemetry name is an int attribute in semantic conventions + // and Metadata carries the status as a string, so the rename waits on the int-typed Metadata + // rather than shipping the semconv key with a non-semconv type. assertTrue( attrKeys.contains("http.status_code"), "attributes must include 'http.status_code' when set via setHttpStatusCode [" + + caseName + + "]; got " + + attrKeys); + assertFalse( + attrKeys.contains("http.response.status_code"), + "status code must not yet be emitted under its OpenTelemetry name, which semantic" + + " conventions type as an int [" + caseName + "]"); } diff --git a/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java new file mode 100644 index 00000000000..7afe3ac1d94 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java @@ -0,0 +1,208 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.bootstrap.instrumentation.api.Tags; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Parity test for the keyOf substrate: the generated {@link KnownTags} registry + the {@link + * KnownTagCodec.Resolver} it registers. Verifies name ↔ id resolution and the serial/level + * partitioning of an id. {@code keyOf} is many→one (a Datadog or an OpenTelemetry name both + * land on the one id) and the per-namespace accessors take it back out. A tag id is identity only, + * so nothing here depends on how a tag is stored -- or on how it is set. + */ +class KnownTagsTest { + + /** (name, id) pairs across the groups — keyOf returns the id verbatim. */ + static Stream knownTags() { + return Stream.of( + Arguments.of(DDTags.PARENT_ID, KnownTags.DD_PARENT_ID), + Arguments.of(DDTags.BASE_SERVICE, KnownTags.DD_BASE_SERVICE_ID), + Arguments.of(Tags.VERSION, KnownTags.VERSION_ID), + Arguments.of("env", KnownTags.ENV_ID), + Arguments.of(DDTags.DJM_ENABLED, KnownTags.DD_DJM_ENABLED_ID), + Arguments.of(DDTags.DSM_ENABLED, KnownTags.DD_DSM_ENABLED_ID), + Arguments.of(DDTags.TRACER_HOST, KnownTags.DD_TRACER_HOST_ID), + Arguments.of(DDTags.DD_INTEGRATION, KnownTags.DD_INTEGRATION_ID), + Arguments.of(DDTags.DD_SVC_SRC, KnownTags.DD_SVC_SRC_ID), + Arguments.of(Tags.PEER_SERVICE, KnownTags.PEER_SERVICE_ID), + Arguments.of(DDTags.PEER_SERVICE_REMAPPED_FROM, KnownTags.DD_PEER_SERVICE_REMAPPED_FROM_ID), + Arguments.of(Tags.HTTP_METHOD, KnownTags.HTTP_METHOD_ID), + Arguments.of(Tags.HTTP_ROUTE, KnownTags.HTTP_ROUTE_ID), + Arguments.of(Tags.HTTP_URL, KnownTags.HTTP_URL_ID), + 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), + Arguments.of(Tags.DB_TYPE, KnownTags.DB_TYPE_ID), + Arguments.of(Tags.DB_INSTANCE, KnownTags.DB_INSTANCE_ID), + Arguments.of(Tags.DB_USER, KnownTags.DB_USER_ID), + Arguments.of(Tags.DB_OPERATION, KnownTags.DB_OPERATION_ID), + Arguments.of(Tags.DB_POOL_NAME, KnownTags.DB_POOL_NAME_ID)); + } + + /** + * (otelName, canonicalId, datadogName) — the OpenTelemetry name resolves (keyOf) to the canonical + * tag's id; datadogNameOf returns the Datadog name and openTelemetryNameOf returns the OTel name. + */ + static Stream otelNamedTags() { + return Stream.of( + Arguments.of("http.request.method", KnownTags.HTTP_METHOD_ID, "http.method"), + Arguments.of( + "http.response.status_code", KnownTags.HTTP_STATUS_CODE_ID, "http.status_code"), + Arguments.of("url.full", KnownTags.HTTP_URL_ID, "http.url"), + Arguments.of("server.address", KnownTags.HTTP_HOSTNAME_ID, "http.hostname"), + Arguments.of("user_agent.original", KnownTags.HTTP_USERAGENT_ID, "http.useragent"), + Arguments.of("url.query", KnownTags.HTTP_QUERY_STRING_ID, "http.query.string"), + Arguments.of("db.system", KnownTags.DB_TYPE_ID, "db.type"), + Arguments.of("db.operation.name", KnownTags.DB_OPERATION_ID, "db.operation"), + Arguments.of("db.query.text", KnownTags.DB_STATEMENT_ID, "db.statement"), + Arguments.of("service.name", KnownTags.SERVICE_ID, "service")); + } + + /** + * Trace-level tags (live on the TraceSegment's TagMap) — their id carries the LEVEL_TRACE bit. + */ + static Stream traceLevelTags() { + return Stream.of( + Arguments.of(KnownTags.DD_BASE_SERVICE_ID), + Arguments.of(KnownTags.VERSION_ID), + Arguments.of(KnownTags.ENV_ID), + Arguments.of(KnownTags.LANGUAGE_ID), + Arguments.of(KnownTags.RUNTIME_ID), + Arguments.of(KnownTags.DD_TRACER_HOST_ID), + Arguments.of(KnownTags.DD_DJM_ENABLED_ID)); + } + + /** Span-level tags — their id leaves the LEVEL_TRACE bit clear. */ + static Stream spanLevelTags() { + return Stream.of( + Arguments.of(KnownTags.HTTP_METHOD_ID), + Arguments.of(KnownTags.HTTP_URL_ID), + Arguments.of(KnownTags.DB_TYPE_ID), + Arguments.of(KnownTags.COMPONENT_ID), + Arguments.of(KnownTags.SPAN_KIND_ID), + Arguments.of(KnownTags.PEER_SERVICE_ID)); + } + + @ParameterizedTest + @MethodSource("knownTags") + void keyOfResolvesNameToId(String name, long id) { + assertEquals(id, KnownTagCodec.keyOf(name), "keyOf(" + name + ")"); + } + + @ParameterizedTest + @MethodSource("knownTags") + void nameOfResolvesIdToName(String name, long id) { + assertEquals(name, KnownTagCodec.nameOf(id), "nameOf(" + name + ")"); + } + + @ParameterizedTest + @MethodSource("otelNamedTags") + void otelNameResolvesToCanonicalId(String otelName, long id, String datadogName) { + // Inbound (keyOf) is many->one: both names land on the same canonical id. + assertEquals(id, KnownTagCodec.keyOf(otelName), "keyOf(" + otelName + ")"); + assertEquals(id, KnownTagCodec.keyOf(datadogName), "keyOf(" + datadogName + ")"); + } + + @ParameterizedTest + @MethodSource("otelNamedTags") + void namespaceAccessorsReturnPerNamespaceName(String otelName, long id, String datadogName) { + assertEquals(datadogName, KnownTagCodec.datadogNameOf(id), "datadogNameOf"); + assertEquals(otelName, KnownTagCodec.openTelemetryNameOf(id), "openTelemetryNameOf"); + // nameOf stays the Datadog name -- outbound is namespace-specific, not normalized to OTel. + assertEquals(datadogName, KnownTagCodec.nameOf(id), "nameOf stays Datadog"); + } + + @ParameterizedTest + @MethodSource("otelNamedTags") + void openTelemetryTagOfReturnsTheRename(String otelName, long id, String datadogName) { + assertEquals(otelName, KnownTagCodec.openTelemetryTagOf(id), "openTelemetryTagOf"); + } + + @Test + void openTelemetryTagOfPassesThroughWhenThereIsNoRename() { + // http.route declares no otel-name, so the OpenTelemetry namespace emits the Datadog name. + assertNull(KnownTagCodec.openTelemetryNameOf(KnownTags.HTTP_ROUTE_ID), "no declared rename"); + assertEquals( + KnownTagCodec.nameOf(KnownTags.HTTP_ROUTE_ID), + KnownTagCodec.openTelemetryTagOf(KnownTags.HTTP_ROUTE_ID), + "pass-through falls back to the Datadog name"); + } + + @Test + void openTelemetryTagOfIsNullForAnUnknownId() { + // A custom tag has no registry name in any namespace; only its holder knows its key. + assertNull(KnownTagCodec.openTelemetryTagOf(0L)); + } + + @Test + void tagsWithoutOtelNameReturnNull() { + assertNull(KnownTagCodec.openTelemetryNameOf(KnownTags.HTTP_ROUTE_ID)); // no OTel name declared + assertNull(KnownTagCodec.openTelemetryNameOf(0L)); // unknown id + } + + @Test + void unknownNamesResolveToZero() { + assertEquals(0L, KnownTagCodec.keyOf("definitely.not.a.known.tag")); + assertEquals(0L, KnownTagCodec.keyOf("http.statuscode")); // close-but-not-listed + assertEquals(0L, KnownTagCodec.keyOf("")); + } + + @Test + void unknownIdsResolveToNullName() { + assertNull(KnownTagCodec.nameOf(0L)); + assertNull(KnownTagCodec.nameOf(KnownTagCodec.makeTagId(9999))); // serial with no assigned tag + } + + @Test + void globalSerialsAreUnique() { + List serials = new ArrayList<>(); + knownTags().forEach(a -> serials.add((long) KnownTagCodec.serialNum((Long) a.get()[1]))); + assertEquals(serials.size(), new HashSet<>(serials).size(), "globalSerials must be unique"); + } + + @ParameterizedTest + @MethodSource("traceLevelTags") + void traceLevelTagsCarryLevelBit(long id) { + assertTrue(KnownTagCodec.isTraceLevel(id), "isTraceLevel"); + } + + @ParameterizedTest + @MethodSource("spanLevelTags") + void spanLevelTagsClearLevelBit(long id) { + assertFalse(KnownTagCodec.isTraceLevel(id), "not trace-level"); + } + + @Test + void levelBitCompositionRoundTrips() { + long spanId = KnownTagCodec.makeTagId(300); // no level bit + assertFalse(KnownTagCodec.isTraceLevel(spanId)); + long traceId = KnownTagCodec.traceLevel(spanId); + assertTrue(KnownTagCodec.isTraceLevel(traceId)); + // level bit is orthogonal to the serial — it survives setting the bit + assertEquals(KnownTagCodec.serialNum(spanId), KnownTagCodec.serialNum(traceId)); + assertEquals(traceId, KnownTagCodec.traceLevel(traceId), "traceLevel is idempotent"); + } + + @Test + void serialEncodingRoundTrips() { + long id = KnownTagCodec.makeTagId(263); + assertEquals(263, KnownTagCodec.serialNum(id)); + assertFalse(KnownTagCodec.isTraceLevel(id)); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapNamespaceNamesTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapNamespaceNamesTest.java new file mode 100644 index 00000000000..b38b8d3af80 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapNamespaceNamesTest.java @@ -0,0 +1,78 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * {@link TagMap.EntryReader#openTelemetryTag()} — the reader's view of its tag in the OpenTelemetry + * namespace. {@link KnownTagCodec#openTelemetryTagOf} owns the naming and returns null for a tag it + * does not know; the reader completes that by falling back to its own key, which is the one thing + * the codec cannot do. + */ +class TagMapNamespaceNamesTest { + + @Test + void renamedTagReadsUnderItsOpenTelemetryName() { + assertEquals("http.request.method", otelTagOf("http.method", "GET")); + } + + @Test + void tagWithoutARenamePassesThroughUnderItsDatadogName() { + // http.route declares no otel-name, so the OpenTelemetry namespace keeps the Datadog spelling. + assertEquals("http.route", otelTagOf("http.route", "/orders/:id")); + } + + @Test + void customTagFallsBackToItsOwnKey() { + // Not in the registry at all: the codec has no name for it, so the reader supplies its key. + assertEquals("my.app.tenant", otelTagOf("my.app.tenant", "acme")); + } + + @Test + void anOpenTelemetrySpellingNormalizesToTheOneName() { + // keyOf is many->one, so an entry written under the OTel name resolves to the same tag and + // reads back under that name -- not under two different ones depending on how it was written. + assertEquals("http.request.method", otelTagOf("http.request.method", "POST")); + assertEquals( + otelTagOf("http.method", "GET"), + otelTagOf("http.request.method", "POST"), + "both spellings of one tag must read under the same OpenTelemetry name"); + } + + @Test + void openTelemetryNameDiffersFromTheDatadogNameForARenamedTag() { + TagMap map = TagMap.create(); + map.set("http.method", "GET"); + TagMap.EntryReader reader = readerFor(map, "http.method"); + assertEquals("http.method", reader.tag(), "tag() stays the key as written"); + assertNotEquals( + reader.tag(), reader.openTelemetryTag(), "a rename must actually change the emitted name"); + } + + private static String otelTagOf(String tag, Object value) { + TagMap map = TagMap.create(); + map.set(tag, value); + return readerFor(map, tag).openTelemetryTag(); + } + + /** + * The entry for {@code tag}, having first checked that iteration agrees with it — the iterator + * may hand out a reused flyweight rather than the entry itself, so the two paths are worth + * pinning together. + */ + private static TagMap.EntryReader readerFor(TagMap map, String tag) { + Map otelByTag = new HashMap<>(); + map.forEach(reader -> otelByTag.put(reader.tag(), reader.openTelemetryTag())); + + TagMap.Entry entry = map.getEntry(tag); + assertEquals( + otelByTag.get(tag), + entry.openTelemetryTag(), + "iteration and getEntry must agree on the OpenTelemetry name for " + tag); + return entry; + } +} From 8d6076119006cb364cfde7aa8951aab3c48629b4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 2 Sep 2026 10:52:31 -0400 Subject: [PATCH 5/5] Register every declared tag, not only layout-reachable ones The registry was built by resolving concrete span types, so a tag reached the union only through a type that could carry it. A mixin attaches via its applies: targets, and ci_visibility applies to `test`, which the conventions do not model yet -- so test.name, test.suite, test.status and test.framework were dropped, silently. keyOf reported live CI Visibility tags as unknown, and nothing in the generated output showed the four declarations had gone anywhere. An id is identity, and identity does not depend on layout: a declared tag gets an id whether or not a modeled span type can carry it today. So the registry is now built from declarationGroups() -- trace-level, span types and every mixin -- which is also what makes mixins behave uniformly rather than working only when their target happens to be modeled. The layout gap is real, just not fatal, so the generator reports it: resolved-tags.txt names each mixin whose applies: target is unmodeled and says its tags are registered but occupy no per-type slot. Declaring tags ahead of the span type that will carry them is a legitimate intermediate state; being unable to see it is not. Registry grows 47 -> 51 tags. Serials are assigned over the sorted name list, so the four insertions shift the ids of tags sorting after them -- fine, since ids are in-process with no cross-release stability. Co-Authored-By: Claude Opus 5 --- .../gradle/plugin/tags/TagConventions.kt | 31 ++++++++++-- .../datadog/gradle/plugin/tags/TagRegistry.kt | 2 +- .../plugin/tags/TagRegistryGenerator.kt | 10 ++++ .../java/datadog/trace/api/KnownTags.java | 48 ++++++++++++++++--- internal-api/src/generated/resolved-tags.txt | 5 ++ internal-api/src/generated/tag-assignment.txt | 10 ++-- .../java/datadog/trace/api/KnownTagsTest.java | 23 +++++++++ tag-conventions.yaml | 4 +- 8 files changed, 117 insertions(+), 16 deletions(-) diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt index 65da0844f04..4801845352d 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt @@ -100,14 +100,35 @@ private constructor( return groups } - /** Full stored-tag universe (concrete span types' resolves + trace-level), de-duped by name. */ - fun allStoredTags(): List { + /** + * Every tag DECLARED in the conventions -- trace-level, span types (abstract included) and every + * mixin -- de-duped by name. Sourced from [declarationGroups] rather than from resolving concrete + * span types, because an id is identity and identity does not depend on layout: a tag declared by + * a mixin whose `applies:` target is not modeled yet still gets an id. Resolving instead would + * drop such a declaration silently, which is how the ci_visibility tags went missing. + */ + fun allDeclaredTags(): List { val union = LinkedHashMap() - for (type in concreteTypes()) for (t in resolve(type)) union.putIfAbsent(t.name, t) - for (t in traceLevel) union.putIfAbsent(t.name, t) + for (g in declarationGroups()) for (t in g.tags) union.putIfAbsent(t.name, t) return union.values.toList() } + /** + * Mixin `applies:` targets that name no span type modeled here, as (mixin, missing types). A tag + * id is identity and does not depend on layout, so such a mixin's tags are still registered -- + * this is a LAYOUT gap, not lost data: the mixin contributes to no type's resolved set, so its + * tags occupy no per-type slot until the type is modeled. Reported rather than fatal, because + * declaring tags ahead of the span type that will carry them is a legitimate intermediate state; + * what is not acceptable is it being invisible. + */ + fun unmodeledAppliesTargets(): List>> = + mixins.values + .sortedBy { it.name } + .mapNotNull { mx -> + val missing = mx.appliesTo.filter { it !in spanTypes }.sorted() + if (missing.isEmpty()) null else mx.name to missing + } + /** * Full composition for a type as (origin, tag) pairs, in composition order and NOT de-duped, so a * tag contributed by more than one source shows up more than once. Origin is the contributing @@ -174,7 +195,7 @@ private constructor( } /** - * A tag is de-duped by name across span types / mixins (see [resolve] / [allStoredTags]), so its + * A tag is de-duped by name across span types / mixins (see [resolve] / [allDeclaredTags]), so its * whole identity — including the OpenTelemetry name — must be declared consistently everywhere it * appears. `http.url` on `http.server` and `http.client`, for instance, is ONE tag: it can carry * exactly one otel-name. Without this check, two conflicting declarations would silently collapse diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt index 726295a1203..1ba8e43bfa5 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt @@ -47,7 +47,7 @@ class TagRegistry private constructor(val tags: List) { // Stable order (by name) so serials -- and therefore ids -- are a pure function of the input. val tags = - conv.allStoredTags().sortedBy { it.name }.mapIndexed { i, t -> + conv.allDeclaredTags().sortedBy { it.name }.mapIndexed { i, t -> val serial = FIRST_SERIAL + i val traceLevel = t.name in traceNames Tag( diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt index 8f85b6957bb..f30d1c6e874 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt @@ -43,6 +43,16 @@ object TagRegistryGenerator { private fun resolvedReport(conv: TagConventions): String { val resolved = StringBuilder() resolved.appendLine("# Resolved per-type tag sets (concrete span types).") + val unmodeled = conv.unmodeledAppliesTargets() + if (unmodeled.isNotEmpty()) { + resolved.appendLine("#") + resolved.appendLine("# LAYOUT GAP: these mixins apply to span types not modeled here, so they") + resolved.appendLine("# contribute to no resolved set below. Their tags ARE registered (an id is") + resolved.appendLine("# identity, not layout) -- they simply occupy no per-type slot yet.") + for ((mixin, missing) in unmodeled) { + resolved.appendLine("# $mixin -> ${missing.joinToString(", ")}") + } + } for (type in conv.concreteTypes()) { val tags = conv.resolve(type) resolved.appendLine() diff --git a/internal-api/src/generated/java/datadog/trace/api/KnownTags.java b/internal-api/src/generated/java/datadog/trace/api/KnownTags.java index f1c9fa4bea1..7f6446fccd7 100644 --- a/internal-api/src/generated/java/datadog/trace/api/KnownTags.java +++ b/internal-api/src/generated/java/datadog/trace/api/KnownTags.java @@ -187,13 +187,29 @@ public final class KnownTags { public static final long SPAN_KIND_ID = 0x002D000000000000L; // makeTagId(serial=45) + public static final String TEST_FRAMEWORK_NAME = "test.framework"; + public static final long TEST_FRAMEWORK_ID = 0x002E000000000000L; + // makeTagId(serial=46) + + public static final String TEST_NAME = "test.name"; + public static final long TEST_NAME_ID = 0x002F000000000000L; + // makeTagId(serial=47) + + public static final String TEST_STATUS_NAME = "test.status"; + public static final long TEST_STATUS_ID = 0x0030000000000000L; + // makeTagId(serial=48) + + public static final String TEST_SUITE_NAME = "test.suite"; + public static final long TEST_SUITE_ID = 0x0031000000000000L; + // makeTagId(serial=49) + public static final String VERSION_NAME = "version"; - public static final long VERSION_ID = 0x002E000000000004L; - // makeTagId(serial=46) + trace-level + public static final long VERSION_ID = 0x0032000000000004L; + // makeTagId(serial=50) + trace-level public static final String VIEW_NAME = "view.name"; - public static final long VIEW_NAME_ID = 0x002F000000000000L; - // makeTagId(serial=47) + public static final long VIEW_NAME_ID = 0x0033000000000000L; + // makeTagId(serial=51) // ---- serial numbers ---- static final int DD_APPSEC_ENABLED_SERIAL_NUM = 1; @@ -241,8 +257,12 @@ public final class KnownTags { static final int SERVLET_CONTEXT_SERIAL_NUM = 43; static final int SERVLET_PATH_SERIAL_NUM = 44; static final int SPAN_KIND_SERIAL_NUM = 45; - static final int VERSION_SERIAL_NUM = 46; - static final int VIEW_NAME_SERIAL_NUM = 47; + static final int TEST_FRAMEWORK_SERIAL_NUM = 46; + static final int TEST_NAME_SERIAL_NUM = 47; + static final int TEST_STATUS_SERIAL_NUM = 48; + static final int TEST_SUITE_SERIAL_NUM = 49; + static final int VERSION_SERIAL_NUM = 50; + static final int VIEW_NAME_SERIAL_NUM = 51; private static final String[] KEYOF_NAMES = { DD_APPSEC_ENABLED_NAME, @@ -290,6 +310,10 @@ public final class KnownTags { SERVLET_CONTEXT_NAME, SERVLET_PATH_NAME, SPAN_KIND_NAME, + TEST_FRAMEWORK_NAME, + TEST_NAME, + TEST_STATUS_NAME, + TEST_SUITE_NAME, VERSION_NAME, VIEW_NAME, "db.operation.name", @@ -349,6 +373,10 @@ public final class KnownTags { SERVLET_CONTEXT_ID, SERVLET_PATH_ID, SPAN_KIND_ID, + TEST_FRAMEWORK_ID, + TEST_NAME_ID, + TEST_STATUS_ID, + TEST_SUITE_ID, VERSION_ID, VIEW_NAME_ID, DB_OPERATION_ID, @@ -478,6 +506,14 @@ public String nameOf(long tagId) { return SERVLET_PATH_NAME; case SPAN_KIND_SERIAL_NUM: return SPAN_KIND_NAME; + case TEST_FRAMEWORK_SERIAL_NUM: + return TEST_FRAMEWORK_NAME; + case TEST_NAME_SERIAL_NUM: + return TEST_NAME; + case TEST_STATUS_SERIAL_NUM: + return TEST_STATUS_NAME; + case TEST_SUITE_SERIAL_NUM: + return TEST_SUITE_NAME; case VERSION_SERIAL_NUM: return VERSION_NAME; case VIEW_NAME_SERIAL_NUM: diff --git a/internal-api/src/generated/resolved-tags.txt b/internal-api/src/generated/resolved-tags.txt index 25aa10553d7..605253bc193 100644 --- a/internal-api/src/generated/resolved-tags.txt +++ b/internal-api/src/generated/resolved-tags.txt @@ -1,4 +1,9 @@ # Resolved per-type tag sets (concrete span types). +# +# LAYOUT GAP: these mixins apply to span types not modeled here, so they +# contribute to no resolved set below. Their tags ARE registered (an id is +# identity, not layout) -- they simply occupy no per-type slot yet. +# ci_visibility -> test db.client (22 tags): - _dd.parent_id diff --git a/internal-api/src/generated/tag-assignment.txt b/internal-api/src/generated/tag-assignment.txt index 6fb20ec5f8a..e3b45edc9ec 100644 --- a/internal-api/src/generated/tag-assignment.txt +++ b/internal-api/src/generated/tag-assignment.txt @@ -1,4 +1,4 @@ -# Tag id assignment. tags=47 +# Tag id assignment. tags=51 # TAGS serial lvl id required name 1 T 0x0001000000000004 recommended _dd.appsec.enabled @@ -46,8 +46,12 @@ 43 - 0x002B000000000000 optional servlet.context 44 - 0x002C000000000000 optional servlet.path 45 - 0x002D000000000000 required span.kind - 46 T 0x002E000000000004 recommended version - 47 - 0x002F000000000000 recommended view.name + 46 - 0x002E000000000000 recommended test.framework + 47 - 0x002F000000000000 recommended test.name + 48 - 0x0030000000000000 recommended test.status + 49 - 0x0031000000000000 recommended test.suite + 50 T 0x0032000000000004 recommended version + 51 - 0x0033000000000000 recommended view.name # OPENTELEMETRY NAMES. keyOf(otelName) resolves to the canonical tag's id; nameOf still # returns the Datadog name, openTelemetryNameOf returns the name below. (No distinct id.) 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 7afe3ac1d94..053f1c963d1 100644 --- a/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java @@ -169,6 +169,29 @@ void unknownIdsResolveToNullName() { assertNull(KnownTagCodec.nameOf(KnownTagCodec.makeTagId(9999))); // serial with no assigned tag } + /** + * A mixin declares tags for the span types its {@code applies:} names. {@code ci_visibility} + * applies to {@code test}, which the conventions do not model yet -- so these tags belong to no + * concrete type's resolved set. They must still be registered: an id is identity, and identity + * does not depend on layout. Building the registry by resolving concrete types instead dropped + * all four silently, leaving keyOf to report live CI Visibility tags as unknown. + */ + @ParameterizedTest + @MethodSource("declarationOnlyMixinTags") + void mixinTagsAreRegisteredEvenWhenTheirSpanTypeIsNotModeled(String name, long id) { + assertEquals(id, KnownTagCodec.keyOf(name), "keyOf(" + name + ")"); + assertEquals(name, KnownTagCodec.nameOf(id), "nameOf(" + name + ")"); + } + + /** Tags reachable only through a mixin whose {@code applies:} target is not modeled. */ + static Stream declarationOnlyMixinTags() { + return Stream.of( + Arguments.of(KnownTags.TEST_NAME, KnownTags.TEST_NAME_ID), + Arguments.of(KnownTags.TEST_SUITE_NAME, KnownTags.TEST_SUITE_ID), + Arguments.of(KnownTags.TEST_STATUS_NAME, KnownTags.TEST_STATUS_ID), + Arguments.of(KnownTags.TEST_FRAMEWORK_NAME, KnownTags.TEST_FRAMEWORK_ID)); + } + @Test void globalSerialsAreUnique() { List serials = new ArrayList<>(); diff --git a/tag-conventions.yaml b/tag-conventions.yaml index a2aa808acb7..861d11259b0 100644 --- a/tag-conventions.yaml +++ b/tag-conventions.yaml @@ -128,7 +128,9 @@ mixins: # 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 - # per-span tags). Applies to the `test` span type (not modeled here yet). + # per-span tags). Applies to the `test` span type, which is not modeled here yet — so these tags + # get ids (identity does not depend on layout) but contribute to no type's resolved set until it + # is. The generator reports that gap in resolved-tags.txt. ci_visibility: enabled_by: dd.civisibility.enabled applies: [ test ]