diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index b31a2fde05c..19bfe847ce7 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -59,6 +59,11 @@ gradlePlugin { implementationClass = "datadog.gradle.plugin.config.SupportedConfigPlugin" } + create("tag-registry-generator") { + id = "dd-trace-java.tag-registry-generator" + implementationClass = "datadog.gradle.plugin.tags.TagRegistryGeneratorPlugin" + } + create("supported-config-linter") { id = "dd-trace-java.config-inversion-linter" implementationClass = "datadog.gradle.plugin.config.ConfigInversionLinter" @@ -107,6 +112,7 @@ dependencies { implementation("com.fasterxml.jackson.core:jackson-databind") implementation("com.fasterxml.jackson.core:jackson-annotations") implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") compileOnly(libs.develocity) diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt new file mode 100644 index 00000000000..fadac53acf9 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/GenerateKnownTagsTask.kt @@ -0,0 +1,35 @@ +package datadog.gradle.plugin.tags + +import javax.inject.Inject +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Generates the committed tag registry (KnownTags.java + assignment reports) from the language-agnostic + * {@code tag-conventions.yaml}. The actual emit lives in [TagRegistryGenerator]; + * this task just wires the inputs/outputs so Gradle can cache and up-to-date-check it. + */ +@CacheableTask +abstract class GenerateKnownTagsTask @Inject constructor(objects: ObjectFactory) : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + val domainYaml: RegularFileProperty = objects.fileProperty() + + + @get:OutputDirectory val destinationDirectory: DirectoryProperty = objects.directoryProperty() + + @TaskAction + fun generate() { + val outDir = destinationDirectory.get().asFile + TagRegistryGenerator.generate(domainYaml.get().asFile, outDir) + logger.lifecycle("tag-registry: generated -> $outDir") + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/KnownTagsEmitter.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/KnownTagsEmitter.kt new file mode 100644 index 00000000000..1e5a80992e0 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/KnownTagsEmitter.kt @@ -0,0 +1,160 @@ +package datadog.gradle.plugin.tags + +import java.util.Locale + +/** + * Emits the generated `KnownTags.java` from a [TagRegistry]. Public API first — per-tag + * `_NAME` (string) + `_ID` (encoded long, literal) couplets with a trailing `// makeTagId(...)` + * derivation comment — then the package-private `_SERIAL_NUM` constants, the + * `StringIndex.EmbeddingSupport` keyOf table, the `serialNum` switch `nameOf`, and resolver + * registration. + */ +object KnownTagsEmitter { + + fun emit(reg: TagRegistry, pkg: String, className: String): String { + // Sanitize tag names into unique Java constant identifiers. + val used = HashSet() + val cname = HashMap() + fun mk(name: String): String { + var c = name.uppercase().replace(Regex("[^A-Za-z0-9]"), "_").replace(Regex("_+"), "_").trim('_') + if (c.isEmpty() || c[0].isDigit()) c = "T_$c" + var u = c + var n = 2 + while (u in used) { + u = "${c}_$n"; n++ + } + used.add(u) + cname[name] = u + return u + } + reg.tags.forEach { mk(it.name) } + + // Constant names. Collapse a duplicated trailing token so e.g. "resource.name" yields NAME + // (not NAME_NAME) and "_dd.parent_id" yields ID (not ID_ID); the non-duplicating pairs + // (ID + _NAME -> ID_NAME, NAME + _ID -> NAME_ID) are kept as-is. + fun withSuffix(base: String, suffix: String) = if (base.endsWith(suffix)) base else "$base$suffix" + fun nameC(name: String) = withSuffix(cname[name]!!, "_NAME") + fun idC(name: String) = withSuffix(cname[name]!!, "_ID") + fun serialC(name: String) = withSuffix(cname[name]!!, "_SERIAL_NUM") + + val order = reg.tags.map { it.name } // stable emit order + // canonical name -> OpenTelemetry name, for the reverse (openTelemetryNameOf) switch. + val otelName = reg.tags.mapNotNull { t -> t.otelName?.let { t.name to it } }.toMap() + val b = StringBuilder() + b.appendLine("package $pkg;") + b.appendLine() + b.appendLine("import datadog.trace.util.StringIndex;") + b.appendLine() + b.appendLine("// GENERATED by the tag-registry code generator (dd-trace-java.tag-registry-generator).") + b.appendLine("// DO NOT EDIT. Source: tag-conventions.yaml.") + b.appendLine("public final class $className {") + b.appendLine() + + // Public API first (name + encoded id couplets), so readers see the useful parts up top; the + // serial ids and keyOf/resolver machinery follow below. Derivation is in the trailing comment. + b.appendLine(" // ---- tags ----") + for (t in reg.tags) { + b.appendLine(" public static final String ${nameC(t.name)} = \"${t.name}\";") + b.appendLine(" public static final long ${idC(t.name)} = ${hex(t.id)};") + b.appendLine(" // makeTagId(serial=${t.serial})${if (t.traceLevel) " + trace-level" else ""}${if (t.otelName != null) " -> ${t.otelName}" else ""} <${t.required}>") + b.appendLine() + } + + // Serial numbers (globalSerial per tag) — package-private, consumed by the resolver switch. + b.appendLine(" // ---- serial numbers ----") + for (t in reg.tags) { + b.appendLine(" static final int ${serialC(t.name)} = ${t.serial};") + } + b.appendLine() + + // OpenTelemetry name -> canonical tag name, for the tags that declare a DISTINCT one. A same-name + // dual (otel-name == dd-name) is already resolvable via the canonical row, so it is skipped here + // to keep the keyOf table free of redundant entries. Deterministic order (by OTel name) so + // output stays byte-identical. + val otelByCanonical = + reg.tags + .mapNotNull { t -> t.otelName?.let { it to t.name } } + .filter { (otel, canonical) -> otel != canonical } + .sortedBy { it.first } + + // keyOf table (open-addressed, via StringIndex.EmbeddingSupport). Canonical names first, then + // OpenTelemetry names -- an OTel name resolves to its canonical tag's id (there is no distinct id + // for it), so keyOf(otelName) == keyOf(canonical); nameOf still returns the canonical name. + b.appendLine(" private static final String[] KEYOF_NAMES = {") + order.forEach { b.appendLine(" ${nameC(it)},") } + otelByCanonical.forEach { (otel, _) -> b.appendLine(" \"$otel\",") } + b.appendLine(" };") + b.appendLine(" private static final long[] KEYOF_VALUES = {") + order.forEach { b.appendLine(" ${idC(it)},") } + otelByCanonical.forEach { (_, canonical) -> b.appendLine(" ${idC(canonical)},") } + b.appendLine(" };") + b.appendLine(" private static final int[] KEYOF_HASHES;") + b.appendLine(" private static final String[] KEYOF_KEYS;") + b.appendLine(" private static final long[] KEYOF_IDS;") + b.appendLine() + b.appendLine(" static {") + b.appendLine(" StringIndex.Data data = StringIndex.EmbeddingSupport.create(KEYOF_NAMES);") + b.appendLine(" long[] ids = new long[data.names.length];") + b.appendLine(" for (int j = 0; j < KEYOF_NAMES.length; j++) {") + b.appendLine(" ids[StringIndex.EmbeddingSupport.indexOf(data.hashes, data.names, KEYOF_NAMES[j])] =") + b.appendLine(" KEYOF_VALUES[j];") + b.appendLine(" }") + b.appendLine(" KEYOF_HASHES = data.hashes;") + b.appendLine(" KEYOF_KEYS = data.names;") + b.appendLine(" KEYOF_IDS = ids;") + b.appendLine(" }") + b.appendLine() + + // Resolver. KnownTagCodec.Installed links to this field directly, so merely resolving a tag + // name initializes this class -- there is no registration step and no ordering to get wrong. + b.appendLine(" /**") + b.appendLine( + " * The registry's name↔id tables, as a {@link KnownTagCodec.Resolver}. {@code KnownTagCodec}") + b.appendLine( + " * reads this field from its own holder, so the two classes complete each other: the codec owns") + b.appendLine( + " * the bit layout and the naming policy, this class owns the data. Nothing has to be called first.") + b.appendLine(" */") + b.appendLine(" static final KnownTagCodec.Resolver RESOLVER =") + b.appendLine(" new KnownTagCodec.Resolver() {") + b.appendLine(" @Override") + b.appendLine(" public String nameOf(long tagId) {") + b.appendLine(" switch (KnownTagCodec.serialNum(tagId)) {") + for (name in order) { + b.appendLine(" case ${serialC(name)}:") + b.appendLine(" return ${nameC(name)};") + } + b.appendLine(" default:") + b.appendLine(" return null;") + b.appendLine(" }") + b.appendLine(" }") + b.appendLine() + // openTelemetryNameOf: canonical id -> OTel-namespace name, null when the tag has none. The + // caller (a serializer) owns any fall-back-to-Datadog-name policy; this stays a pure lookup. + b.appendLine(" @Override") + b.appendLine(" public String openTelemetryNameOf(long tagId) {") + b.appendLine(" switch (KnownTagCodec.serialNum(tagId)) {") + for (name in order) { + val otel = otelName[name] ?: continue + b.appendLine(" case ${serialC(name)}:") + b.appendLine(" return \"$otel\";") + } + b.appendLine(" default:") + b.appendLine(" return null;") + b.appendLine(" }") + b.appendLine(" }") + b.appendLine() + b.appendLine(" @Override") + b.appendLine(" public long keyOf(String name) {") + b.appendLine(" int slot = StringIndex.EmbeddingSupport.indexOf(KEYOF_HASHES, KEYOF_KEYS, name);") + b.appendLine(" return slot < 0 ? 0L : KEYOF_IDS[slot];") + b.appendLine(" }") + b.appendLine(" };") + b.appendLine() + b.appendLine(" private $className() {}") + b.appendLine("}") + return b.toString() + } + + private fun hex(id: Long): String = "0x%016XL".format(Locale.ROOT, id) +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagConventions.kt new file mode 100644 index 00000000000..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/TagRegistry.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt new file mode 100644 index 00000000000..1ba8e43bfa5 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/tags/TagRegistry.kt @@ -0,0 +1,87 @@ +package datadog.gradle.plugin.tags + +/** + * Assigns tag ids from a parsed [TagConventions]. The id encoding mirrors KnownTagCodec: [63-48 + * serial][47-32 reserved][31-0 flags]. + * + *

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

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

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

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

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

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

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

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

This is the one place the pass-through policy lives, so no serializer re-decides it. Pair it + * with {@link #datadogNameOf} for the same tag under the Datadog namespace; outbound naming is + * per-namespace, never normalized to one of them. + */ + public static String openTelemetryTagOf(long tagId) { + Resolver resolver = Installed.RESOLVER; + String otelName = resolver.openTelemetryNameOf(tagId); + return otelName != null ? otelName : resolver.nameOf(tagId); + } + + /** The id for {@code name} in any namespace, or 0 when it is not a known tag. */ + public static long keyOf(String name) { + return Installed.RESOLVER.keyOf(name); + } + + private KnownTagCodec() {} +} diff --git a/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.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. +# ---------------------------------------------------------------------------