From d7973fe4909c9840788818f5f41f4360c9dcb36d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 3 Sep 2026 09:55:37 -0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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); + } + } +}