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/8] 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/8] 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/8] 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/8] 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/8] 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 ] From d7973fe4909c9840788818f5f41f4360c9dcb36d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 3 Sep 2026 09:55:37 -0400 Subject: [PATCH 6/8] Add the Java overlay: intercepted + reserved tag classification The domain spec (tag-conventions.yaml) deliberately models only what a tag IS, and defers set-path routing to "a per-language overlay alongside this file". This adds that overlay for Java, restoring the intercepted/reserved taxonomy from the pre-OTLP-split generator work. intercepted: domain tags this tracer also routes (named, not redeclared -- they already have an identity; this only flags it) reserved: keys accepted by setTag but diverted to a span field or a trace directive, which exist only because this tracer routes them and so have no place in a cross-language contract Both get bit 3 of the tag id, INTERCEPTED. The point is speed: a TagMap entry carries its own tag id, so screening a bundle for anything the interceptor cares about is a mask test on an id already in hand -- no name lookup, no side table. An earlier version of this bit was deleted because it could disagree with TagInterceptor's switch. It returns because the agreement becomes a test rather than a convention; that test lands with the dispatch rework that consumes it. Nothing here records whether a routed tag is also STORED. That is decided per call from the value -- http.url is routed and stored, manual.keep is consumed only when its value coerces to a boolean -- so it is not a property of the tag, and a static flag mirroring it would be the same drift in a new place. Reserved serials are assigned after every domain serial, so a Java-only key cannot renumber the domain block: of the 51 existing tags, only the 8 named in `intercepted` change at all, and only in that flag bit. --- .../plugin/tags/GenerateKnownTagsTask.kt | 12 +- .../gradle/plugin/tags/KnownTagsEmitter.kt | 11 +- .../datadog/gradle/plugin/tags/TagOverlay.kt | 105 ++++++++ .../datadog/gradle/plugin/tags/TagRegistry.kt | 110 +++++++- .../plugin/tags/TagRegistryGenerator.kt | 60 +++-- .../plugin/tags/TagRegistryGeneratorPlugin.kt | 3 + .../gradle/plugin/tags/VerifyKnownTagsTask.kt | 12 +- internal-api/build.gradle.kts | 1 + .../java/datadog/trace/api/KnownTags.java | 237 +++++++++++++----- internal-api/src/generated/tag-assignment.txt | 36 ++- .../java/datadog/trace/api/KnownTagCodec.java | 39 ++- tag-conventions.java.yaml | 77 ++++++ 12 files changed, 594 insertions(+), 109 deletions(-) create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagOverlay.kt create mode 100644 tag-conventions.java.yaml diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt index fadac53acf9..00ab3f78d4f 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt @@ -7,6 +7,7 @@ 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.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity @@ -14,7 +15,7 @@ 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]; + * {@code tag-conventions.yaml} and this language's routing overlay. The actual emit lives in [TagRegistryGenerator]; * this task just wires the inputs/outputs so Gradle can cache and up-to-date-check it. */ @CacheableTask @@ -23,13 +24,20 @@ abstract class GenerateKnownTagsTask @Inject constructor(objects: ObjectFactory) @get:PathSensitive(PathSensitivity.NONE) val domainYaml: RegularFileProperty = objects.fileProperty() + /** This language's routing overlay. Optional -- absent means "no reserved keys". */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + val overlayYaml: RegularFileProperty = objects.fileProperty() + @get:OutputDirectory val destinationDirectory: DirectoryProperty = objects.directoryProperty() @TaskAction fun generate() { val outDir = destinationDirectory.get().asFile - TagRegistryGenerator.generate(domainYaml.get().asFile, outDir) + TagRegistryGenerator.generate( + domainYaml.get().asFile, overlayYaml.orNull?.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 index 1e5a80992e0..4b4dade5be0 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/KnownTagsEmitter.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/KnownTagsEmitter.kt @@ -5,7 +5,7 @@ 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 + * derivation comment — then the `_SERIAL_NUM` constants, the * `StringIndex.EmbeddingSupport` keyOf table, the `serialNum` switch `nameOf`, and resolver * registration. */ @@ -46,7 +46,7 @@ object KnownTagsEmitter { 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("// DO NOT EDIT. Sources: tag-conventions.yaml + tag-conventions.java.yaml.") b.appendLine("public final class $className {") b.appendLine() @@ -60,10 +60,13 @@ object KnownTagsEmitter { b.appendLine() } - // Serial numbers (globalSerial per tag) — package-private, consumed by the resolver switch. + // Serial numbers (globalSerial per tag). Public: besides the resolver switch below, they are + // the case labels of the tracer's set-path dispatch switch, which lives in another package. An + // int switch over dense serials compiles to a tableswitch, where the equivalent switch over tag + // NAMES is a lookupswitch on string hashes plus an equals() per hit. b.appendLine(" // ---- serial numbers ----") for (t in reg.tags) { - b.appendLine(" static final int ${serialC(t.name)} = ${t.serial};") + b.appendLine(" public static final int ${serialC(t.name)} = ${t.serial};") } b.appendLine() diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagOverlay.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagOverlay.kt new file mode 100644 index 00000000000..547b632bb40 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagOverlay.kt @@ -0,0 +1,105 @@ +package datadog.gradle.plugin.tags + +/** + * Parsed per-language overlay (`tag-conventions-java.yaml`): the keys that exist only because this + * tracer ROUTES them on the set-path, and so need an identity to dispatch on but no place in the + * language-agnostic domain spec. + * + *

Deliberately separate from [TagConventions] rather than a section of it. The domain model's + * value is that it knows only structure and semantics; folding one language's routing vocabulary + * into it would make it not that. Composition happens in [TagRegistry], which is already the layer + * that turns declarations into ids. + * + *

An overlay tag carries a name and a type and nothing else. It has no `required` grade (that + * grades how a tag is STORED, and a reserved key is an identity for dispatch), no `otel-name` (a + * domain concern), and — pointedly — no flag saying whether it is also stored, because that is + * decided per call from the value. See the file header and KnownTagCodec for why a static bit there + * is drift rather than information. + */ +class TagOverlay +private constructor( + val reserved: List, + /** + * Names of DOMAIN tags this tracer also routes. Names, not declarations: the tag's identity comes + * from the domain spec and is not duplicated here -- being listed only adds the INTERCEPTED flag + * to the id it already has. + */ + val intercepted: List, +) { + /** One reserved key: an identity for set-path dispatch. */ + data class Tag(val name: String, val type: String) + + companion object { + /** An overlay with nothing in it — the shape used when a language declares no reserved keys. */ + fun empty(): TagOverlay = TagOverlay(emptyList(), emptyList()) + + @Suppress("UNCHECKED_CAST") + fun parse(root: Map): TagOverlay { + val raw = (root["reserved"] as? Map)?.get("tags") as? List> + val decls = raw ?: emptyList() + decls.forEach { rejectDomainFields(it) } + val tags = decls.map { Tag(name = parseDdName(it), type = (it["type"] as? String) ?: "string") } + validateNoDuplicates(tags) + val intercepted = parseIntercepted(root) + return TagOverlay(tags, intercepted) + } + + /** + * The `intercepted` list: plain domain tag names, so a string list rather than declarations. + * A non-string entry (an accidental `{ dd-name: x }` mapping, say) must fail rather than + * `toString()` into a name that matches no domain tag and then silently flags nothing. + */ + @Suppress("UNCHECKED_CAST") + private fun parseIntercepted(root: Map): List { + val raw = (root["intercepted"] as? Map)?.get("tags") as? List + val names = + (raw ?: emptyList()).map { e -> + require(e is String && e.isNotBlank()) { + "intercepted entry is not a tag name: '$e'. List domain tag names as plain strings; " + + "a tag that needs its own identity goes under `reserved:` instead." + } + e + } + val seen = HashSet() + for (n in names) require(seen.add(n)) { "intercepted names '$n' more than once" } + return names + } + + /** + * The same routing key declared twice. Harmless to the id assignment (the union de-dupes), but + * it means one of the two declarations is dead and nobody can tell which was intended. + */ + private fun validateNoDuplicates(tags: List) { + val seen = HashSet() + for (t in tags) { + require(seen.add(t.name)) { "overlay declares reserved key '${t.name}' more than once" } + } + } + + /** + * Domain-only fields on an overlay tag. `required` grades how a tag is STORED and `otel-name` is + * a cross-language naming decision; neither means anything for a routing identity. Ignoring them + * silently would let someone believe they had graded a reserved key as dense, or given it an + * OpenTelemetry name that nothing will ever emit. A key that genuinely needs either belongs in + * the domain spec. + */ + private fun rejectDomainFields(m: Map) { + for (key in DOMAIN_ONLY_FIELDS) { + require(!m.containsKey(key)) { + "reserved key '${m["dd-name"]}' declares '$key', which is a domain-spec field and has no " + + "meaning for a set-path routing identity. Declare the tag in tag-conventions.yaml if " + + "it needs one." + } + } + } + + private val DOMAIN_ONLY_FIELDS = listOf("required", "otel-name") + + /** Mirrors [TagConventions] — a missing or non-string name would flow on as the literal "null". */ + private fun parseDdName(m: Map): String { + val raw = m["dd-name"] + require(raw is String && raw.isNotBlank()) { "reserved key declaration has no valid dd-name: $m" } + return raw + } + } +} 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 1ba8e43bfa5..c8daf5488cb 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt @@ -25,45 +25,145 @@ class TagRegistry private constructor(val tags: List) { val traceLevel: Boolean, val id: Long, val otelName: String? = null, + /** + * True when this tracer routes the tag on the set-path -- the INTERCEPTED flag is set in [id]. + * Says nothing about whether the tag is also STORED: that is decided per call from the value + * (`http.url` is routed and stored; `manual.keep` is consumed only when its value coerces to a + * boolean), so it is not a property of the tag at all. + */ + val intercepted: Boolean = false, + /** + * True for a tag declared by the per-language overlay (a set-path ROUTING identity) rather than + * by the domain spec. Affects nothing about the id -- an overlay tag's id is an ordinary + * identity -- it only records where the declaration came from, so the reports can show the two + * blocks apart and the overlap guard has something to check. + */ + val overlay: Boolean = false, ) 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 INTERCEPTED = 1L shl 3 // low-32 carve bit 3; mirrors KnownTagCodec.INTERCEPTED const val TRACE_LAYER = "" + /** + * The `required` grade recorded for an overlay tag. A reserved key has no storage grade -- it is + * an identity for set-path dispatch -- so it gets its own value rather than being filed under + * `optional`, which would read as "stored, but rarely". + */ + const val RESERVED = "reserved" + /** * 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 { + fun encode(serial: Int, traceLevel: Boolean, intercepted: Boolean = false): Long { var id = serial.toLong() shl 48 if (traceLevel) id = id or LEVEL_TRACE + if (intercepted) id = id or INTERCEPTED return id } - fun build(conv: TagConventions): TagRegistry { + fun build(conv: TagConventions): TagRegistry = build(conv, TagOverlay.empty()) + + /** + * Assigns serials over the domain declarations and then the overlay's reserved keys. + * + *

Domain tags are numbered FIRST, sorted by name, exactly as they are without an overlay. So + * the domain block's serials -- and therefore its ids and its generated output -- stay a pure + * function of tag-conventions.yaml alone: adding a Java-only reserved key cannot renumber the + * shared spec. Overlay serials continue from there, also sorted by name, so they too are stable + * against anything but a change to the overlay itself. + */ + fun build(conv: TagConventions, overlay: TagOverlay): TagRegistry { val traceNames = conv.traceLevelTags().map { it.name }.toSet() + val routedDomain = overlay.intercepted.toSet() // Stable order (by name) so serials -- and therefore ids -- are a pure function of the input. - val tags = + val domain = conv.allDeclaredTags().sortedBy { it.name }.mapIndexed { i, t -> val serial = FIRST_SERIAL + i val traceLevel = t.name in traceNames + val intercepted = t.name in routedDomain Tag( t.name, t.type, t.required, serial, traceLevel, - id = encode(serial, traceLevel), - otelName = t.otelName) + id = encode(serial, traceLevel, intercepted), + otelName = t.otelName, + intercepted = intercepted) + } + + validateNoOverlap(domain, overlay) + validateIntercepted(domain, overlay) + + val reserved = + overlay.reserved.sortedBy { it.name }.mapIndexed { i, t -> + val serial = FIRST_SERIAL + domain.size + i + Tag( + t.name, + t.type, + RESERVED, + serial, + traceLevel = false, + id = encode(serial, traceLevel = false, intercepted = true), + otelName = null, + intercepted = true, + overlay = true) } + val tags = domain + reserved validateOtelNames(tags) return TagRegistry(tags) } + /** + * A reserved key that the domain spec already declares. Both declarations are for one tag, so the + * overlay's would mint a SECOND id for it -- two identities, and dispatch would key off whichever + * the caller happened to resolve. The eight interceptor keys that are domain tags + * (db.statement, service, peer.service, servlet.context, http.status_code, http.method, + * http.url, span.kind) must therefore be absent from the overlay, and this is what enforces it. + * + *

An OpenTelemetry name counts as taken too: keyOf is many->one, so a reserved key colliding + * with a domain tag's otel-name would make keyOf(name) ambiguous in exactly the same way. + */ + private fun validateNoOverlap(domain: List, overlay: TagOverlay) { + val byName = domain.associateBy { it.name } + val byOtel = domain.mapNotNull { t -> t.otelName?.let { it to t.name } }.toMap() + for (t in overlay.reserved) { + require(t.name !in byName) { + "reserved key '${t.name}' is already declared in the domain spec (tag-conventions.yaml), " + + "so it already has an id; declaring it again in the overlay would mint a second " + + "identity for one tag. Remove it from the overlay." + } + byOtel[t.name]?.let { canonical -> + throw IllegalArgumentException( + "reserved key '${t.name}' collides with the OpenTelemetry name of domain tag " + + "'$canonical', so keyOf('${t.name}') would have two answers.") + } + } + } + + /** + * Every `intercepted` name must actually BE a domain tag. A typo there would otherwise flag + * nothing at all: the name matches no declaration, no id gets the INTERCEPTED bit, and the + * set-path pre-screen silently stops recognising a key the interceptor still handles. That is a + * behaviour change with no error message, which is the worst shape this file can fail in. + */ + private fun validateIntercepted(domain: List, overlay: TagOverlay) { + val names = domain.map { it.name }.toSet() + for (n in overlay.intercepted) { + require(n in names) { + "intercepted names '$n', which is not declared in the domain spec " + + "(tag-conventions.yaml). Use the tag's canonical dd-name; a key with no domain " + + "declaration belongs under `routed:` instead." + } + } + } + /** * 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. 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 f30d1c6e874..d538a6b926b 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt @@ -7,20 +7,28 @@ 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. + * Turns the language-agnostic {@code tag-conventions.yaml}, plus this language's routing overlay + * {@code tag-conventions-java.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) { + /** + * Parses the conventions YAML plus the routing overlay and writes the full generated tree under + * [outDir]. [overlayYaml] is optional: a language with no reserved keys passes null and gets the + * domain registry alone, byte-identical to what it would get without an overlay at all. + */ + fun generate(domainYaml: File, overlayYaml: File?, outDir: File) { val mapper = ObjectMapper(YAMLFactory()) - val domain: Map = - domainYaml.inputStream().use { - mapper.readValue(it, object : TypeReference>() {}) - } + fun readYaml(f: File): Map = + f.inputStream().use { mapper.readValue(it, object : TypeReference>() {}) } + + val domain: Map = readYaml(domainYaml) + val overlay = + if (overlayYaml == null) TagOverlay.empty() else TagOverlay.parse(readYaml(overlayYaml)) // 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 @@ -31,7 +39,7 @@ object TagRegistryGenerator { val javaPkg = File(outDir, "java/datadog/trace/api").apply { mkdirs() } val conv = TagConventions.parse(domain) - val reg = TagRegistry.build(conv) + val reg = TagRegistry.build(conv, overlay) File(outDir, "resolved-tags.txt").writeText(resolvedReport(conv)) File(outDir, "tag-assignment.txt").writeText(assignmentReport(reg)) @@ -62,21 +70,35 @@ object TagRegistryGenerator { return resolved.toString() } - /** tag-assignment.txt — serials, ids, and the OpenTelemetry name mapping (identity check). */ + /** + * tag-assignment.txt — serials, ids, and the OpenTelemetry name mapping (identity check). Domain + * tags and the overlay's reserved keys are reported as separate blocks, in serial order, because + * that is the invariant worth being able to eyeball: every domain serial precedes every reserved + * one, so a change to the overlay cannot renumber the domain block. + */ private fun assignmentReport(reg: TagRegistry): String { val a = StringBuilder() a.appendLine("# Tag id assignment. tags=${reg.tags.size}") a.appendLine() + val row = { t: TagRegistry.Tag -> + " %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) + } + val (reserved, domain) = reg.tags.partition { it.overlay } 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)) + for (t in domain) a.appendLine(row(t)) + if (reserved.isNotEmpty()) { + a.appendLine() + a.appendLine("# RESERVED KEYS (java overlay). Set-path routing identities: accepted by setTag but") + a.appendLine("# diverted to a span field or a trace directive. They have no `required` grade (that") + a.appendLine("# grades storage) and belong to no span type's resolved set. Serials continue after") + a.appendLine("# the domain block, so adding one cannot renumber the tags above.") + for (t in reserved) a.appendLine(row(t)) } a.appendLine() a.appendLine("# OPENTELEMETRY NAMES. keyOf(otelName) resolves to the canonical tag's id; nameOf still") diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGeneratorPlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGeneratorPlugin.kt index 79f20b3a35e..313bd859068 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGeneratorPlugin.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGeneratorPlugin.kt @@ -10,6 +10,7 @@ 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 overlayYaml: RegularFileProperty = objects.fileProperty() val destinationDirectory: DirectoryProperty = objects.directoryProperty() } @@ -23,11 +24,13 @@ class TagRegistryGeneratorPlugin : Plugin { val ext = project.extensions.create("tagRegistry", TagRegistryExtension::class.java) project.tasks.register("generateKnownTags", GenerateKnownTagsTask::class.java) { domainYaml.set(ext.domainYaml) + overlayYaml.set(ext.overlayYaml) destinationDirectory.set(ext.destinationDirectory) } val verify = project.tasks.register("verifyKnownTags", VerifyKnownTagsTask::class.java) { domainYaml.set(ext.domainYaml) + overlayYaml.set(ext.overlayYaml) committedDirectory.set(ext.destinationDirectory) } // `check` is contributed by lifecycle-base (via java-library); wait for it before wiring. diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/VerifyKnownTagsTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/VerifyKnownTagsTask.kt index af64ce7271c..f9a2c55d3e0 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/VerifyKnownTagsTask.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/VerifyKnownTagsTask.kt @@ -9,6 +9,7 @@ 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.Optional import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction @@ -24,6 +25,11 @@ abstract class VerifyKnownTagsTask @Inject constructor(objects: ObjectFactory) : @get:PathSensitive(PathSensitivity.NONE) val domainYaml: RegularFileProperty = objects.fileProperty() + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + val overlayYaml: RegularFileProperty = objects.fileProperty() + @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) @@ -34,7 +40,8 @@ abstract class VerifyKnownTagsTask @Inject constructor(objects: ObjectFactory) : val committed = committedDirectory.get().asFile val scratch = File(temporaryDir, "generated") scratch.deleteRecursively() - TagRegistryGenerator.generate(domainYaml.get().asFile, scratch) + TagRegistryGenerator.generate( + domainYaml.get().asFile, overlayYaml.orNull?.asFile, scratch) val diffs = ArrayList() val freshFiles = scratch.walkTopDown().filter { it.isFile }.toList() @@ -55,7 +62,8 @@ abstract class VerifyKnownTagsTask @Inject constructor(objects: ObjectFactory) : if (diffs.isNotEmpty()) { throw GradleException( buildString { - appendLine("Generated tag registry is out of date with tag-conventions.yaml:") + appendLine( + "Generated tag registry is out of date with tag-conventions.yaml / the routing overlay:") diffs.forEach { appendLine(" - $it") } append("Run `./gradlew :internal-api:generateKnownTags` and commit the result.") }) diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts index 8298146aa09..cefbe84b619 100644 --- a/internal-api/build.gradle.kts +++ b/internal-api/build.gradle.kts @@ -272,6 +272,7 @@ extra["excludedClassesInstructionCoverage"] = listOf("datadog.trace.util.stacktr // 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")) + overlayYaml.set(rootProject.layout.projectDirectory.file("tag-conventions.java.yaml")) destinationDirectory.set(layout.projectDirectory.dir("src/generated")) } 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 7f6446fccd7..6de94e8b300 100644 --- a/internal-api/src/generated/java/datadog/trace/api/KnownTags.java +++ b/internal-api/src/generated/java/datadog/trace/api/KnownTags.java @@ -3,7 +3,7 @@ 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. +// DO NOT EDIT. Sources: tag-conventions.yaml + tag-conventions.java.yaml. public final class KnownTags { // ---- tags ---- @@ -80,7 +80,7 @@ public final class KnownTags { // makeTagId(serial=18) public static final String DB_STATEMENT_NAME = "db.statement"; - public static final long DB_STATEMENT_ID = 0x0013000000000000L; + public static final long DB_STATEMENT_ID = 0x0013000000000008L; // makeTagId(serial=19) -> db.query.text public static final String DB_TYPE_NAME = "db.type"; @@ -112,7 +112,7 @@ public final class KnownTags { // makeTagId(serial=26) -> server.address public static final String HTTP_METHOD_NAME = "http.method"; - public static final long HTTP_METHOD_ID = 0x001B000000000000L; + public static final long HTTP_METHOD_ID = 0x001B000000000008L; // makeTagId(serial=27) -> http.request.method public static final String HTTP_QUERY_STRING_NAME = "http.query.string"; @@ -128,11 +128,11 @@ public final class KnownTags { // makeTagId(serial=30) public static final String HTTP_STATUS_CODE_NAME = "http.status_code"; - public static final long HTTP_STATUS_CODE_ID = 0x001F000000000000L; + public static final long HTTP_STATUS_CODE_ID = 0x001F000000000008L; // makeTagId(serial=31) -> http.response.status_code public static final String HTTP_URL_NAME = "http.url"; - public static final long HTTP_URL_ID = 0x0020000000000000L; + public static final long HTTP_URL_ID = 0x0020000000000008L; // makeTagId(serial=32) -> url.full public static final String HTTP_USERAGENT_NAME = "http.useragent"; @@ -164,7 +164,7 @@ public final class KnownTags { // makeTagId(serial=39) public static final String PEER_SERVICE_NAME = "peer.service"; - public static final long PEER_SERVICE_ID = 0x0028000000000000L; + public static final long PEER_SERVICE_ID = 0x0028000000000008L; // makeTagId(serial=40) public static final String RUNTIME_ID_NAME = "runtime-id"; @@ -172,11 +172,11 @@ public final class KnownTags { // makeTagId(serial=41) + trace-level public static final String SERVICE_NAME = "service"; - public static final long SERVICE_ID = 0x002A000000000000L; + public static final long SERVICE_ID = 0x002A000000000008L; // makeTagId(serial=42) -> service.name public static final String SERVLET_CONTEXT_NAME = "servlet.context"; - public static final long SERVLET_CONTEXT_ID = 0x002B000000000000L; + public static final long SERVLET_CONTEXT_ID = 0x002B000000000008L; // makeTagId(serial=43) public static final String SERVLET_PATH_NAME = "servlet.path"; @@ -184,7 +184,7 @@ public final class KnownTags { // makeTagId(serial=44) public static final String SPAN_KIND_NAME = "span.kind"; - public static final long SPAN_KIND_ID = 0x002D000000000000L; + public static final long SPAN_KIND_ID = 0x002D000000000008L; // makeTagId(serial=45) public static final String TEST_FRAMEWORK_NAME = "test.framework"; @@ -211,58 +211,123 @@ public final class KnownTags { public static final long VIEW_NAME_ID = 0x0033000000000000L; // makeTagId(serial=51) + public static final String DD_MEASURED_NAME = "_dd.measured"; + public static final long DD_MEASURED_ID = 0x0034000000000008L; + // makeTagId(serial=52) + + public static final String DD_ORIGIN_NAME = "_dd.origin"; + public static final long DD_ORIGIN_ID = 0x0035000000000008L; + // makeTagId(serial=53) + + public static final String DD_P_DEBUG_NAME = "_dd.p.debug"; + public static final long DD_P_DEBUG_ID = 0x0036000000000008L; + // makeTagId(serial=54) + + public static final String DD_P_TS_NAME = "_dd.p.ts"; + public static final long DD_P_TS_ID = 0x0037000000000008L; + // makeTagId(serial=55) + + public static final String DD1_SR_EAUSR_NAME = "_dd1.sr.eausr"; + public static final long DD1_SR_EAUSR_ID = 0x0038000000000008L; + // makeTagId(serial=56) + + public static final String AI_GUARD_KEEP_NAME = "ai_guard.keep"; + public static final long AI_GUARD_KEEP_ID = 0x0039000000000008L; + // makeTagId(serial=57) + + public static final String ASM_KEEP_NAME = "asm.keep"; + public static final long ASM_KEEP_ID = 0x003A000000000008L; + // makeTagId(serial=58) + + public static final String ERROR_NAME = "error"; + public static final long ERROR_ID = 0x003B000000000008L; + // makeTagId(serial=59) + + public static final String MANUAL_DROP_NAME = "manual.drop"; + public static final long MANUAL_DROP_ID = 0x003C000000000008L; + // makeTagId(serial=60) + + public static final String MANUAL_KEEP_NAME = "manual.keep"; + public static final long MANUAL_KEEP_ID = 0x003D000000000008L; + // makeTagId(serial=61) + + public static final String RESOURCE_NAME = "resource.name"; + public static final long RESOURCE_NAME_ID = 0x003E000000000008L; + // makeTagId(serial=62) + + public static final String SAMPLING_PRIORITY_NAME = "sampling.priority"; + public static final long SAMPLING_PRIORITY_ID = 0x003F000000000008L; + // makeTagId(serial=63) + + public static final String SPAN_TYPE_NAME = "span.type"; + public static final long SPAN_TYPE_ID = 0x0040000000000008L; + // makeTagId(serial=64) + // ---- 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 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; + public static final int DD_APPSEC_ENABLED_SERIAL_NUM = 1; + public static final int DD_BASE_SERVICE_SERIAL_NUM = 2; + public static final int DD_CIVISIBILITY_ENABLED_SERIAL_NUM = 3; + public static final int DD_DJM_ENABLED_SERIAL_NUM = 4; + public static final int DD_DSM_ENABLED_SERIAL_NUM = 5; + public static final int DD_GIT_COMMIT_SHA_SERIAL_NUM = 6; + public static final int DD_GIT_REPOSITORY_URL_SERIAL_NUM = 7; + public static final int DD_INTEGRATION_SERIAL_NUM = 8; + public static final int DD_PARENT_ID_SERIAL_NUM = 9; + public static final int DD_PEER_SERVICE_REMAPPED_FROM_SERIAL_NUM = 10; + public static final int DD_PEER_SERVICE_SOURCE_SERIAL_NUM = 11; + public static final int DD_PROFILING_ENABLED_SERIAL_NUM = 12; + public static final int DD_SVC_SRC_SERIAL_NUM = 13; + public static final int DD_TRACER_HOST_SERIAL_NUM = 14; + public static final int COMPONENT_SERIAL_NUM = 15; + public static final int DB_INSTANCE_SERIAL_NUM = 16; + public static final int DB_OPERATION_SERIAL_NUM = 17; + public static final int DB_POOL_NAME_SERIAL_NUM = 18; + public static final int DB_STATEMENT_SERIAL_NUM = 19; + public static final int DB_TYPE_SERIAL_NUM = 20; + public static final int DB_USER_SERIAL_NUM = 21; + public static final int ENV_SERIAL_NUM = 22; + public static final int ERROR_MESSAGE_SERIAL_NUM = 23; + public static final int ERROR_STACK_SERIAL_NUM = 24; + public static final int ERROR_TYPE_SERIAL_NUM = 25; + public static final int HTTP_HOSTNAME_SERIAL_NUM = 26; + public static final int HTTP_METHOD_SERIAL_NUM = 27; + public static final int HTTP_QUERY_STRING_SERIAL_NUM = 28; + public static final int HTTP_RESEND_COUNT_SERIAL_NUM = 29; + public static final int HTTP_ROUTE_SERIAL_NUM = 30; + public static final int HTTP_STATUS_CODE_SERIAL_NUM = 31; + public static final int HTTP_URL_SERIAL_NUM = 32; + public static final int HTTP_USERAGENT_SERIAL_NUM = 33; + public static final int LANGUAGE_SERIAL_NUM = 34; + public static final int NETWORK_PROTOCOL_VERSION_SERIAL_NUM = 35; + public static final int PEER_HOSTNAME_SERIAL_NUM = 36; + public static final int PEER_IPV4_SERIAL_NUM = 37; + public static final int PEER_IPV6_SERIAL_NUM = 38; + public static final int PEER_PORT_SERIAL_NUM = 39; + public static final int PEER_SERVICE_SERIAL_NUM = 40; + public static final int RUNTIME_ID_SERIAL_NUM = 41; + public static final int SERVICE_SERIAL_NUM = 42; + public static final int SERVLET_CONTEXT_SERIAL_NUM = 43; + public static final int SERVLET_PATH_SERIAL_NUM = 44; + public static final int SPAN_KIND_SERIAL_NUM = 45; + public static final int TEST_FRAMEWORK_SERIAL_NUM = 46; + public static final int TEST_NAME_SERIAL_NUM = 47; + public static final int TEST_STATUS_SERIAL_NUM = 48; + public static final int TEST_SUITE_SERIAL_NUM = 49; + public static final int VERSION_SERIAL_NUM = 50; + public static final int VIEW_NAME_SERIAL_NUM = 51; + public static final int DD_MEASURED_SERIAL_NUM = 52; + public static final int DD_ORIGIN_SERIAL_NUM = 53; + public static final int DD_P_DEBUG_SERIAL_NUM = 54; + public static final int DD_P_TS_SERIAL_NUM = 55; + public static final int DD1_SR_EAUSR_SERIAL_NUM = 56; + public static final int AI_GUARD_KEEP_SERIAL_NUM = 57; + public static final int ASM_KEEP_SERIAL_NUM = 58; + public static final int ERROR_SERIAL_NUM = 59; + public static final int MANUAL_DROP_SERIAL_NUM = 60; + public static final int MANUAL_KEEP_SERIAL_NUM = 61; + public static final int RESOURCE_NAME_SERIAL_NUM = 62; + public static final int SAMPLING_PRIORITY_SERIAL_NUM = 63; + public static final int SPAN_TYPE_SERIAL_NUM = 64; private static final String[] KEYOF_NAMES = { DD_APPSEC_ENABLED_NAME, @@ -316,6 +381,19 @@ public final class KnownTags { TEST_SUITE_NAME, VERSION_NAME, VIEW_NAME, + DD_MEASURED_NAME, + DD_ORIGIN_NAME, + DD_P_DEBUG_NAME, + DD_P_TS_NAME, + DD1_SR_EAUSR_NAME, + AI_GUARD_KEEP_NAME, + ASM_KEEP_NAME, + ERROR_NAME, + MANUAL_DROP_NAME, + MANUAL_KEEP_NAME, + RESOURCE_NAME, + SAMPLING_PRIORITY_NAME, + SPAN_TYPE_NAME, "db.operation.name", "db.query.text", "db.system", @@ -379,6 +457,19 @@ public final class KnownTags { TEST_SUITE_ID, VERSION_ID, VIEW_NAME_ID, + DD_MEASURED_ID, + DD_ORIGIN_ID, + DD_P_DEBUG_ID, + DD_P_TS_ID, + DD1_SR_EAUSR_ID, + AI_GUARD_KEEP_ID, + ASM_KEEP_ID, + ERROR_ID, + MANUAL_DROP_ID, + MANUAL_KEEP_ID, + RESOURCE_NAME_ID, + SAMPLING_PRIORITY_ID, + SPAN_TYPE_ID, DB_OPERATION_ID, DB_STATEMENT_ID, DB_TYPE_ID, @@ -518,6 +609,32 @@ public String nameOf(long tagId) { return VERSION_NAME; case VIEW_NAME_SERIAL_NUM: return VIEW_NAME; + case DD_MEASURED_SERIAL_NUM: + return DD_MEASURED_NAME; + case DD_ORIGIN_SERIAL_NUM: + return DD_ORIGIN_NAME; + case DD_P_DEBUG_SERIAL_NUM: + return DD_P_DEBUG_NAME; + case DD_P_TS_SERIAL_NUM: + return DD_P_TS_NAME; + case DD1_SR_EAUSR_SERIAL_NUM: + return DD1_SR_EAUSR_NAME; + case AI_GUARD_KEEP_SERIAL_NUM: + return AI_GUARD_KEEP_NAME; + case ASM_KEEP_SERIAL_NUM: + return ASM_KEEP_NAME; + case ERROR_SERIAL_NUM: + return ERROR_NAME; + case MANUAL_DROP_SERIAL_NUM: + return MANUAL_DROP_NAME; + case MANUAL_KEEP_SERIAL_NUM: + return MANUAL_KEEP_NAME; + case RESOURCE_NAME_SERIAL_NUM: + return RESOURCE_NAME; + case SAMPLING_PRIORITY_SERIAL_NUM: + return SAMPLING_PRIORITY_NAME; + case SPAN_TYPE_SERIAL_NUM: + return SPAN_TYPE_NAME; default: return null; } diff --git a/internal-api/src/generated/tag-assignment.txt b/internal-api/src/generated/tag-assignment.txt index e3b45edc9ec..357326391cc 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=51 +# Tag id assignment. tags=64 # TAGS serial lvl id required name 1 T 0x0001000000000004 recommended _dd.appsec.enabled @@ -19,7 +19,7 @@ 16 - 0x0010000000000000 recommended db.instance 17 - 0x0011000000000000 recommended db.operation 18 - 0x0012000000000000 optional db.pool.name - 19 - 0x0013000000000000 recommended db.statement + 19 - 0x0013000000000008 recommended db.statement 20 - 0x0014000000000000 required db.type 21 - 0x0015000000000000 recommended db.user 22 T 0x0016000000000004 recommended env @@ -27,12 +27,12 @@ 24 - 0x0018000000000000 recommended error.stack 25 - 0x0019000000000000 recommended error.type 26 - 0x001A000000000000 required http.hostname - 27 - 0x001B000000000000 required http.method + 27 - 0x001B000000000008 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 + 31 - 0x001F000000000008 conditional http.status_code + 32 - 0x0020000000000008 required http.url 33 - 0x0021000000000000 recommended http.useragent 34 T 0x0022000000000004 required language 35 - 0x0023000000000000 recommended network.protocol.version @@ -40,12 +40,12 @@ 37 - 0x0025000000000000 optional peer.ipv4 38 - 0x0026000000000000 optional peer.ipv6 39 - 0x0027000000000000 optional peer.port - 40 - 0x0028000000000000 recommended peer.service + 40 - 0x0028000000000008 recommended peer.service 41 T 0x0029000000000004 required runtime-id - 42 - 0x002A000000000000 required service - 43 - 0x002B000000000000 optional servlet.context + 42 - 0x002A000000000008 required service + 43 - 0x002B000000000008 optional servlet.context 44 - 0x002C000000000000 optional servlet.path - 45 - 0x002D000000000000 required span.kind + 45 - 0x002D000000000008 required span.kind 46 - 0x002E000000000000 recommended test.framework 47 - 0x002F000000000000 recommended test.name 48 - 0x0030000000000000 recommended test.status @@ -53,6 +53,24 @@ 50 T 0x0032000000000004 recommended version 51 - 0x0033000000000000 recommended view.name +# RESERVED KEYS (java overlay). Set-path routing identities: accepted by setTag but +# diverted to a span field or a trace directive. They have no `required` grade (that +# grades storage) and belong to no span type's resolved set. Serials continue after +# the domain block, so adding one cannot renumber the tags above. + 52 - 0x0034000000000008 reserved _dd.measured + 53 - 0x0035000000000008 reserved _dd.origin + 54 - 0x0036000000000008 reserved _dd.p.debug + 55 - 0x0037000000000008 reserved _dd.p.ts + 56 - 0x0038000000000008 reserved _dd1.sr.eausr + 57 - 0x0039000000000008 reserved ai_guard.keep + 58 - 0x003A000000000008 reserved asm.keep + 59 - 0x003B000000000008 reserved error + 60 - 0x003C000000000008 reserved manual.drop + 61 - 0x003D000000000008 reserved manual.keep + 62 - 0x003E000000000008 reserved resource.name + 63 - 0x003F000000000008 reserved sampling.priority + 64 - 0x0040000000000008 reserved span.type + # 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 diff --git a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java index 13e18fe6155..0c3b38ae22a 100644 --- a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java +++ b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java @@ -25,15 +25,20 @@ public final class KnownTagCodec { * 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. + * the trace/span LEVEL bit (set ⟹ trace-level) and bit 3 is the INTERCEPTED bit (set ⟹ routed on + * the set-path); bits 1-0 are reserved. Unknown (string-only) custom tags are NOT known ids — + * {@code keyOf} returns 0 for them, so they are never mistaken for intercepted. * - *

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. + *

Of the low flag bits, bit 3 is the INTERCEPTED bit: set when this tracer routes the tag on + * the set-path (to a span field or a sampling directive) rather than merely storing it. It is a + * per-language classification, declared in the Java overlay rather than in the + * language-agnostic domain spec, and it exists for speed: a stored TagMap entry carries its own + * tag id, so screening a bundle for anything the interceptor cares about is a mask test on an id + * already in hand — no name lookup and no side table. An earlier version of this bit was deleted + * because it could disagree with TagInterceptor's switch; it is back because that agreement is + * now a test (see TagInterceptorRoutingTest) rather than a convention. Note it says only that the + * tag is routed, never whether it is also stored — {@code http.url} is both, and which of the two + * happens is decided per call from the value. * *

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 @@ -61,6 +66,24 @@ public static long traceLevel(long tagId) { return tagId | LEVEL_TRACE; } + /** + * Set-path ROUTING bit (low-32 carve, bit 3). Set marks a tag this tracer diverts on the set path + * — to a span field, a metric, or a sampling directive — as declared in the Java overlay (its + * `intercepted` and `reserved` sections). Clear marks a tag that is only ever stored. Being + * routed does not imply not being stored; that is decided per call from the value. + */ + public static final long INTERCEPTED = 1L << 3; + + /** + * True if the tagId names a tag this tracer routes on the set-path. A single mask test, and a + * stored entry already carries its id, so this is the cheap form of the pre-screen that + * TagInterceptor's name switch used to do. Returns false for id 0 (an unknown custom tag), which + * is the right answer: routing is only ever declared for known tags. + */ + public static boolean isIntercepted(long tagId) { + return (tagId & INTERCEPTED) != 0L; + } + /** * 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 diff --git a/tag-conventions.java.yaml b/tag-conventions.java.yaml new file mode 100644 index 00000000000..b2bc2d75d50 --- /dev/null +++ b/tag-conventions.java.yaml @@ -0,0 +1,77 @@ +# Tag conventions — JAVA OVERLAY (set-path routing; NOT part of the domain spec) +# --------------------------------------------------------------------------- +# tag-conventions.yaml is the language-agnostic domain spec: what a tag IS. This file is the Java +# tracer's overlay alongside it, and holds the keys that exist only because THIS tracer routes them +# somewhere on the set-path. It is the "per-language overlay" the domain spec's header defers to. +# +# Why these are not in the domain spec: a key belongs there when it needs an id AND a cross-language +# name. Every key below needs only an id — an identity to dispatch on. `manual.keep` and +# `_dd1.sr.eausr` are Java-tracer routing vocabulary, not shared semantics, and exporting them into +# a cross-language contract would be claiming agreement that does not exist. +# +# RESERVED means: accepted by setTag, but the tracer diverts it to a span field or a trace directive +# instead of (or as well as) tag storage. TagInterceptor owns that routing, and its dispatch table +# is the authority on it. Nothing here says whether a reserved key is also STORED: that is decided per +# call from the value (`http.method` is routed and stored; `manual.keep` is consumed only when its +# value coerces to a boolean), so it cannot be a static property of the tag. Mirroring it here as a +# flag is exactly the drift that got an earlier classification bit deleted — see KnownTagCodec. +# +# A key that IS in the domain spec must not be REDECLARED here; it already has an id, and a second +# declaration would be a second identity for one tag (the generator fails the build on overlap). +# Eight of TagInterceptor's keys are domain tags, so they are named in `intercepted` below instead +# — which marks them as routed without the domain file having to know that this tracer routes them. +# `service` is also why the interceptor's `service.name`/`service` case pair collapses to one serial: +# the domain spec declares `service` with otel-name `service.name`, so both names already resolve to +# the same id. +# +# Every key named here — in either section — gets the INTERCEPTED flag on its id. That is what makes +# the set-path pre-screen a single mask test on an id the caller already holds: a TagMap entry +# carries its own tag id, so screening a stored bundle needs no name lookup at all. The flag is a +# CLASSIFICATION, and a classification that disagrees with the dispatch switch is worse than none — +# an earlier attempt at this bit was deleted for exactly that drift (see KnownTagCodec). What makes +# it safe here is that the agreement is a test, not a convention: the flagged set must equal the set +# TagInterceptor actually handles, and CI fails if they diverge. +# +# Serials for these are assigned AFTER every domain serial, so adding a Java-only key here cannot +# renumber the domain block or churn the shared spec's generated output. +# +# tag fields: dd-name | type (string|int|long|boolean|double). No `required` (that grades how a tag +# is STORED, and these are routing identities) and no `otel-name` (that is a domain concern). +# --------------------------------------------------------------------------- + +reserved: + tags: + # resource name / span type — routed to span fields. + - { dd-name: resource.name, type: string } + - { dd-name: span.type, type: string } + # error flag — routed to the span's error field. + - { dd-name: error, type: boolean } + # sampling directives — routed to trace-level sampling decisions, never stored as tags. + - { dd-name: manual.keep, type: boolean } + - { dd-name: manual.drop, type: boolean } + - { dd-name: asm.keep, type: boolean } + - { dd-name: ai_guard.keep, type: boolean } + - { dd-name: sampling.priority, type: int } + # legacy app-analytics sample rate — routed to a metric. + - { dd-name: _dd1.sr.eausr, type: double } + # propagation-tag writes — routed into the propagated tag set. + - { dd-name: _dd.p.ts, type: int } + - { dd-name: _dd.p.debug, type: string } + # span bookkeeping — routed to span fields. + - { dd-name: _dd.origin, type: string } + - { dd-name: _dd.measured, type: boolean } + +# Domain tags that THIS tracer also routes on the set-path. Named, not redeclared: they already have +# an identity from the domain spec, and this only adds the INTERCEPTED flag to it. Being routed does +# not mean being consumed — `http.method`, `http.url` and `span.kind` are routed AND still stored — +# which is why nothing here says anything about storage. +intercepted: + tags: + - db.statement # -> resource name + - service # -> service name (also matches its otel-name, service.name) + - peer.service # -> peer service name + - servlet.context # -> service name, when splitting by servlet context + - http.status_code # -> the span's status field + - http.method # -> resource name, with http.url (still stored) + - http.url # -> resource name, with http.method (still stored) + - span.kind # -> cached span-kind ordinal (still stored) From 989a3e93b0a6d6cdc0ec745d9c0c91cc169fb573 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 3 Sep 2026 10:13:39 -0400 Subject: [PATCH 7/8] Route tags by registry id instead of by name TagInterceptor's two parallel string switches become one id lookup: the pre-screen is a mask test on the INTERCEPTED flag, and the dispatch is an int switch over dense serials (a tableswitch, where the name switch was a lookupswitch on string hashes plus an equals() per hit). DDSpanContext resolves the id once and hands it to both, so a routed tag is never looked up twice; a TagMap entry already carries its own id, so the bundle screens cost a mask per entry. Because keyOf is many->one, a tag now routes under every name it is known by. The hand-maintained "service.name"/"service" pair of case labels collapses into the one `service` serial, and OpenTelemetry names route without a second label. splitServiceTags stays a name lookup -- it is user configuration and may name a custom tag with no id -- but is now skipped outright when unset. TagInterceptorRoutingTest asserts the declared flag set is exactly the set the switch handles. That test is what licenses the flag: an earlier version of it was deleted because the declaration and the switch could drift apart silently. Co-Authored-By: Claude Opus 5 --- .../datadog/trace/core/DDSpanContext.java | 49 +++-- .../core/taginterceptor/TagInterceptor.java | 163 +++++++++------ .../TagInterceptorRoutingTest.java | 186 ++++++++++++++++++ 3 files changed, 316 insertions(+), 82 deletions(-) create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorRoutingTest.java diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index adf4cd66156..6674c2a3038 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -11,6 +11,7 @@ import datadog.trace.api.DDTags; import datadog.trace.api.DDTraceId; import datadog.trace.api.Functions; +import datadog.trace.api.KnownTagCodec; import datadog.trace.api.ProcessTags; import datadog.trace.api.TagMap; import datadog.trace.api.cache.DDCache; @@ -968,10 +969,11 @@ public void setTag(TagMap.EntryReader entry) { return; } - // pre-check to avoid boxing + // pre-check to avoid boxing; the entry already knows its own id + long tagId = entry.tagId(); boolean intercepted = - precheckIntercept(entry.tag()) - && tagInterceptor.interceptTag(this, entry.tag(), entry.objectValue()); + precheckIntercept(tagId, entry.tag()) + && tagInterceptor.interceptTag(this, tagId, entry.tag(), entry.objectValue()); if (!intercepted) { synchronized (unsafeTags) { unsafeTags.set(entry); @@ -983,9 +985,16 @@ public void setTag(TagMap.EntryReader entry) { * Uses to determine if there's an opportunity to avoid primitve boxing. * If the underlying map doesn't support efficient primitives, then boxing is used. * If the tag may be intercepted, then boxing is also used. + * + * Resolves the tag id ONCE and hands it to both the screen and, on a hit, interceptTag -- + * the name is never looked up twice. */ - private boolean precheckIntercept(String tag) { - return tagInterceptor.needsIntercept(tag); + private static long tagIdOf(String tag) { + return KnownTagCodec.keyOf(tag); + } + + private boolean precheckIntercept(long tagId, String tag) { + return tagInterceptor.needsIntercept(tagId, tag); } /* @@ -1000,8 +1009,8 @@ private boolean precheckIntercept(String tag) { * The TagMap isn't optimized and will need to box the primitive regardless of * tag interception */ - private void setBox(String tag, Object box) { - if (!tagInterceptor.interceptTag(this, tag, box)) { + private void setBox(long tagId, String tag, Object box) { + if (!tagInterceptor.interceptTag(this, tagId, tag, box)) { synchronized (unsafeTags) { unsafeTags.set(tag, box); } @@ -1012,8 +1021,9 @@ public void setTag(final String tag, final boolean value) { if (null == tag) { return; } - if (precheckIntercept(tag)) { - this.setBox(tag, value); + long tagId = tagIdOf(tag); + if (precheckIntercept(tagId, tag)) { + this.setBox(tagId, tag, value); } else { synchronized (unsafeTags) { unsafeTags.set(tag, value); @@ -1025,8 +1035,9 @@ public void setTag(final String tag, final int value) { if (null == tag) { return; } - if (precheckIntercept(tag)) { - this.setBox(tag, value); + long tagId = tagIdOf(tag); + if (precheckIntercept(tagId, tag)) { + this.setBox(tagId, tag, value); } else { synchronized (unsafeTags) { unsafeTags.set(tag, value); @@ -1039,8 +1050,10 @@ public void setTag(final String tag, final long value) { return; } // check needsIntercept first to avoid unnecessary boxing + long tagId = tagIdOf(tag); boolean intercepted = - tagInterceptor.needsIntercept(tag) && tagInterceptor.interceptTag(this, tag, value); + tagInterceptor.needsIntercept(tagId, tag) + && tagInterceptor.interceptTag(this, tagId, tag, value); if (!intercepted) { synchronized (unsafeTags) { unsafeTags.set(tag, value); @@ -1052,8 +1065,9 @@ public void setTag(final String tag, final float value) { if (null == tag) { return; } - if (precheckIntercept(tag)) { - this.setBox(tag, value); + long tagId = tagIdOf(tag); + if (precheckIntercept(tagId, tag)) { + this.setBox(tagId, tag, value); } else { synchronized (unsafeTags) { unsafeTags.set(tag, value); @@ -1065,8 +1079,9 @@ public void setTag(final String tag, final double value) { if (null == tag) { return; } - if (precheckIntercept(tag)) { - this.setBox(tag, value); + long tagId = tagIdOf(tag); + if (precheckIntercept(tagId, tag)) { + this.setBox(tagId, tag, value); } else { synchronized (unsafeTags) { unsafeTags.set(tag, value); @@ -1094,7 +1109,7 @@ void setAllTags(final TagMap map, boolean needsIntercept) { String tag = tagEntry.tag(); Object value = tagEntry.objectValue(); - if (!ctx.tagInterceptor.interceptTag(ctx, tag, value)) { + if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry.tagId(), tag, value)) { ctx.unsafeTags.set(tagEntry); } }); diff --git a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java index d81a9cc8441..eaab327cc65 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java @@ -1,15 +1,11 @@ package datadog.trace.core.taginterceptor; import static datadog.trace.api.DDTags.ANALYTICS_SAMPLE_RATE; -import static datadog.trace.api.DDTags.MEASURED; -import static datadog.trace.api.DDTags.ORIGIN_KEY; -import static datadog.trace.api.DDTags.SPAN_TYPE; import static datadog.trace.api.sampling.PrioritySampling.USER_DROP; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.SERVLET_CONTEXT; import static datadog.trace.bootstrap.instrumentation.api.ServiceNameSources.SPLIT_BY_SERVLET_CONTEXT; import static datadog.trace.bootstrap.instrumentation.api.ServiceNameSources.SPLIT_BY_TAGS; import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_METHOD; -import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_STATUS; import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_URL; import static datadog.trace.core.taginterceptor.RuleFlags.Feature.FORCE_MANUAL_DROP; import static datadog.trace.core.taginterceptor.RuleFlags.Feature.FORCE_SAMPLING_PRIORITY; @@ -23,6 +19,8 @@ import datadog.trace.api.Config; import datadog.trace.api.ConfigDefaults; import datadog.trace.api.DDTags; +import datadog.trace.api.KnownTagCodec; +import datadog.trace.api.KnownTags; import datadog.trace.api.Pair; import datadog.trace.api.TagMap; import datadog.trace.api.config.GeneralConfig; @@ -42,6 +40,24 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +/** + * Routes tags this tracer treats as more than storage -- to a span field, a metric, or a sampling + * directive -- on their way through {@code setTag}. + * + *

Dispatch is keyed on the tag's registry ID rather than on its name. That buys three things. + * The pre-screen ({@link #needsIntercept}) becomes a mask test on {@link KnownTagCodec#INTERCEPTED} + * against an id a stored entry already carries, instead of a switch over strings. The dispatch + * itself becomes an int switch over dense serials -- a {@code tableswitch}, where a switch over + * names is a {@code lookupswitch} on string hashes plus an {@code equals()} per hit. And because + * {@code keyOf} is many->one, every namespace a tag is known by lands on one case: the hand- + * maintained {@code "service.name"}/{@code "service"} pair collapses into the one {@code service} + * serial, and an OpenTelemetry name routes without a second label. + * + *

Which keys carry the INTERCEPTED flag is declared in {@code tag-conventions.java.yaml}, and + * {@code TagInterceptorRoutingTest} asserts that set is exactly the set this switch handles. That + * test is what licenses the flag to exist: an earlier version of it was removed precisely because + * the declaration and the switch could drift apart silently. + */ public class TagInterceptor { private static final UTF8BytesString NOT_FOUND_RESOURCE_NAME = UTF8BytesString.create("404"); @@ -51,6 +67,7 @@ public class TagInterceptor { private final boolean splitByServletContext; private final String inferredServiceName; private final Set splitServiceTags; + private final boolean hasSplitServiceTags; private final boolean shouldSet404ResourceName; private final boolean shouldSetUrlResourceAsName; @@ -74,6 +91,7 @@ public TagInterceptor( this.isServiceNameSetByUser = isServiceNameSetByUser; this.inferredServiceName = inferredServiceName; this.splitServiceTags = splitServiceTags; + this.hasSplitServiceTags = !splitServiceTags.isEmpty(); this.ruleFlags = ruleFlags; splitByServletContext = splitServiceTags.contains(SERVLET_CONTEXT); @@ -85,9 +103,13 @@ public TagInterceptor( this.jeeSplitByDeployment = jeeSplitByDeployment; } + /** + * True if any entry in {@code map} is routed. Each entry is asked for its own id, so the common + * answer -- no -- costs a mask test per entry and no name comparison at all. + */ public boolean needsIntercept(TagMap map) { for (TagMap.EntryReader entry : map) { - if (needsIntercept(entry.tag())) return true; + if (needsIntercept(entry.tagId(), entry.tag())) return true; } return false; } @@ -100,100 +122,106 @@ public boolean needsIntercept(Map map) { } public boolean needsIntercept(String tag) { - switch (tag) { - case DDTags.RESOURCE_NAME: - case Tags.DB_STATEMENT: - case DDTags.SERVICE_NAME: - case "service": - case Tags.PEER_SERVICE: - case DDTags.MANUAL_KEEP: - case DDTags.MANUAL_DROP: - case Tags.ASM_KEEP: - case Tags.AI_GUARD_KEEP: - case Tags.SAMPLING_PRIORITY: - case Tags.PROPAGATED_TRACE_SOURCE: - case Tags.PROPAGATED_DEBUG: - case SERVLET_CONTEXT: - case SPAN_TYPE: - case ANALYTICS_SAMPLE_RATE: - case Tags.ERROR: - case HTTP_STATUS: - case HTTP_METHOD: - case HTTP_URL: - case ORIGIN_KEY: - case MEASURED: - case Tags.SPAN_KIND: - return true; + return needsIntercept(KnownTagCodec.keyOf(tag), tag); + } - default: - return splitServiceTags.contains(tag); - } + /** + * The pre-screen, for a caller that already holds the tag's id. Prefer it: resolving the name + * once and passing the id to both this and {@link #interceptTag} is the whole point of keying on + * ids, and it is what keeps a routed tag from being looked up twice. + * + *

{@code splitServiceTags} is the one case the flag cannot answer. It is user configuration -- + * any tag name at all, including a custom one with no id -- so it stays a set lookup, guarded by + * the usual case of the feature being off. + */ + public boolean needsIntercept(long tagId, String tag) { + return KnownTagCodec.isIntercepted(tagId) || isSplitServiceTag(tag); + } + + private boolean isSplitServiceTag(String tag) { + return hasSplitServiceTags && splitServiceTags.contains(tag); } public boolean interceptTag(DDSpanContext span, String tag, Object value) { - switch (tag) { - case DDTags.RESOURCE_NAME: + return interceptTag(span, KnownTagCodec.keyOf(tag), tag, value); + } + + /** + * Routes one tag, for a caller that already holds its id. Returns true when the value has been + * consumed and must NOT also be stored. + * + *

Whether a routed tag is also stored is decided here, per call, from the value -- it is not a + * property of the tag: {@code http.url} is routed and always stored, {@code manual.keep} is + * consumed only when its value coerces to a boolean. That is why the INTERCEPTED flag says only + * "ask", and this return value stays the authority. + * + *

{@code tag} is still needed for the {@code splitServiceTags} fallback, which is keyed on the + * name the user configured rather than on an id. + */ + public boolean interceptTag(DDSpanContext span, long tagId, String tag, Object value) { + switch (KnownTagCodec.serialNum(tagId)) { + case KnownTags.RESOURCE_NAME_SERIAL_NUM: return interceptResourceName(span, value); - case Tags.DB_STATEMENT: + case KnownTags.DB_STATEMENT_SERIAL_NUM: return interceptDbStatement(span, value); - case DDTags.SERVICE_NAME: - case "service": + case KnownTags.SERVICE_SERIAL_NUM: return interceptServiceName(SERVICE_NAME, span, value); - case Tags.PEER_SERVICE: + case KnownTags.PEER_SERVICE_SERIAL_NUM: // we still need to intercept and add this tag when the user manually set span.setTag(DDTags.PEER_SERVICE_SOURCE, Tags.PEER_SERVICE); return interceptServiceName(PEER_SERVICE, span, value); - case DDTags.MANUAL_KEEP: + case KnownTags.MANUAL_KEEP_SERIAL_NUM: if (asBoolean(value)) { span.forceKeep(); return true; } return false; - case DDTags.MANUAL_DROP: + case KnownTags.MANUAL_DROP_SERIAL_NUM: return interceptSamplingPriority( FORCE_MANUAL_DROP, USER_DROP, SamplingMechanism.MANUAL, span, value); - case Tags.ASM_KEEP: + case KnownTags.ASM_KEEP_SERIAL_NUM: if (asBoolean(value)) { span.forceKeep(SamplingMechanism.APPSEC); return true; } return false; - case Tags.AI_GUARD_KEEP: + case KnownTags.AI_GUARD_KEEP_SERIAL_NUM: if (asBoolean(value)) { span.forceKeep(SamplingMechanism.AI_GUARD); return true; } return false; - case Tags.SAMPLING_PRIORITY: + case KnownTags.SAMPLING_PRIORITY_SERIAL_NUM: return interceptSamplingPriority(span, value); - case Tags.PROPAGATED_TRACE_SOURCE: + case KnownTags.DD_P_TS_SERIAL_NUM: if (value instanceof Integer) { span.addPropagatedTraceSource((Integer) value); return true; } return false; - case Tags.PROPAGATED_DEBUG: + case KnownTags.DD_P_DEBUG_SERIAL_NUM: span.updateDebugPropagation(String.valueOf(value)); return true; - case SERVLET_CONTEXT: + case KnownTags.SERVLET_CONTEXT_SERIAL_NUM: return interceptServletContext(span, value); - case SPAN_TYPE: + case KnownTags.SPAN_TYPE_SERIAL_NUM: return interceptSpanType(span, value); - case ANALYTICS_SAMPLE_RATE: + case KnownTags.DD1_SR_EAUSR_SERIAL_NUM: return interceptAnalyticsSampleRate(span, value); - case Tags.ERROR: + case KnownTags.ERROR_SERIAL_NUM: return interceptError(span, value); - case HTTP_STATUS: + case KnownTags.HTTP_STATUS_CODE_SERIAL_NUM: // not set internally but may come from manual instrumentation return interceptHttpStatusCode(span, value); - case HTTP_METHOD: - case HTTP_URL: - return interceptUrlResourceAsNameRule(span, tag, value); - case ORIGIN_KEY: + case KnownTags.HTTP_METHOD_SERIAL_NUM: + return interceptHttpMethod(span, value); + case KnownTags.HTTP_URL_SERIAL_NUM: + return interceptHttpUrl(span, value); + case KnownTags.DD_ORIGIN_SERIAL_NUM: return interceptOrigin(span, value); - case MEASURED: + case KnownTags.DD_MEASURED_SERIAL_NUM: return interceptMeasured(span, value); - case Tags.SPAN_KIND: + case KnownTags.SPAN_KIND_SERIAL_NUM: // Cache the ordinal for fast isOutbound() checks. // Return false so the value is still stored in unsafeTags for serialization. span.setSpanKindOrdinal(String.valueOf(value)); @@ -203,18 +231,23 @@ public boolean interceptTag(DDSpanContext span, String tag, Object value) { } } - private boolean interceptUrlResourceAsNameRule(DDSpanContext span, String tag, Object value) { + private boolean interceptHttpMethod(DDSpanContext span, Object value) { if (shouldSetUrlResourceAsName) { - if (HTTP_METHOD.equals(tag)) { - final Object url = span.unsafeGetTag(HTTP_URL); - if (url != null) { - setResourceFromUrl(span, value.toString(), url); - } - } else if (HTTP_URL.equals(tag)) { - final Object method = span.unsafeGetTag(HTTP_METHOD); - setResourceFromUrl(span, method != null ? method.toString() : null, value); + final Object url = span.unsafeGetTag(HTTP_URL); + if (url != null) { + setResourceFromUrl(span, value.toString(), url); } } + // always false: the method is routed to the resource name AND stored + return false; + } + + private boolean interceptHttpUrl(DDSpanContext span, Object value) { + if (shouldSetUrlResourceAsName) { + final Object method = span.unsafeGetTag(HTTP_METHOD); + setResourceFromUrl(span, method != null ? method.toString() : null, value); + } + // always false: the url is routed to the resource name AND stored return false; } diff --git a/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorRoutingTest.java b/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorRoutingTest.java new file mode 100644 index 00000000000..064e7afb076 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorRoutingTest.java @@ -0,0 +1,186 @@ +package datadog.trace.core.taginterceptor; + +import static datadog.trace.bootstrap.instrumentation.api.ServiceNameSources.SPLIT_BY_TAGS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockingDetails; +import static org.mockito.Mockito.when; + +import datadog.trace.api.KnownTagCodec; +import datadog.trace.api.TagMap; +import datadog.trace.core.DDSpanContext; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * The guard that licenses the INTERCEPTED flag to exist. + * + *

{@code KnownTagCodec.INTERCEPTED} is a declaration in {@code tag-conventions.java.yaml}; + * {@code TagInterceptor.interceptTag} is the code. An earlier version of the flag was deleted + * precisely because those two could drift apart with nothing to notice — a flag set on a tag the + * switch does not handle silently boxes a value for no reason, and a case label with no flag makes + * the pre-screen skip a tag the interceptor was supposed to route, which is a behaviour change with + * no error message at all. + * + *

So the agreement is asserted rather than maintained by convention: for every tag in the + * registry, the flag must say exactly what the switch does. "What the switch does" is read + * behaviourally, by asking whether the tag reaches the {@code default} branch — configuring every + * known tag as a split-service tag makes that branch, and only that branch, call {@code + * setServiceName(value, SPLIT_BY_TAGS)}. + */ +class TagInterceptorRoutingTest { + + private static final String PROBE = "probe-value"; + + /** Every serial the registry has assigned, walked until the resolver stops naming them. */ + private static List allKnownTagNames() { + List names = new ArrayList<>(); + for (int serial = 1; ; serial++) { + String name = KnownTagCodec.nameOf(KnownTagCodec.makeTagId(serial)); + if (name == null) { + return names; + } + names.add(name); + } + } + + /** + * An interceptor for which the {@code default} branch is observable: every known tag is a + * split-service tag, so a tag that falls through calls {@code setServiceName(value, + * SPLIT_BY_TAGS)} and one that is routed does not. + */ + private static TagInterceptor interceptorSplittingOn(Set tags) { + return new TagInterceptor(false, "inferred-service", tags, new RuleFlags(), false); + } + + private static DDSpanContext probeSpan() { + DDSpanContext span = mock(DDSpanContext.class); + // interceptServletContext reads it before deciding; the mock default (null) would NPE + when(span.getServiceName()).thenReturn(""); + return span; + } + + private static boolean fellThroughToDefault(DDSpanContext span) { + return mockingDetails(span).getInvocations().stream() + .anyMatch( + i -> + "setServiceName".equals(i.getMethod().getName()) + && i.getArguments().length == 2 + && SPLIT_BY_TAGS.equals(i.getArguments()[1])); + } + + @Test + void interceptedFlagMatchesTheDispatchSwitchExactly() { + List names = allKnownTagNames(); + assertFalse(names.isEmpty(), "registry resolved no tags at all"); + + TagInterceptor interceptor = interceptorSplittingOn(new LinkedHashSet<>(names)); + + List flaggedButNotHandled = new ArrayList<>(); + List handledButNotFlagged = new ArrayList<>(); + + for (String name : names) { + long tagId = KnownTagCodec.keyOf(name); + DDSpanContext span = probeSpan(); + interceptor.interceptTag(span, tagId, name, PROBE); + + boolean handled = !fellThroughToDefault(span); + boolean flagged = KnownTagCodec.isIntercepted(tagId); + + if (flagged && !handled) { + flaggedButNotHandled.add(name); + } else if (handled && !flagged) { + handledButNotFlagged.add(name); + } + } + + assertTrue( + flaggedButNotHandled.isEmpty(), + "declared `intercepted`/`reserved` in tag-conventions.java.yaml but TagInterceptor has no " + + "case for them — either add the case or drop the declaration: " + + flaggedButNotHandled); + assertTrue( + handledButNotFlagged.isEmpty(), + "TagInterceptor routes them but tag-conventions.java.yaml does not declare them, so the " + + "pre-screen will skip them: " + + handledButNotFlagged); + } + + /** + * The pre-screen must agree with the dispatch for the same reason, and it is the half that runs + * on every {@code setTag}. A custom tag has no id at all, so it can only be recognised by name. + */ + @Test + void preScreenAgreesWithTheFlagAndStillSeesCustomSplitTags() { + for (String name : allKnownTagNames()) { + long tagId = KnownTagCodec.keyOf(name); + assertEquals( + KnownTagCodec.isIntercepted(tagId), + interceptorSplittingOn(emptyTags()).needsIntercept(tagId, name), + name); + } + + TagInterceptor splitting = interceptorSplittingOn(singleton("my.custom.tag")); + assertTrue(splitting.needsIntercept("my.custom.tag"), "custom split tag has no id to test"); + assertEquals(0L, KnownTagCodec.keyOf("my.custom.tag")); + assertFalse(interceptorSplittingOn(emptyTags()).needsIntercept("my.custom.tag")); + } + + /** + * {@code keyOf} is many→one, so a tag routes under every name it is known by. This is what + * replaces the hand-maintained {@code "service.name"}/{@code "service"} pair of case labels, and + * it now extends to the OpenTelemetry namespace for free. + */ + @Test + void alternateNamesRouteToTheSameHandler() { + assertSameRoute("service", "service.name"); + assertSameRoute("http.method", "http.request.method"); + assertSameRoute("http.status_code", "http.response.status_code"); + assertSameRoute("http.url", "url.full"); + assertSameRoute("db.statement", "db.query.text"); + } + + private static void assertSameRoute(String ddName, String otherName) { + assertEquals(KnownTagCodec.keyOf(ddName), KnownTagCodec.keyOf(otherName), otherName); + + TagInterceptor interceptor = interceptorSplittingOn(emptyTags()); + assertEquals( + interceptor.needsIntercept(ddName), interceptor.needsIntercept(otherName), otherName); + } + + /** The bundle screen is the same test, once per entry, over ids the entries already carry. */ + @Test + void bundleScreenSeesRoutedEntries() { + TagInterceptor interceptor = interceptorSplittingOn(emptyTags()); + + TagMap plain = TagMap.create(); + plain.set("component", "netty"); + plain.set("http.route", "/x"); + assertFalse(interceptor.needsIntercept(plain)); + + TagMap routed = TagMap.create(); + routed.set("component", "netty"); + routed.set("service.name", "billing"); // routed under its OpenTelemetry name + assertTrue(interceptor.needsIntercept(routed)); + + TagMap custom = TagMap.create(); + custom.set("my.custom.tag", "v"); + assertFalse(interceptor.needsIntercept(custom)); + assertTrue(interceptorSplittingOn(singleton("my.custom.tag")).needsIntercept(custom)); + } + + private static Set emptyTags() { + return new LinkedHashSet<>(); + } + + private static Set singleton(String tag) { + Set tags = new LinkedHashSet<>(); + tags.add(tag); + return tags; + } +} From 1e1ff99bef46e998c0fa2eff8dd281199e727cf0 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 3 Sep 2026 11:26:27 -0400 Subject: [PATCH 8/8] Add TagInterceptorScreenBenchmark Both the id-based screen/dispatch and a frozen copy of the pre-change name switch live here as arms: the old switch is deleted, so there is no one-binary flag, and a two-jar A/B would conflate master drift. This is a CPU-not-allocation lever, so flat gc.alloc.rate.norm across every arm is the correctness check, not the result. Co-Authored-By: Claude Opus 5 --- .../TagInterceptorScreenBenchmark.java | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/taginterceptor/TagInterceptorScreenBenchmark.java diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/taginterceptor/TagInterceptorScreenBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/taginterceptor/TagInterceptorScreenBenchmark.java new file mode 100644 index 00000000000..ce55a94a058 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/taginterceptor/TagInterceptorScreenBenchmark.java @@ -0,0 +1,320 @@ +package datadog.trace.core.taginterceptor; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +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.InstrumentationTags; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Measures the tag-routing SCREEN — the {@code needsIntercept} check that runs on every {@code + * setTag}, whether or not the tag is routed — against the name-keyed string switch it replaced. + * + *

Both implementations live here, as arms. The old switch is deleted from production, so + * there is no flag to turn on and off in one binary, and a master-vs-branch two-jar comparison + * would conflate this change with everything else that moved. So {@link #screenByName} runs a + * verbatim copy of the switch as it stood before this change, and {@link #screenById} runs the + * shipped path. One binary, no drift, and the baseline arm is frozen the moment it is written. + * + *

Read throughput here, not allocation. This is one of the rare CPU-not-allocation + * levers: {@code gc.alloc.rate.norm} should come out flat across every arm, and if it does not, + * something is wrong — that is the check, not the result. Which makes the signal the fragile one: + * run {@code -f3} at minimum (per-fork inlining bimodality is the failure mode for a method this + * small) and treat the numbers as directional. The acceptance number is macro; see the PetClinic + * harness. + * + *

What this deliberately over-states. It isolates the screen from the span work around + * it. In a real span the screen is a small slice of create/tag/finish, so a percentage here is not + * a percentage there — {@code SpanCreationBenchmark} is where the in-situ effect shows up. + * + *

The arms are chosen around what the change actually does: + * + *

    + *
  • miss — a known tag that is not routed ({@code http.route}). The common case, and the + * one that decides the whole thing: the old path fell through the whole {@code lookupswitch} + * to a set lookup; the new one is a {@code keyOf} probe plus a mask test. + *
  • hit — a routed tag ({@code resource.name}). The old path found a case label early. + *
  • custom — a tag with no registry id at all. The one case that plausibly got + * worse: {@code keyOf} probes and misses, and the set lookup still runs. + *
  • bundle — the per-bundle screen over a 7-tag web-shaped map with nothing routed in + * it, so the scan runs to completion. This is the O(n) form, and the baseline for the + * running-OR idea (one mask for a whole map) if that lands later. + *
+ * + *

{@code splitServiceTags} is a {@link Param} because it is the one input the id cannot answer — + * user configuration naming any tag at all — and because the new path skips the set lookup outright + * when it is unset, which is only visible on the miss arms. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 5) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.Throughput) +@Threads(8) +@OutputTimeUnit(MICROSECONDS) +@Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn") +public class TagInterceptorScreenBenchmark { + + /** A known tag the interceptor does not route. */ + private static final String MISS_TAG = Tags.HTTP_ROUTE; + + /** A routed tag; the old switch found it as a case label. */ + private static final String HIT_TAG = DDTags.RESOURCE_NAME; + + /** No registry id at all, so only a name lookup can recognise it. */ + private static final String CUSTOM_TAG = "app.checkout.step"; + + /** + * Off: {@code splitServiceTags} empty, the shipped configuration. On: populated, which forces the + * set lookup back onto the miss path in both arms. + */ + @Param({"off", "on"}) + String splitByTags; + + TagInterceptor interceptor; + Set splitServiceTags; + TagMap webBundle; + + @Setup + public void setup() { + this.splitServiceTags = + "on".equals(splitByTags) + ? new HashSet<>(Arrays.asList("sn.tenant", "sn.region")) + : Collections.emptySet(); + this.interceptor = + new TagInterceptor( + false, "inferred-service", this.splitServiceTags, new RuleFlags(), false); + + // Web-server-shaped bundle with nothing routed in it, so the screen scans every entry -- the + // shape the trace-level bundles in CoreTracer actually have. + this.webBundle = TagMap.create(7); + this.webBundle.set(Tags.COMPONENT, "tomcat-server"); + this.webBundle.set(Tags.HTTP_ROUTE, "/owners/{ownerId}"); + this.webBundle.set(Tags.HTTP_HOSTNAME, "localhost"); + this.webBundle.set(Tags.HTTP_USER_AGENT, "curl/8.4.0"); + this.webBundle.set(Tags.PEER_PORT, 80); + this.webBundle.set(InstrumentationTags.SERVLET_PATH, "/owners/42"); + this.webBundle.set("app.build", "2026.09.1"); + } + + // ---- shipped path: keyOf + mask ---- + + @Benchmark + public boolean screenById_miss() { + return interceptor.needsIntercept(MISS_TAG); + } + + @Benchmark + public boolean screenById_hit() { + return interceptor.needsIntercept(HIT_TAG); + } + + @Benchmark + public boolean screenById_custom() { + return interceptor.needsIntercept(CUSTOM_TAG); + } + + @Benchmark + public boolean bundleScreenById() { + return interceptor.needsIntercept(webBundle); + } + + // ---- baseline: the name switch as it stood before this change ---- + + @Benchmark + public boolean screenByName_miss() { + return needsInterceptByName(MISS_TAG); + } + + @Benchmark + public boolean screenByName_hit() { + return needsInterceptByName(HIT_TAG); + } + + @Benchmark + public boolean screenByName_custom() { + return needsInterceptByName(CUSTOM_TAG); + } + + @Benchmark + public boolean bundleScreenByName() { + for (TagMap.EntryReader entry : webBundle) { + if (needsInterceptByName(entry.tag())) return true; + } + return false; + } + + // ---- dispatch MECHANISM, handler bodies excluded ---- + + /** + * The dispatch half, isolated to its lookup. Both arms return a distinct int per tag instead of + * running a handler, so what is being compared is the {@code lookupswitch}-on-string-hashes shape + * against the {@code tableswitch}-on-dense-serials shape and nothing else. + * + *

Copying the real 22-case dispatch in here to serve as a baseline would reintroduce exactly + * the duplication this change removes, and it would go stale silently. So this measures the + * mechanism honestly and claims nothing about the handlers, which are unchanged. + */ + @Benchmark + public int dispatchLookupById() { + return lookupBySerial(KnownTagCodec.keyOf(HIT_TAG)); + } + + @Benchmark + public int dispatchLookupByName() { + return lookupByName(HIT_TAG); + } + + private static int lookupBySerial(long tagId) { + switch (KnownTagCodec.serialNum(tagId)) { + case KnownTags.RESOURCE_NAME_SERIAL_NUM: + return 1; + case KnownTags.DB_STATEMENT_SERIAL_NUM: + return 2; + case KnownTags.SERVICE_SERIAL_NUM: + return 3; + case KnownTags.PEER_SERVICE_SERIAL_NUM: + return 4; + case KnownTags.MANUAL_KEEP_SERIAL_NUM: + return 5; + case KnownTags.MANUAL_DROP_SERIAL_NUM: + return 6; + case KnownTags.ASM_KEEP_SERIAL_NUM: + return 7; + case KnownTags.AI_GUARD_KEEP_SERIAL_NUM: + return 8; + case KnownTags.SAMPLING_PRIORITY_SERIAL_NUM: + return 9; + case KnownTags.DD_P_TS_SERIAL_NUM: + return 10; + case KnownTags.DD_P_DEBUG_SERIAL_NUM: + return 11; + case KnownTags.SERVLET_CONTEXT_SERIAL_NUM: + return 12; + case KnownTags.SPAN_TYPE_SERIAL_NUM: + return 13; + case KnownTags.DD1_SR_EAUSR_SERIAL_NUM: + return 14; + case KnownTags.ERROR_SERIAL_NUM: + return 15; + case KnownTags.HTTP_STATUS_CODE_SERIAL_NUM: + return 16; + case KnownTags.HTTP_METHOD_SERIAL_NUM: + return 17; + case KnownTags.HTTP_URL_SERIAL_NUM: + return 18; + case KnownTags.DD_ORIGIN_SERIAL_NUM: + return 19; + case KnownTags.DD_MEASURED_SERIAL_NUM: + return 20; + case KnownTags.SPAN_KIND_SERIAL_NUM: + return 21; + default: + return 0; + } + } + + private static int lookupByName(String tag) { + switch (tag) { + case DDTags.RESOURCE_NAME: + return 1; + case Tags.DB_STATEMENT: + return 2; + case DDTags.SERVICE_NAME: + case "service": + return 3; + case Tags.PEER_SERVICE: + return 4; + case DDTags.MANUAL_KEEP: + return 5; + case DDTags.MANUAL_DROP: + return 6; + case Tags.ASM_KEEP: + return 7; + case Tags.AI_GUARD_KEEP: + return 8; + case Tags.SAMPLING_PRIORITY: + return 9; + case Tags.PROPAGATED_TRACE_SOURCE: + return 10; + case Tags.PROPAGATED_DEBUG: + return 11; + case InstrumentationTags.SERVLET_CONTEXT: + return 12; + case DDTags.SPAN_TYPE: + return 13; + case DDTags.ANALYTICS_SAMPLE_RATE: + return 14; + case Tags.ERROR: + return 15; + case Tags.HTTP_STATUS: + return 16; + case Tags.HTTP_METHOD: + return 17; + case Tags.HTTP_URL: + return 18; + case DDTags.ORIGIN_KEY: + return 19; + case DDTags.MEASURED: + return 20; + case Tags.SPAN_KIND: + return 21; + default: + return 0; + } + } + + /** + * The screen as it stood before this change: one {@code lookupswitch} over 22 case labels, then a + * set lookup for anything that falls through. Verbatim apart from reading the benchmark's own + * {@code splitServiceTags} field. + */ + private boolean needsInterceptByName(String tag) { + switch (tag) { + case DDTags.RESOURCE_NAME: + case Tags.DB_STATEMENT: + case DDTags.SERVICE_NAME: + case "service": + case Tags.PEER_SERVICE: + case DDTags.MANUAL_KEEP: + case DDTags.MANUAL_DROP: + case Tags.ASM_KEEP: + case Tags.AI_GUARD_KEEP: + case Tags.SAMPLING_PRIORITY: + case Tags.PROPAGATED_TRACE_SOURCE: + case Tags.PROPAGATED_DEBUG: + case InstrumentationTags.SERVLET_CONTEXT: + case DDTags.SPAN_TYPE: + case DDTags.ANALYTICS_SAMPLE_RATE: + case Tags.ERROR: + case Tags.HTTP_STATUS: + case Tags.HTTP_METHOD: + case Tags.HTTP_URL: + case DDTags.ORIGIN_KEY: + case DDTags.MEASURED: + case Tags.SPAN_KIND: + return true; + + default: + return splitServiceTags.contains(tag); + } + } +}