diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagConventions.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagConventions.kt index 27ca3cfba0f..9a417835e3f 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagConventions.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagConventions.kt @@ -249,6 +249,47 @@ class TagConventions private constructor( ) } + /** A span type declaration, from the shared conventions or a tracer overlay. */ + @Suppress("UNCHECKED_CAST") + private fun parseSpanType(name: String, v: Any?): SpanType { + require(v is Map<*, *>) { "span type '$name' must be a mapping" } + val m = v as Map + require(m["abstract"] == null || m["abstract"] is Boolean) { "span type '$name' abstract must be a boolean" } + require(m["extends"] == null || m["extends"] is String) { "span type '$name' extends must be a span type name" } + val include = m["include"] + require(include == null || (include is List<*> && include.all { it is String })) { + "span type '$name' include must be a list of mixin names" + } + return SpanType( + name = name, + abstract = (m["abstract"] as? Boolean) ?: false, + extends = m["extends"] as? String, + include = (m["include"] as? List) ?: emptyList(), + tags = tagList(m["tags"]), + refs = refList(m["tags"]), + direction = parseDirection(m, "span type '$name'"), + ) + } + + /** A mixin declaration, from the shared conventions or a tracer overlay. */ + @Suppress("UNCHECKED_CAST") + private fun parseMixin(name: String, v: Any?): Mixin { + require(v is Map<*, *>) { "mixin '$name' must be a mapping" } + val m = v as Map + val applies = m["applies"] + require(applies == null || applies == "all" || (applies is List<*> && applies.all { it is String })) { + "mixin '$name' applies must be 'all' or a list of span types" + } + return Mixin( + name = name, + appliesAll = applies == "all", + appliesTo = if (applies is List<*>) (applies as List).toSet() else emptySet(), + tags = tagList(m["tags"]), + refs = refList(m["tags"]), + direction = parseDirection(m, "mixin '$name'"), + ) + } + /** Reads `span-kind` as the direction it sets, or null when absent. */ private fun parseDirection(m: Map, owner: String): Direction? { val spanKind = m["span-kind"] @@ -262,17 +303,21 @@ class TagConventions private constructor( const val OVERLAY_MIXIN = "tracer overlay" /** - * Parses [root], the language-agnostic conventions, plus [overlay], this tracer's own set-path - * routing: `tags` declares keys that exist only to be routed (e.g. `resource.name`), so each is - * intercepted, and `intercepted` adds the tags declared in [root] that the tracer also routes. + * Parses [root], the language-agnostic conventions, plus [overlay], this tracer's own additions: + * `tags` declares keys that exist only to be routed (e.g. `resource.name`), so each is + * intercepted; `span_types` declares this tracer's own span types, each extending a shared one + * (e.g. servlet's request extends http.server); `mixins` declares this tracer's cross-cutting + * tags, each mixin pushed onto span types with `applies`; and `intercepted` adds the declared + * tags the tracer also routes. */ @Suppress("UNCHECKED_CAST") fun parse(root: Map, overlay: Map = emptyMap()): TagConventions { for (section in listOf("span_types", "mixins", "trace_level")) { require(root[section] == null || root[section] is Map<*, *>) { "$section must be a mapping" } } - require(overlay.keys.all { it == "tags" || it == "intercepted" }) { - "the tracer overlay may only declare `tags` and `intercepted`, not ${overlay.keys - setOf("tags", "intercepted")}" + val overlayKeys = setOf("tags", "span_types", "mixins", "intercepted") + require(overlay.keys.all { it in overlayKeys }) { + "the tracer overlay may only declare $overlayKeys, not ${overlay.keys - overlayKeys}" } require(refList(overlay["tags"]).isEmpty()) { "tracer overlay tags must be declarations, not refs" } val interceptedRaw = overlay["intercepted"] @@ -283,50 +328,39 @@ class TagConventions private constructor( val intercepted = ((interceptedRaw as? List) ?: emptyList()).toSet() + tagList(overlay["tags"]).map { it.ddName } - val spanTypesRaw = (root["span_types"] as? Map) ?: emptyMap() - val parsedSpanTypes = - spanTypesRaw.mapValues { (name, v) -> - require(v is Map<*, *>) { "span type '$name' must be a mapping" } - val m = v as Map - require(m["abstract"] == null || m["abstract"] is Boolean) { - "span type '$name' abstract must be a boolean" - } - require(m["extends"] == null || m["extends"] is String) { - "span type '$name' extends must be a span type name" - } - val include = m["include"] - require(include == null || (include is List<*> && include.all { it is String })) { - "span type '$name' include must be a list of mixin names" - } - SpanType( - name = name, - abstract = (m["abstract"] as? Boolean) ?: false, - extends = m["extends"] as? String, - include = (m["include"] as? List) ?: emptyList(), - tags = tagList(m["tags"]), - refs = refList(m["tags"]), - direction = parseDirection(m, "span type '$name'"), - ) + val rootSpanTypes = ((root["span_types"] as? Map) ?: emptyMap()).mapValues { (name, v) -> parseSpanType(name, v) } + require(overlay["span_types"] == null || overlay["span_types"] is Map<*, *>) { + "tracer overlay span_types must be a mapping" + } + // A tracer's span type extends a shared concept with tags only this tracer sets (servlet's + // request on http.server, say). + val overlaySpanTypes = + ((overlay["span_types"] as? Map) ?: emptyMap()).mapValues { (name, v) -> + require(name !in rootSpanTypes) { "tracer overlay span type '$name' reuses the name of a shared span type" } + val spanType = parseSpanType(name, v) + require(spanType.extends != null) { "tracer overlay span type '$name' must extend a span type" } + spanType } + val parsedSpanTypes = rootSpanTypes + overlaySpanTypes - val mixinsRaw = (root["mixins"] as? Map) ?: emptyMap() - val parsedMixins = - mixinsRaw.mapValues { (name, v) -> - require(v is Map<*, *>) { "mixin '$name' must be a mapping" } - val m = v as Map - val applies = m["applies"] - require(applies == null || applies == "all" || (applies is List<*> && applies.all { it is String })) { - "mixin '$name' applies must be 'all' or a list of span types" + require(overlay["mixins"] == null || overlay["mixins"] is Map<*, *>) { + "tracer overlay mixins must be a mapping" + } + val rootMixins = ((root["mixins"] as? Map) ?: emptyMap()).mapValues { (name, v) -> parseMixin(name, v) } + // A shared span type can't `include` a tracer's mixin by name, so an overlay mixin pushes + // itself onto shared types with `applies`. + val overlayMixins = + ((overlay["mixins"] as? Map) ?: emptyMap()).mapValues { (name, v) -> + require(name !in rootMixins && name != OVERLAY_MIXIN) { + "tracer overlay mixin '$name' reuses the name of a shared mixin" } - Mixin( - name = name, - appliesAll = applies == "all", - appliesTo = if (applies is List<*>) (applies as List).toSet() else emptySet(), - tags = tagList(m["tags"]), - refs = refList(m["tags"]), - direction = parseDirection(m, "mixin '$name'"), - ) - } + overlayMixin(overlay) + val mixin = parseMixin(name, v) + require(mixin.appliesAll || mixin.appliesTo.isNotEmpty()) { + "tracer overlay mixin '$name' must say which span types it applies to" + } + mixin + } + val parsedMixins = rootMixins + overlayMixins + overlayMixin(overlay) for (spanType in parsedSpanTypes.values) { for (included in spanType.include) { diff --git a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt index a88f7509144..7015c825605 100644 --- a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt +++ b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt @@ -543,17 +543,87 @@ class TagRegistryGeneratorTest { ) } + @Test + fun `a tracer overlay mixin applies its tags to shared span types`() { + val mapper = ObjectMapper(YAMLFactory()) + val type = object : TypeReference>() {} + val root: Map = + mapper.readValue( + """ + span_types: + base: {abstract: true, tags: [{dd-name: service}]} + http.server: {extends: base, span-kind: server, tags: [{dd-name: http.route}]} + db.client: {extends: base, span-kind: client, tags: [{dd-name: db.type}]} + """, + type + ) + val overlay: Map = + mapper.readValue( + """ + mixins: + servlet: {applies: [http.server], tags: [{dd-name: servlet.context}, {dd-name: servlet.path}]} + intercepted: [servlet.context] + """, + type + ) + + val conv = TagConventions.parse(root, overlay) + + assertThat(conv.resolve("http.server").map { it.ddName }) + .containsExactlyInAnyOrder("service", "http.route", "servlet.context", "servlet.path") + assertThat(conv.resolve("db.client").map { it.ddName }).containsExactlyInAnyOrder("service", "db.type") + assertThat(conv.interceptedNames).contains("servlet.context") + } + + @Test + fun `a tracer overlay span type extends a shared one`() { + val mapper = ObjectMapper(YAMLFactory()) + val type = object : TypeReference>() {} + val root: Map = + mapper.readValue( + """ + span_types: + base: {abstract: true, tags: [{dd-name: service}]} + http.server: {extends: base, span-kind: server, tags: [{dd-name: http.route}]} + """, + type + ) + val overlay: Map = + mapper.readValue( + """ + span_types: + servlet.request: {extends: http.server, tags: [{dd-name: servlet.context}, {dd-name: servlet.path}]} + intercepted: [servlet.context] + """, + type + ) + + val conv = TagConventions.parse(root, overlay) + + assertThat(conv.concreteTypes()).contains("http.server", "servlet.request") + assertThat(conv.resolve("servlet.request").map { it.ddName }) + .containsExactlyInAnyOrder("service", "http.route", "servlet.context", "servlet.path") + assertThat(conv.resolve("http.server").map { it.ddName }).containsExactlyInAnyOrder("service", "http.route") + } + @TableTest( """ scenario | overlay | message undeclared name | 'intercepted: [missing]' | is not declared ref in overlay | 'tags: [{ref: service}]' | must be declarations, not refs - unknown section | 'span_types: {}' | may only declare + unknown section | 'trace_level: {}' | may only declare duplicate of a tag | 'tags: [{dd-name: service}]' | declared in both + mixin with no applies | 'mixins: {servlet: {tags: [{dd-name: servlet.path}]}}' | must say which span types + mixin reusing a shared name | 'mixins: {peer: {applies: [base], tags: [{dd-name: x.y}]}}' | reuses the name + type with no extends | 'span_types: {servlet.request: {tags: [{dd-name: servlet.path}]}}' | must extend a span type + type reusing a shared name | 'span_types: {base: {extends: base}}' | reuses the name of a shared span type """ ) fun `invalid tracer overlays are rejected`(overlay: String, message: String) { - val yaml = directory.conventionsFile("span_types: {base: {tags: [{dd-name: service}]}}") + val yaml = + directory.conventionsFile( + "span_types: {base: {tags: [{dd-name: service}]}}\nmixins: {peer: {tags: [{dd-name: peer.service}]}}" + ) val overlayFile = directory.writeFile("overlay.yaml", overlay) assertThatIllegalArgumentException() diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java index 6edb66d1f98..1971a551f88 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java @@ -94,7 +94,6 @@ public class InstrumentationTags { public static final String SERVLET_PATH = "servlet.path"; public static final String SERVLET_DISPATCH = "servlet.dispatch"; public static final String TIMEOUT = "timeout"; - public static final String JMS_PRODUCE = "jms.produce"; public static final String JSP_COMPILER = "jsp.compiler"; public static final String JSP_CLASSFQCN = "jsp.classFQCN"; public static final String JSP_FORWARD_ORIGIN = "jsp.forwardOrigin"; diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java index 9e96c17a486..12bbb0b9502 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java @@ -50,7 +50,6 @@ public class Tags { public static final String DB_OPERATION = "db.operation"; public static final String DB_STATEMENT = "db.statement"; public static final String DB_WAREHOUSE = "db.warehouse"; - public static final String DB_HOST = "db.host"; public static final String DB_SCHEMA = "db.schema"; public static final String MESSAGE_BUS_DESTINATION = "message_bus.destination"; public static final String DB_POOL_NAME = "db.pool.name"; diff --git a/internal-api/src/test/java/datadog/trace/api/TagConstantsRegistryCoverageTest.java b/internal-api/src/test/java/datadog/trace/api/TagConstantsRegistryCoverageTest.java new file mode 100644 index 00000000000..9a0cc104520 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagConstantsRegistryCoverageTest.java @@ -0,0 +1,229 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** + * Every tag-name constant in {@link Tags}, {@link InstrumentationTags} and {@link DDTags} is in the + * tag registry, so instrumentation can set it by id -- or is listed here. Entries are {@code + * Class.FIELD} names or patterns, where {@code *} matches any run of characters. + * + *
    + *
  • {@link #NEVER_REGISTERED}: constants deliberately never registered, with the reason. A + * pattern excludes every constant it matches. + *
  • {@link #NOT_YET_REGISTERED_FAMILIES}: whole families not registered yet, with the reason. A + * pattern excuses only the unregistered constants it matches, and must match at least one. + *
  • {@link #NOT_YET_REGISTERED}: single constants not registered yet. A ratchet: registering + * one fails the test until its entry is removed, so the list only gets shorter. + *
+ * + *

A new constant must be registered or listed. + */ +class TagConstantsRegistryCoverageTest { + static final List> TAG_CONSTANT_CLASSES = + Arrays.asList(Tags.class, InstrumentationTags.class, DDTags.class); + + /** + * Constants deliberately never registered, with the reason: values of a tag, name prefixes and + * patterns, operation names, and names only the serializers write. + */ + static final Map NEVER_REGISTERED = new TreeMap<>(); + + static { + NEVER_REGISTERED.put( + "DDTags.RUNTIME_VERSION_TAG", "a profiling and telemetry tag, not a span tag"); + NEVER_REGISTERED.put( + "DDTags.THREAD_ID", "a span field, written by the serializers; never stored as a tag"); + NEVER_REGISTERED.put( + "DDTags.THREAD_NAME", "a span field, written by the serializers; never stored as a tag"); + NEVER_REGISTERED.put( + "DDTags.TRACE_START_TIME", "a baggage key (legacy end-to-end timing), not a tag"); + NEVER_REGISTERED.put("Tags.DD_ENV", "a log correlation (MDC) key, not a span tag"); + NEVER_REGISTERED.put("Tags.DD_SERVICE", "a log correlation (MDC) key, not a span tag"); + NEVER_REGISTERED.put("Tags.DD_VERSION", "a log correlation (MDC) key, not a span tag"); + NEVER_REGISTERED.put("DDTags.DB_STATEMENT", "unused, but public API"); + NEVER_REGISTERED.put( + "DDTags.DD_CODE_ORIGIN_FRAME_*", + "an instance of a name pattern (_dd.code_origin.frames.N.*), not modeled yet"); + NEVER_REGISTERED.put("DDTags.DD_CODE_ORIGIN_PREFIX", "a name prefix"); + NEVER_REGISTERED.put("DDTags.LANGUAGE_TAG_VALUE", "a value of language"); + NEVER_REGISTERED.put( + "DDTags.PROCESS_TAGS", "written by the serializers, never stored as a span tag"); + NEVER_REGISTERED.put( + "DDTags.SDK_OTLP_EXPORT", "written by the serializers, never stored as a span tag"); + NEVER_REGISTERED.put("InstrumentationTags.SERVLET_DISPATCH", "an operation name, not a tag"); + NEVER_REGISTERED.put("Tags.LLMOBS_*_SPAN_KIND", "values of an LLM Observability span kind"); + NEVER_REGISTERED.put( + "Tags.MESSAGE_BUS_DESTINATION", + "unused in production; TagInterceptorTest splits by it as a custom tag"); + NEVER_REGISTERED.put("Tags.SPAN_KIND_*", "values of span.kind"); + } + + /** Families not registered yet, with the reason. */ + static final Map NOT_YET_REGISTERED_FAMILIES = new TreeMap<>(); + + static { + NOT_YET_REGISTERED_FAMILIES.put("Tags.OS_*", "CI Visibility, in a follow-up PR"); + NOT_YET_REGISTERED_FAMILIES.put("Tags.RUNTIME_*", "CI Visibility, in a follow-up PR"); + NOT_YET_REGISTERED_FAMILIES.put("DDTags.HOST_VCPU_COUNT", "CI Visibility, in a follow-up PR"); + NOT_YET_REGISTERED_FAMILIES.put( + "DDTags.LIBRARY_VERSION_TAG_KEY", "CI Visibility, in a follow-up PR"); + NOT_YET_REGISTERED_FAMILIES.put("Tags.ITR_CORRELATION_ID", "CI Visibility, in a follow-up PR"); + NOT_YET_REGISTERED_FAMILIES.put("Tags.PULL_REQUEST_NUMBER", "CI Visibility, in a follow-up PR"); + NOT_YET_REGISTERED_FAMILIES.put("DDTags.CI_*", "CI Visibility, in a follow-up PR"); + NOT_YET_REGISTERED_FAMILIES.put("DDTags.TEST_*", "CI Visibility, in a follow-up PR"); + NOT_YET_REGISTERED_FAMILIES.put("Tags.CI_*", "CI Visibility, in a follow-up PR"); + NOT_YET_REGISTERED_FAMILIES.put( + "Tags.GIT_*", "CI Visibility and source code metadata, in a follow-up PR"); + NOT_YET_REGISTERED_FAMILIES.put("Tags.TEST_*", "CI Visibility, in a follow-up PR"); + } + + /** + * Single constants not registered yet. Remove an entry when its tag is registered. Empty: every + * constant outside the families above is registered or deliberately never registered. + */ + static final Set NOT_YET_REGISTERED = new HashSet<>(); + + @Test + void everyTagConstantIsRegisteredOrListed() { + List unlisted = new ArrayList<>(); + for (Map.Entry constant : tagConstants().entrySet()) { + String name = constant.getKey(); + if (!isRegistered(constant.getValue()) + && !NOT_YET_REGISTERED.contains(name) + && !matchesAny(NOT_YET_REGISTERED_FAMILIES.keySet(), name) + && !matchesAny(NEVER_REGISTERED.keySet(), name)) { + unlisted.add(name + " = \"" + constant.getValue() + "\""); + } + } + assertEquals( + Collections.emptyList(), + unlisted, + "register these tags in tag-conventions.yaml (or the Java overlay), or list them here"); + } + + @Test + void noSingleNotYetRegisteredConstantIsRegistered() { + Map constants = tagConstants(); + List registered = new ArrayList<>(); + for (String name : NOT_YET_REGISTERED) { + String value = constants.get(name); + if (value != null && isRegistered(value)) { + registered.add(name); + } + } + Collections.sort(registered); + assertEquals( + Collections.emptyList(), registered, "now registered: remove from NOT_YET_REGISTERED"); + } + + @Test + void noNeverRegisteredConstantIsRegistered() { + List registered = new ArrayList<>(); + for (Map.Entry constant : tagConstants().entrySet()) { + if (matchesAny(NEVER_REGISTERED.keySet(), constant.getKey()) + && isRegistered(constant.getValue())) { + registered.add(constant.getKey()); + } + } + assertEquals(Collections.emptyList(), registered, "registered, but listed as never registered"); + } + + @Test + void everyEntryStillMatchesAConstant() { + Map constants = tagConstants(); + List stale = new ArrayList<>(); + for (String listed : NOT_YET_REGISTERED) { + if (!constants.containsKey(listed)) { + stale.add(listed); + } + } + for (String pattern : NEVER_REGISTERED.keySet()) { + if (!matchesAnyConstant(pattern, constants, false)) { + stale.add(pattern); + } + } + // A family is done once none of its constants is unregistered. + for (String pattern : NOT_YET_REGISTERED_FAMILIES.keySet()) { + if (!matchesAnyConstant(pattern, constants, true)) { + stale.add(pattern); + } + } + Collections.sort(stale); + assertEquals(Collections.emptyList(), stale, "no constant matches these entries: remove them"); + } + + private static boolean matchesAnyConstant( + String pattern, Map constants, boolean unregisteredOnly) { + for (Map.Entry constant : constants.entrySet()) { + if (matches(pattern, constant.getKey()) + && !(unregisteredOnly && isRegistered(constant.getValue()))) { + return true; + } + } + return false; + } + + private static boolean matchesAny(Set patterns, String name) { + for (String pattern : patterns) { + if (matches(pattern, name)) { + return true; + } + } + return false; + } + + /** {@code name} matches {@code pattern}, where {@code *} matches any run of characters. */ + private static boolean matches(String pattern, String name) { + if (pattern.indexOf('*') < 0) { + return pattern.equals(name); + } + StringBuilder regex = new StringBuilder(); + for (String literal : pattern.split("\\*", -1)) { + if (regex.length() > 0) { + regex.append(".*"); + } + regex.append(Pattern.quote(literal)); + } + return name.matches(regex.toString()); + } + + private static boolean isRegistered(String tag) { + return KnownTagCodec.keyOf(tag) != 0; + } + + /** Every public static final String constant of the tag classes, keyed {@code Class.FIELD}. */ + private static Map tagConstants() { + Map constants = new TreeMap<>(); + for (Class cls : TAG_CONSTANT_CLASSES) { + for (Field field : cls.getDeclaredFields()) { + int mods = field.getModifiers(); + if (field.getType() == String.class + && Modifier.isPublic(mods) + && Modifier.isStatic(mods) + && Modifier.isFinal(mods)) { + try { + constants.put(cls.getSimpleName() + "." + field.getName(), (String) field.get(null)); + } catch (IllegalAccessException e) { + throw new AssertionError(e); + } + } + } + } + return constants; + } +} diff --git a/tag-conventions-java.yaml b/tag-conventions-java.yaml index 2f3d89e39ed..f853c33adab 100644 --- a/tag-conventions-java.yaml +++ b/tag-conventions-java.yaml @@ -1,9 +1,12 @@ -# Tracer overlay for tag-conventions.yaml — dd-trace-java's set-path routing. +# Tracer overlay for tag-conventions.yaml — dd-trace-java's own span types and set-path routing. # --------------------------------------------------------------------------- -# tag-conventions.yaml is language-agnostic; which keys a tracer intercepts on the set-path (routing -# them to a span field or a sampling directive instead of tag storage) is this tracer's concern, so -# it lives here. +# tag-conventions.yaml is language-agnostic. What only this tracer has lives here: span types for Java +# technologies, and which keys it intercepts on the set-path (routing them to a span field or a +# sampling directive instead of tag storage). # +# span_types — span types only this tracer has, each extending a shared one with tags only this +# tracer sets (servlet's request extends http.server). Their tags get slots like any other. +# mixins — this tracer's cross-cutting tags, pushed onto span types with `applies`. (None yet.) # tags — keys that exist only to be routed, so the conventions do not declare them. They get # ids like any other tag (instrumentation can set them by id) but belong to no span type. # Each is intercepted, so it is not listed again below. Same declaration syntax as @@ -14,6 +17,127 @@ # keeps this list and the switch in agreement. # --------------------------------------------------------------------------- +span_types: + # Servlet requests: http.server spans from a Servlet container, with its context and path. + servlet.request: + extends: http.server + tags: + - { dd-name: servlet.context, type: string, required: optional } + - { dd-name: servlet.path, type: string, required: optional } + - { dd-name: user.principal, type: string, required: optional } # the authenticated principal's name + - { dd-name: timeout, type: long, required: optional } # Servlet 3 async timeout, when it fires + + # Spring Web MVC view rendering: the rendered view's class. + spring.view.render: + extends: view.render + tags: + - { dd-name: view.type, type: string, required: optional } + + # Spring WebFlux handlers: the handler's class, and a functional route's predicate. + spring.webflux.handler: + extends: web.handler + tags: + - { dd-name: handler.type, type: string, required: optional } + - { dd-name: request.predicate, type: string, required: optional } + + # Spring WebFlux's HTTP client: why a request was cancelled. + spring.webflux.client: + extends: http.client + tags: + - { dd-name: message, type: string, required: optional } + - { dd-name: event, type: string, required: optional } # "cancelled" + + # TIBCO BusinessWorks process and activity spans: the app node and version. + tibco.process: + extends: internal + tags: + - { dd-name: tibco.node, type: string, required: optional } + - { dd-name: tibco.version, type: string, required: optional } + + # Mule action spans: the correlation id, and the location when Mule supplies one. + mule.action: + extends: internal + tags: + - { dd-name: mule.correlation_id, type: string, required: recommended } + - { dd-name: mule.location, type: string, required: optional } + + # Elasticsearch REST client requests. + elasticsearch.rest: + extends: db.client + tags: + - { ref: http.method } + - { ref: http.url } # the endpoint path + - { dd-name: elasticsearch.body, type: string, required: optional } # off by default + - { dd-name: elasticsearch.params, type: string, required: optional } + + # Elasticsearch transport client actions. + elasticsearch.transport: + extends: db.client + tags: + - { dd-name: elasticsearch.action, type: string, required: recommended } + - { dd-name: elasticsearch.request, type: string, required: recommended } + - { dd-name: elasticsearch.request.indices, type: string, required: optional } + - { dd-name: elasticsearch.request.search.types, type: string, required: optional } + - { dd-name: elasticsearch.request.write.type, type: string, required: optional } + - { dd-name: elasticsearch.request.write.routing, type: string, required: optional } + - { dd-name: elasticsearch.request.write.version, type: long, required: optional } + - { dd-name: elasticsearch.type, type: string, required: optional } + - { dd-name: elasticsearch.id, type: string, required: optional } + - { dd-name: elasticsearch.version, type: long, required: optional } + - { dd-name: elasticsearch.shard.broadcast.total, type: int, required: optional } + - { dd-name: elasticsearch.shard.broadcast.successful, type: int, required: optional } + - { dd-name: elasticsearch.shard.broadcast.failed, type: int, required: optional } + - { dd-name: elasticsearch.shard.replication.total, type: int, required: optional } + - { dd-name: elasticsearch.shard.replication.successful, type: int, required: optional } + - { dd-name: elasticsearch.shard.replication.failed, type: int, required: optional } + - { dd-name: elasticsearch.response.status, type: int, required: optional } + - { dd-name: elasticsearch.shard.bulk.id, type: int, required: optional } + - { dd-name: elasticsearch.shard.bulk.index, type: string, required: optional } + - { dd-name: elasticsearch.node.failures, type: int, required: optional } + - { dd-name: elasticsearch.node.cluster.name, type: string, required: optional } + + # Twilio SDK calls: client spans typed http, though they set no HTTP tags. + twilio.sdk: + extends: http.client + tags: + - { dd-name: twilio.type, type: string, required: recommended } + - { dd-name: twilio.account, type: string, required: optional } + - { dd-name: twilio.sid, type: string, required: optional } + - { dd-name: twilio.status, type: string, required: optional } + - { dd-name: twilio.parentSid, type: string, required: optional } # calls only + + # JSP compilation and rendering (span.kind unset at runtime). + jsp.compile: + extends: internal + tags: + - { ref: servlet.context } + - { dd-name: jsp.compiler, type: string, required: optional } + - { dd-name: jsp.classFQCN, type: string, required: recommended } + + jsp.render: + extends: view.render + tags: + - { ref: servlet.context } + - { dd-name: jsp.forwardOrigin, type: string, required: optional } + - { dd-name: jsp.requestURL, type: string, required: optional } + + # Quartz scheduled job executions (span.kind unset at runtime). + quartz.job: + extends: internal + tags: + - { dd-name: quartz.job.name, type: string, required: optional } + - { dd-name: quartz.job.group, type: string, required: optional } + - { dd-name: quartz.trigger.name, type: string, required: optional } + - { dd-name: quartz.trigger.group, type: string, required: optional } + + # Hystrix commands, when Hystrix tags are enabled (span.kind unset at runtime). + hystrix.command: + extends: internal + tags: + - { dd-name: hystrix.command, type: string, required: optional } + - { dd-name: hystrix.group, type: string, required: optional } + - { dd-name: hystrix.circuit-open, type: boolean, required: optional } + tags: - { dd-name: resource.name, type: string } - { dd-name: span.type, type: string } diff --git a/tag-conventions.yaml b/tag-conventions.yaml index b54462a6a62..d2579b5fe74 100644 --- a/tag-conventions.yaml +++ b/tag-conventions.yaml @@ -58,7 +58,9 @@ # bucketed. See the design doc. # --------------------------------------------------------------------------- -# Trace-level tier: its own TagMap on the TraceSegment. Set once per trace, not per span. +# Trace-level tier: its own TagMap on the TraceSegment. Two kinds of tag, neither per span: facts that +# are static for the process (language, runtime-id, the product .enabled flags), and values common to +# every span of a trace (the local root span's tags, e.g. the attribute schema version). # (Their OTel mapping is a resource-attribute follow-on; they pass through under dd-name for now.) trace_level: tags: @@ -77,6 +79,12 @@ trace_level: - { 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 } + - { dd-name: _dd.apm.enabled, type: int, required: optional } # 0 on every span, only when APM tracing is disabled + # local root span tags + - { dd-name: _dd.trace_span_attribute_schema, type: int, required: required } + - { dd-name: _dd.profiling.ctx, type: string, required: optional } # once the profiling context integration is available + - { dd-name: process_id, type: long, required: required } + - { dd-name: ai_guard.event, type: boolean, required: optional } # when the trace holds an AI Guard evaluation span span_types: # root: per-span tags every span has (incl. the per-span core tags parent_id / integration / svc_src @@ -93,6 +101,8 @@ span_types: - { 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 } + - { dd-name: _dd.span_links, type: string, required: optional } # JSON; only for a span with links, when links are injected as tags + - { dd-name: events, type: string, required: optional } # span events as JSON, when the writer can't encode them natively http: abstract: true @@ -101,7 +111,9 @@ span_types: - { 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 - - { dd-name: http.url, type: string, required: required, otel-name: url.full } # shared by http.server and 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.url, type: string, required: required, otel-name: url.full } + - { dd-name: http.fragment.string, type: string, required: optional } # only when query strings are tagged + - { dd-name: http.endpoint, type: string, required: optional } # simplified endpoint, when resource renaming is on # shared by http.server and 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. http.server: extends: http @@ -112,12 +124,20 @@ span_types: - { dd-name: http.hostname, type: string, required: required, otel-name: server.address } # inbound only; on outbound spans server.address is peer.hostname - { dd-name: http.useragent, type: string, required: recommended, otel-name: user_agent.original, span-kind-neutral: true } - { dd-name: http.query.string, type: string, required: recommended } # not a rename: url.query is also carried inside url.full, while http.url excludes the query (QueryObfuscator re-appends it) - - { dd-name: servlet.path, type: string, required: optional } - - { dd-name: servlet.context, type: string, required: optional } - { dd-name: http.client_ip, type: string, required: recommended, otel-name: client.address, span-kind-neutral: true } # Inbound only: the client's socket address. Server spans also carry that address in peer.ipv4 / # peer.ipv6, so do not map those inbound too, or the attribute is exported twice. - { dd-name: network.client.ip, type: string, required: recommended, otel-name: network.peer.address } + # Forwarding headers, when client IPs are tagged and the header is present. + - { dd-name: http.forwarded, type: string, required: optional } + - { dd-name: http.forwarded.proto, type: string, required: optional } + - { dd-name: http.forwarded.host, type: string, required: optional } + - { dd-name: http.forwarded.ip, type: string, required: optional } + - { dd-name: http.forwarded.port, type: string, required: optional } + # Security-testing request headers, when present. Fixed members of the http.request.headers.

+ # family, which the security-testing code always checks for. + - { dd-name: http.request.headers.x-datadog-endpoint-scan, type: string, required: optional } + - { dd-name: http.request.headers.x-datadog-security-test, type: string, required: optional } http.client: extends: http @@ -126,6 +146,65 @@ span_types: tags: - { dd-name: http.resend_count, type: int, required: recommended } + # AWS SDK calls: HTTP client spans, one type per AWS service (a span never carries two services' + # tags). aws.client itself covers the services not modeled below. The request tags shared with SQS + # consumer spans are the aws_request mixin. + aws.client: + extends: http.client + + aws.s3: + extends: aws.client + tags: + - { dd-name: aws.bucket.name, type: string, required: optional } + - { dd-name: bucketname, type: string, required: optional } + - { dd-name: aws.object.key, type: string, required: optional } + - { dd-name: aws.storage.class, type: string, required: optional } + # Data Streams payload sizes, when Data Streams is enabled. + - { dd-name: http.request_content_length, type: long, required: optional } + - { dd-name: http.response_content_length, type: long, required: optional } + # Span pointer input, removed before serialization (SpanPointersProcessor). + - { dd-name: s3.eTag, type: string, required: optional } + + aws.sqs: + extends: aws.client + + aws.sns: + extends: aws.client + tags: + - { dd-name: aws.topic.arn, type: string, required: optional } + - { dd-name: aws.topic.name, type: string, required: optional } + - { dd-name: topicname, type: string, required: optional } + + aws.kinesis: + extends: aws.client + tags: + - { dd-name: aws.stream.name, type: string, required: optional } + - { dd-name: streamname, type: string, required: optional } + - { dd-name: aws.stream.arn, type: string, required: optional } + + aws.dynamodb: + extends: aws.client + tags: + - { dd-name: aws.table.name, type: string, required: optional } + - { dd-name: tablename, type: string, required: optional } + # Span pointer input, removed before serialization (SpanPointersProcessor). + - { dd-name: dynamodb.primary_key_1, type: string, required: optional } + - { dd-name: dynamodb.primary_key_1_value, type: string, required: optional } + - { dd-name: dynamodb.primary_key_2, type: string, required: optional } + - { dd-name: dynamodb.primary_key_2_value, type: string, required: optional } + + aws.sfn: + extends: aws.client + tags: + - { dd-name: aws.state_machine.arn, type: string, required: optional } + - { dd-name: aws.execution.arn, type: string, required: optional } + + aws.lambda: + extends: aws.client + tags: + - { dd-name: aws.function.name, type: string, required: optional } + - { dd-name: aws.function.arn, type: string, required: optional } # when the function is named by ARN + db.client: extends: base span-kind: client @@ -135,8 +214,39 @@ span_types: - { 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, span-kind-neutral: true } - { 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, span-kind-neutral: true } + # A command's outcome (Redis, Memcached). + - { dd-name: db.command.cancelled, type: boolean, required: optional } + - { dd-name: db.command.results.count, type: int, required: optional } + + # Relational databases (JDBC, R2DBC). + db.sql: + extends: db.client + tags: + - { dd-name: db.pool.name, type: string, required: optional } + - { dd-name: db.schema, type: string, required: optional } + - { dd-name: db.warehouse, type: string, required: optional } + + db.cassandra: + extends: db.client + tags: + - { dd-name: db.cassandra.contact.points, type: string, required: optional } + + db.couchbase: + extends: db.client + tags: + - { dd-name: db.couchbase.seed.nodes, type: string, required: optional } + - { dd-name: bucket, type: string, required: optional } + - { dd-name: couchbase.operation_id, type: string, required: optional } + + db.redis: + extends: db.client + tags: + - { dd-name: db.redis.dbIndex, type: int, required: optional } + + # MongoDB: no tags of its own, but Database Monitoring applies to it. + db.mongo: + extends: db.client view.render: extends: base @@ -144,6 +254,102 @@ span_types: tags: - { dd-name: view.name, type: string, required: recommended } + # Messaging: one type per span kind; each messaging system adds its tags with a mixin. + messaging: + abstract: true + extends: base + + messaging.producer: + extends: messaging + span-kind: producer + include: [ peer, peer_address, outbound_peer ] + + # TODO: RabbitMQ consumers also set peer.hostname, which outbound_peer declares for client spans only + # (as on websocket spans). + messaging.consumer: + extends: messaging + span-kind: consumer + include: [ peer_address, inbound_peer ] + tags: + - { dd-name: record.queue_time_ms, type: long, required: optional } # from the message's timestamp, when it has one + - { dd-name: record.e2e_duration_ms, type: long, required: optional } # only when end-to-end durations are enabled + + # Time a message spent in the broker (span.kind broker). The registry gives broker no direction, + # and none of these tags depends on one. + messaging.broker: + extends: messaging + + # A client command that neither publishes nor consumes (e.g. an AMQP queue declare). + messaging.client: + extends: messaging + span-kind: client + include: [ peer, peer_address, outbound_peer ] + + # A consumer's poll for a batch (span.kind unset). + messaging.poll: + extends: messaging + tags: + - { dd-name: kafka.records_count, type: int, required: optional } # created only when Data Streams is enabled + + # RPC: client and server calls, and their per-message child spans. + rpc: + abstract: true + extends: base + tags: + - { dd-name: rpc.service, type: string, required: recommended } # set on client spans (and SofaRPC servers) + - { dd-name: rpc.grpc.status_code, type: int, required: conditional } + - { dd-name: message.type, type: string, required: optional } # on gRPC message child spans + - { dd-name: status.code, type: string, required: conditional } # gRPC status code name, on close + - { dd-name: status.description, type: string, required: optional } + + rpc.client: + extends: rpc + span-kind: client + include: [ peer, peer_address, outbound_peer ] + + rpc.server: + extends: rpc + span-kind: server + include: [ peer_address, inbound_peer ] + + # An internal span no other type models, e.g. an integration platform's process or action step. + internal: + extends: base + span-kind: internal + + # A web framework's controller or handler span, inside the server span. + web.handler: + extends: base + span-kind: internal + + # Websocket spans: one per message received or sent, and one per close. Receive spans and inbound + # close spans start their own trace, linked to the handshake. TODO: they also copy peer.hostname + # from the handshake, which outbound_peer declares for client spans only. + websocket: + abstract: true + extends: base + tags: + - { dd-name: websocket.session.id, type: string, required: optional } # off by default + - { dd-name: websocket.message.type, type: string, required: conditional } + - { dd-name: websocket.message.length, type: long, required: conditional } + - { dd-name: websocket.message.frames, type: long, required: conditional } + - { dd-name: websocket.message.receive_time, type: long, required: conditional } + + websocket.receive: + extends: websocket + span-kind: consumer + + websocket.send: + extends: websocket + span-kind: producer + + # Both directions: a close received (consumer) or sent (producer). + websocket.close: + extends: websocket + tags: + - { dd-name: websocket.close.code, type: int, required: required } + - { dd-name: websocket.close.reason, type: string, required: recommended } + mixins: # peer — the downstream service a client span calls, PULLED via `include` by client span types. peer: @@ -178,6 +384,95 @@ mixins: tags: - { dd-name: peer.port, type: int, otel-name: client.port } + # code_origin — the debugger's code origin, on entry spans (and their local root), annotated + # controller methods' handler spans, and @Trace spans. + # Only the origin's type is registered: its frames are _dd.code_origin.frames.., a name + # pattern, which the registry doesn't model yet. (Only frame 0 is produced today.) + code_origin: + applies: [ http.server, rpc.server, messaging.consumer, internal, web.handler ] + tags: + - { dd-name: _dd.code_origin.type, type: string, required: optional } + + # dbm — Database Monitoring propagation into queries, when enabled. + dbm: + applies: [ db.sql, db.mongo ] + tags: + - { dd-name: _dd.dbm_trace_injected, type: boolean, required: optional } + - { dd-name: _dd.propagated_hash, type: string, required: optional } + - { dd-name: _dd.instrumentation.time_ms, type: long, required: optional } + + # exception_replay — Exception Replay, on any span carrying an exception it captured. + exception_replay: + applies: all + tags: + - { dd-name: error.debug_info_captured, type: boolean, required: optional } + + # data_streams — Data Streams Monitoring checkpoints, on the spans that carry or receive a pathway. + data_streams: + applies: [ http, messaging, rpc ] + tags: + - { dd-name: pathway.hash, type: string, required: optional } + + # dsm_transactions — Data Streams transaction tracking, when an extractor matches. + dsm_transactions: + applies: [ http, messaging.producer, messaging.consumer ] + tags: + - { dd-name: dsm.transaction.id, type: string, required: optional } + - { dd-name: dsm.transaction.checkpoint, type: string, required: optional } + + # aws_request — an AWS SDK request: on AWS client spans, and on the SQS consumer and broker spans that + # receive its messages. + aws_request: + applies: [ aws.client, messaging.consumer, messaging.broker ] + tags: + - { dd-name: aws.agent, type: string, required: recommended } + - { dd-name: aws.service, type: string, required: recommended } + - { dd-name: aws_service, type: string, required: recommended } + - { dd-name: aws.operation, type: string, required: recommended } + - { dd-name: aws.endpoint, type: string, required: optional } # SDK v1 only + - { dd-name: aws.requestId, type: string, required: optional } + + # aws_sqs_queue — the SQS queue, on SQS client spans and on SQS consumer and broker spans. + aws_sqs_queue: + applies: [ aws.sqs, messaging.consumer, messaging.broker ] + tags: + - { dd-name: aws.queue.url, type: string, required: optional } + - { dd-name: aws.queue.name, type: string, required: optional } + - { dd-name: queuename, type: string, required: optional } + + # kafka — Kafka producer and consumer spans (and Kafka Streams consumers). + kafka: + applies: [ messaging.producer, messaging.consumer ] + tags: + - { dd-name: messaging.destination.name, type: string, required: recommended } # the topic + - { dd-name: messaging.kafka.bootstrap.servers, type: string, required: optional } + - { dd-name: messaging.kafka.cluster.id, type: string, required: optional } + - { dd-name: kafka.group, type: string, required: optional } # consumers + - { dd-name: partition, type: int, required: optional } + - { dd-name: offset, type: long, required: optional } + - { dd-name: tombstone, type: boolean, required: optional } # a null record value + - { dd-name: processor.name, type: string, required: optional } # Kafka Streams + + # amqp — RabbitMQ (AMQP) spans: publish, consume, other commands, and time in queue. + amqp: + applies: [ messaging.producer, messaging.consumer, messaging.broker, messaging.client ] + tags: + - { dd-name: amqp.command, type: string, required: recommended } + - { dd-name: amqp.exchange, type: string, required: optional } + - { dd-name: amqp.routing_key, type: string, required: optional } + - { dd-name: amqp.queue, type: string, required: optional } + - { dd-name: amqp.delivery_mode, type: int, required: optional } + - { dd-name: message.size, type: int, required: optional } + + # inherited_sampling — a span that starts its own trace but inherits the sampling decision of the + # span it came from (a websocket message, from its handshake). + inherited_sampling: + applies: [ websocket.receive, websocket.close ] + tags: + - { dd-name: _dd.dm.inherited, type: int, required: optional } + - { dd-name: _dd.dm.service, type: string, required: optional } + - { dd-name: _dd.dm.resource, type: string, required: optional } + # 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