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..00ab3f78d4f --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt @@ -0,0 +1,43 @@ +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.Optional +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} 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 +abstract class GenerateKnownTagsTask @Inject constructor(objects: ObjectFactory) : DefaultTask() { + @get:InputFile + @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, 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 new file mode 100644 index 00000000000..4b4dade5be0 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/KnownTagsEmitter.kt @@ -0,0 +1,163 @@ +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 `_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. Sources: tag-conventions.yaml + tag-conventions.java.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). 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(" public 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..4801845352d --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt @@ -0,0 +1,270 @@ +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 + } + + /** + * 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 (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 + * 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] / [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 + * 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/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 new file mode 100644 index 00000000000..c8daf5488cb --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt @@ -0,0 +1,187 @@ +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, + /** + * 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, 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 = 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 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, 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. + * 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..d538a6b926b --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGenerator.kt @@ -0,0 +1,112 @@ +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}, 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 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()) + 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 + // 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, overlay) + + 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).") + 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() + 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). 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 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") + 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..313bd859068 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistryGeneratorPlugin.kt @@ -0,0 +1,41 @@ +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 overlayYaml: 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) + 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. + 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..f9a2c55d3e0 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/VerifyKnownTagsTask.kt @@ -0,0 +1,72 @@ +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.Optional +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:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + val overlayYaml: 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, overlayYaml.orNull?.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 / the routing overlay:") + diffs.forEach { appendLine(" - $it") } + append("Run `./gradlew :internal-api:generateKnownTags` and commit the result.") + }) + } + } +} 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); + } + } +} 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/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()); } } 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/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/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; + } +} 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..cefbe84b619 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,18 @@ 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")) + overlayYaml.set(rootProject.layout.projectDirectory.file("tag-conventions.java.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..6de94e8b300 --- /dev/null +++ b/internal-api/src/generated/java/datadog/trace/api/KnownTags.java @@ -0,0 +1,679 @@ +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. Sources: tag-conventions.yaml + tag-conventions.java.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 = 0x0013000000000008L; + // 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 = 0x001B000000000008L; + // 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 = 0x001F000000000008L; + // makeTagId(serial=31) -> http.response.status_code + + public static final String HTTP_URL_NAME = "http.url"; + public static final long HTTP_URL_ID = 0x0020000000000008L; + // 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 = 0x0028000000000008L; + // 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 = 0x002A000000000008L; + // makeTagId(serial=42) -> service.name + + public static final String SERVLET_CONTEXT_NAME = "servlet.context"; + public static final long SERVLET_CONTEXT_ID = 0x002B000000000008L; + // 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 = 0x002D000000000008L; + // 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 = 0x0032000000000004L; + // makeTagId(serial=50) + trace-level + + public static final String VIEW_NAME = "view.name"; + 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 ---- + 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, + 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, + TEST_FRAMEWORK_NAME, + TEST_NAME, + TEST_STATUS_NAME, + 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", + "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, + TEST_FRAMEWORK_ID, + TEST_NAME_ID, + TEST_STATUS_ID, + 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, + 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 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: + 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; + } + } + + @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..605253bc193 --- /dev/null +++ b/internal-api/src/generated/resolved-tags.txt @@ -0,0 +1,86 @@ +# 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 + - 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..357326391cc --- /dev/null +++ b/internal-api/src/generated/tag-assignment.txt @@ -0,0 +1,85 @@ +# Tag id assignment. tags=64 + +# 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 - 0x0013000000000008 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 - 0x001B000000000008 required http.method + 28 - 0x001C000000000000 recommended http.query.string + 29 - 0x001D000000000000 recommended http.resend_count + 30 - 0x001E000000000000 conditional http.route + 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 + 36 - 0x0024000000000000 recommended peer.hostname + 37 - 0x0025000000000000 optional peer.ipv4 + 38 - 0x0026000000000000 optional peer.ipv6 + 39 - 0x0027000000000000 optional peer.port + 40 - 0x0028000000000008 recommended peer.service + 41 T 0x0029000000000004 required runtime-id + 42 - 0x002A000000000008 required service + 43 - 0x002B000000000008 optional servlet.context + 44 - 0x002C000000000000 optional servlet.path + 45 - 0x002D000000000008 required span.kind + 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 + +# 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 + 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..0c3b38ae22a --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java @@ -0,0 +1,171 @@ +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) 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. + * + *

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 + * 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; + } + + /** + * 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 + * 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/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); 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..053f1c963d1 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java @@ -0,0 +1,231 @@ +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 + } + + /** + * 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<>(); + 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; + } +} 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) diff --git a/tag-conventions.yaml b/tag-conventions.yaml new file mode 100644 index 00000000000..861d11259b0 --- /dev/null +++ b/tag-conventions.yaml @@ -0,0 +1,154 @@ +# 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, 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 ] + 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. +# ---------------------------------------------------------------------------