diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpClientDecorator.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpClientDecorator.java index 42214929571..3d85a2851d1 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpClientDecorator.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpClientDecorator.java @@ -23,6 +23,7 @@ import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes; +import datadog.trace.bootstrap.instrumentation.api.ResourceNamePriorities; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.URIUtils; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; @@ -102,8 +103,13 @@ public AgentSpan onRequest(final AgentSpan span, final REQUEST request) { request, DSM_TRANSACTION_SOURCE_READER); + final boolean otelSemantics = Config.get().isTraceOtelSemanticsEnabled(); String method = method(request); - span.setTag(Tags.HTTP_METHOD, method); + if (otelSemantics) { + OtelHttpSemantics.setRequestMethod(span, method); + } else { + span.setTag(Tags.HTTP_METHOD, method); + } if (CLIENT_TAG_HEADERS) { for (Map.Entry headerTag : @@ -120,15 +126,40 @@ public AgentSpan onRequest(final AgentSpan span, final REQUEST request) { final URI url = url(request); if (url != null) { onURI(span, url); - span.setTag( - Tags.HTTP_URL, - URIUtils.lazyValidURL(url.getScheme(), url.getHost(), url.getPort(), url.getPath())); - if (Config.get().isHttpClientTagQueryString()) { - span.setTag(DDTags.HTTP_QUERY, url.getQuery()); - span.setTag(DDTags.HTTP_FRAGMENT, url.getFragment()); - } - if (shouldSetResourceName()) { - HTTP_RESOURCE_DECORATOR.withClientPath(span, method, url.getPath()); + if (otelSemantics) { + // OTel client: the absolute URL goes in url.full only. Clients don't use the decomposed + // url.path/url.query, and url.scheme is opt-in (already contained in url.full), so we + // don't emit it. Target is server.address/port; span is named by method only. + String urlFull = OtelHttpSemantics.redactedUrl(url); + if (!Config.get().isHttpClientTagQueryString()) { + // honor the query-string opt-out (privacy/PII) just like the Datadog http.url path + urlFull = OtelHttpSemantics.withoutQueryAndFragment(urlFull); + } + span.setTag(Tags.URL_FULL, urlFull); + if (url.getHost() != null) { + span.setTag(Tags.SERVER_ADDRESS, url.getHost()); + } + int serverPort = OtelHttpSemantics.serverPort(url); + if (serverPort > 0) { + span.setTag(Tags.SERVER_PORT, serverPort); + } + if (shouldSetResourceName()) { + span.setResourceName( + OtelHttpSemantics.spanNameMethod(method), + ResourceNamePriorities.HTTP_FRAMEWORK_ROUTE); + } + } else { + span.setTag( + Tags.HTTP_URL, + URIUtils.lazyValidURL( + url.getScheme(), url.getHost(), url.getPort(), url.getPath())); + if (Config.get().isHttpClientTagQueryString()) { + span.setTag(DDTags.HTTP_QUERY, url.getQuery()); + span.setTag(DDTags.HTTP_FRAGMENT, url.getFragment()); + } + if (shouldSetResourceName()) { + HTTP_RESOURCE_DECORATOR.withClientPath(span, method, url.getPath()); + } } // SSRF exploit prevention check onHttpClientRequest(span, url.toString()); @@ -149,11 +180,18 @@ public AgentSpan onRequest(final AgentSpan span, final REQUEST request) { public AgentSpan onResponse(final AgentSpan span, final RESPONSE response) { if (response != null) { final int status = status(response); + final boolean otelSemantics = Config.get().isTraceOtelSemanticsEnabled(); if (status > UNSET_STATUS) { + // Status stays in the dedicated field; the serializer renames the key under OTel semantics. span.setHttpStatusCode(status); - if (CLIENT_ERROR_STATUSES.get(status)) { + // OTel treats client 4xx and 5xx as errors; the Datadog default only marks the configured + // client error range (4xx). Keep error flag and error.type consistent under OTel. + if (CLIENT_ERROR_STATUSES.get(status) || (otelSemantics && status >= 400)) { span.setError(true); } + if (otelSemantics && status >= 400) { + OtelHttpSemantics.setErrorType(span, status); + } } if (CLIENT_TAG_HEADERS) { diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java index 267d0149c3c..f5c360d7bb3 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java @@ -243,6 +243,7 @@ public AgentSpan onRequest( final REQUEST request, final Context parentContext) { Config config = Config.get(); + final boolean otelSemantics = config.isTraceOtelSemanticsEnabled(); if (APPSEC_ACTIVE) { RequestContext requestContext = span.getRequestContext(); @@ -308,13 +309,17 @@ public AgentSpan onRequest( } String userAgent = extracted.getUserAgent(); if (userAgent != null) { - span.setTag(Tags.HTTP_USER_AGENT, userAgent); + span.setTag(otelSemantics ? Tags.USER_AGENT_ORIGINAL : Tags.HTTP_USER_AGENT, userAgent); } } if (request != null) { String method = method(request); - span.setTag(Tags.HTTP_METHOD, method); + if (otelSemantics) { + OtelHttpSemantics.setRequestMethod(span, method); + } else { + span.setTag(Tags.HTTP_METHOD, method); + } // Copy of HttpClientDecorator url handling try { @@ -324,30 +329,71 @@ public AgentSpan onRequest( boolean encoded = supportsRaw && config.isHttpServerRawResource(); boolean valid = url.isValid(); String path = encoded ? url.rawPath() : url.path(); - if (valid) { - span.setTag( - Tags.HTTP_URL, URIUtils.lazyValidURL(url.scheme(), url.host(), url.port(), path)); - } else if (supportsRaw) { - span.setTag(Tags.HTTP_URL, URIUtils.lazyInvalidUrl(url.raw())); - } - if (extracted != null && extracted.getXForwardedHost() != null) { - span.setTag(Tags.HTTP_HOSTNAME, extracted.getXForwardedHost()); - } else if (url.host() != null) { - span.setTag(Tags.HTTP_HOSTNAME, url.host()); + if (otelSemantics) { + // OTel server: url.path/url.scheme + server.address/port instead of the DD http.url + // tag. + if (valid) { + if (url.scheme() != null) { + span.setTag(Tags.URL_SCHEME, url.scheme()); + } + span.setTag(Tags.URL_PATH, path); + } else if (supportsRaw) { + span.setTag(Tags.URL_PATH, url.raw()); + } + String serverAddress = + extracted != null && extracted.getXForwardedHost() != null + ? extracted.getXForwardedHost() + : url.host(); + if (serverAddress != null) { + span.setTag(Tags.SERVER_ADDRESS, serverAddress); + } + if (url.port() > 0) { + span.setTag(Tags.SERVER_PORT, url.port()); + } + } else { + if (valid) { + span.setTag( + Tags.HTTP_URL, URIUtils.lazyValidURL(url.scheme(), url.host(), url.port(), path)); + } else if (supportsRaw) { + span.setTag(Tags.HTTP_URL, URIUtils.lazyInvalidUrl(url.raw())); + } + if (extracted != null && extracted.getXForwardedHost() != null) { + span.setTag(Tags.HTTP_HOSTNAME, extracted.getXForwardedHost()); + } else if (url.host() != null) { + span.setTag(Tags.HTTP_HOSTNAME, url.host()); + } } + // url.query stays gated on the existing query-string toggle even under OTel semantics: + // that toggle is a privacy/PII control, so a user who disabled query capture should not + // start leaking query strings just because they switched conventions. if (valid && config.isHttpServerTagQueryString()) { String query = supportsRaw && config.isHttpServerRawQueryString() ? url.rawQuery() : url.query(); - span.setTag(DDTags.HTTP_QUERY, query); - span.setTag(DDTags.HTTP_FRAGMENT, url.fragment()); + if (otelSemantics) { + // OTel: url.query is omitted when empty + if (query != null && !query.isEmpty()) { + span.setTag(Tags.URL_QUERY, query); + } + } else { + span.setTag(DDTags.HTTP_QUERY, query); + span.setTag(DDTags.HTTP_FRAGMENT, url.fragment()); + } } Flow flow = callIGCallbackURI(span, url, method); if (flow.getAction() instanceof RequestBlockingAction) { span.setRequestBlockingAction((RequestBlockingAction) flow.getAction()); } if (valid && SHOULD_SET_URL_RESOURCE_NAME) { - HTTP_RESOURCE_DECORATOR.withServerPath(span, method, path, encoded); + if (otelSemantics) { + // OTel span name is "{method}" (HTTP for unknown methods); route-based naming appends + // the route when known. The spec forbids using the URL path as the target. + span.setResourceName( + OtelHttpSemantics.spanNameMethod(method), + ResourceNamePriorities.HTTP_PATH_NORMALIZER); + } else { + HTTP_RESOURCE_DECORATOR.withServerPath(span, method, path, encoded); + } } } else if (SHOULD_SET_URL_RESOURCE_NAME) { span.setResourceName(DEFAULT_RESOURCE_NAME); @@ -383,7 +429,9 @@ public AgentSpan onRequest( if (inferredAddress != null) { inferredAddressStr = inferredAddress.getHostAddress(); if (shouldTagIps) { - span.setTag(Tags.HTTP_CLIENT_IP, inferredAddressStr); + // OTel maps the resolved client IP (http.client_ip) to client.address + span.setTag( + otelSemantics ? Tags.CLIENT_ADDRESS : Tags.HTTP_CLIENT_IP, inferredAddressStr); } } } else if (shouldTagIps && span.getLocalRootSpan() != span) { @@ -393,9 +441,10 @@ public AgentSpan onRequest( // likely already happened on the top span, so we don't need to do the resolution // again. Instead, copy from the top span, should it exist AgentSpan localRootSpan = span.getLocalRootSpan(); - Object clientIp = localRootSpan.getTag(Tags.HTTP_CLIENT_IP); + String clientIpTag = otelSemantics ? Tags.CLIENT_ADDRESS : Tags.HTTP_CLIENT_IP; + Object clientIp = localRootSpan.getTag(clientIpTag); if (clientIp != null) { - span.setTag(Tags.HTTP_CLIENT_IP, clientIp); + span.setTag(clientIpTag, clientIp); } } @@ -406,7 +455,8 @@ public AgentSpan onRequest( span.setTag(Tags.PEER_HOST_IPV4, peerIp); } if (shouldTagIps) { - span.setTag(Tags.NETWORK_CLIENT_IP, peerIp); + // OTel maps the socket peer IP (network.client.ip) to network.peer.address + span.setTag(otelSemantics ? Tags.NETWORK_PEER_ADDRESS : Tags.NETWORK_CLIENT_IP, peerIp); } } if (shouldStashIps && (peerIp != null || inferredAddressStr != null)) { @@ -450,7 +500,9 @@ protected BlockResponseFunction createBlockResponseFunction( } public AgentSpan onResponseStatus(final AgentSpan span, final int status) { + final boolean otelSemantics = Config.get().isTraceOtelSemanticsEnabled(); if (status > UNSET_STATUS) { + // Status stays in the dedicated field; the serializer renames the key under OTel semantics. span.setHttpStatusCode(status); // explicitly set here because some other decorators might already set an error without // looking at the status code @@ -460,9 +512,15 @@ public AgentSpan onResponseStatus(final AgentSpan span, final int status) { if (!BlockingException.class.getName().equals(span.getTag("error.type"))) { span.setError(SERVER_ERROR_STATUSES.get(status), ErrorPriorities.HTTP_SERVER_DECORATOR); } + // OTel error.type = status for server error responses (5xx; 4xx isn't an error), unless set. + if (otelSemantics && SERVER_ERROR_STATUSES.get(status)) { + OtelHttpSemantics.setErrorType(span, status); + } } - if (SHOULD_SET_404_RESOURCE_NAME && status == 404) { + // Under OTel semantics the span name must not use the path/404 as the target, so leave the + // "{method}" name in place rather than overriding it with the Datadog "404" resource name. + if (!otelSemantics && SHOULD_SET_404_RESOURCE_NAME && status == 404) { span.setResourceName(NOT_FOUND_RESOURCE_NAME, ResourceNamePriorities.HTTP_404); } return span; diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/OtelHttpSemantics.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/OtelHttpSemantics.java new file mode 100644 index 00000000000..f1318c974fd --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/OtelHttpSemantics.java @@ -0,0 +1,114 @@ +package datadog.trace.bootstrap.instrumentation.decorator; + +import datadog.trace.api.DDTags; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Helpers for emitting OpenTelemetry HTTP semantic-convention attributes, shared by the HTTP server + * and client decorators. See https://opentelemetry.io/docs/specs/semconv/http/http-spans/ + */ +final class OtelHttpSemantics { + private OtelHttpSemantics() {} + + static final String OTHER_METHOD = "_OTHER"; + + // "Known" HTTP methods per the spec: RFC 9110 + PATCH (RFC 5789) + QUERY (httpbis draft). + // Method names are case-sensitive and must match exactly. + private static final Set KNOWN_METHODS = + Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + "CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY", + "TRACE"))); + + /** + * Sets {@code http.request.method}, normalizing methods the instrumentation doesn't know to + * {@code _OTHER} and recording the original value in {@code http.request.method_original}. + */ + static void setRequestMethod(final AgentSpan span, final String method) { + if (method != null && !KNOWN_METHODS.contains(method)) { + span.setTag(Tags.HTTP_REQUEST_METHOD, OTHER_METHOD); + span.setTag(Tags.HTTP_REQUEST_METHOD_ORIGINAL, method); + } else { + span.setTag(Tags.HTTP_REQUEST_METHOD, method); + } + } + + /** + * Returns the method component to use in the span name. Per the spec, when the request method is + * unknown ({@code http.request.method} is {@code _OTHER}), the span name uses the literal {@code + * HTTP} rather than the raw verb. + */ + static String spanNameMethod(final String method) { + return method != null && KNOWN_METHODS.contains(method) ? method : "HTTP"; + } + + /** + * Returns the value for {@code url.full} with any embedded credentials redacted: the spec + * mandates that {@code url.full} MUST NOT contain credentials (e.g. {@code + * https://user:pass@host} becomes {@code https://REDACTED:REDACTED@host}). + */ + static String redactedUrl(final URI url) { + final String full = url.toString(); + final String userInfo = url.getRawUserInfo(); + if (userInfo == null || userInfo.isEmpty()) { + return full; + } + // Redact only the components that are actually present (user vs user:password) so we don't + // imply a password that wasn't there. + final String redacted = userInfo.indexOf(':') >= 0 ? "REDACTED:REDACTED" : "REDACTED"; + return full.replace(userInfo + "@", redacted + "@"); + } + + /** + * Strips the query string and fragment from a URL, used to honor the client query-string opt-out + * ({@code DD_TRACE_HTTP_CLIENT_TAG_QUERY_STRING=false}) for {@code url.full}. + */ + static String withoutQueryAndFragment(final String url) { + int cut = url.length(); + final int query = url.indexOf('?'); + if (query >= 0) { + cut = query; + } + final int fragment = url.indexOf('#'); + if (fragment >= 0 && fragment < cut) { + cut = fragment; + } + return url.substring(0, cut); + } + + /** + * Sets {@code error.type} to the HTTP status code (as a string) when the response indicates an + * error, unless an error type (e.g. from a thrown exception) has already been recorded — the spec + * prefers the exception type over the status code. + */ + static void setErrorType(final AgentSpan span, final int status) { + if (span.getTag(DDTags.ERROR_TYPE) == null) { + span.setTag(DDTags.ERROR_TYPE, Integer.toString(status)); + } + } + + /** + * Resolves {@code server.port} for a client span, falling back to the scheme default (80/443) + * when the URL omits an explicit port, since the spec marks it required for client spans. + */ + static int serverPort(final URI url) { + final int port = url.getPort(); + if (port > 0) { + return port; + } + if ("https".equals(url.getScheme())) { + return 443; + } + if ("http".equals(url.getScheme())) { + return 80; + } + return -1; + } +} diff --git a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/OtelHttpSemanticsTest.java b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/OtelHttpSemanticsTest.java new file mode 100644 index 00000000000..1ea0fb8ccda --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/OtelHttpSemanticsTest.java @@ -0,0 +1,109 @@ +package datadog.trace.bootstrap.instrumentation.decorator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.trace.api.DDTags; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import java.net.URI; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** Unit tests for the OpenTelemetry HTTP semantic-convention helpers. */ +class OtelHttpSemanticsTest { + + @ParameterizedTest + @ValueSource(strings = {"GET", "POST", "DELETE", "PATCH", "QUERY", "CONNECT"}) + void setRequestMethodKeepsKnownMethods(String method) { + AgentSpan span = mock(AgentSpan.class); + OtelHttpSemantics.setRequestMethod(span, method); + verify(span).setTag(Tags.HTTP_REQUEST_METHOD, method); + verify(span, never()).setTag(eq(Tags.HTTP_REQUEST_METHOD_ORIGINAL), anyString()); + } + + @ParameterizedTest + @ValueSource(strings = {"PROPFIND", "get", "Get", "BOGUS"}) + void setRequestMethodNormalizesUnknownMethodsToOther(String method) { + AgentSpan span = mock(AgentSpan.class); + OtelHttpSemantics.setRequestMethod(span, method); + verify(span).setTag(Tags.HTTP_REQUEST_METHOD, OtelHttpSemantics.OTHER_METHOD); + verify(span).setTag(Tags.HTTP_REQUEST_METHOD_ORIGINAL, method); + verify(span, never()).setTag(Tags.HTTP_REQUEST_METHOD, method); + } + + @ParameterizedTest + @CsvSource({"GET,GET", "POST,POST", "QUERY,QUERY"}) + void spanNameMethodKeepsKnownMethods(String method, String expected) { + assertEquals(expected, OtelHttpSemantics.spanNameMethod(method)); + } + + @ParameterizedTest + @ValueSource(strings = {"PROPFIND", "get", "BOGUS"}) + void spanNameMethodUsesHttpForUnknownMethods(String method) { + assertEquals("HTTP", OtelHttpSemantics.spanNameMethod(method)); + } + + @Test + void spanNameMethodUsesHttpForNullMethod() { + assertEquals("HTTP", OtelHttpSemantics.spanNameMethod(null)); + } + + @ParameterizedTest + @CsvSource({ + // raw url, expected url.full (credentials redacted, structure preserved) + "http://host:8080/p, http://host:8080/p", + "http://user:pass@host/p, http://REDACTED:REDACTED@host/p", + "http://user@host/p, http://REDACTED@host/p", + "https://u:p@host:443/a?b=c, https://REDACTED:REDACTED@host:443/a?b=c", + }) + void redactedUrlRedactsOnlyPresentCredentials(String raw, String expected) { + assertEquals(expected, OtelHttpSemantics.redactedUrl(URI.create(raw))); + } + + @ParameterizedTest + @CsvSource({ + "http://host/p?token=secret, http://host/p", + "http://host/p#frag, http://host/p", + "http://host/p?q=1#frag, http://host/p", + "http://host/p, http://host/p", + }) + void withoutQueryAndFragmentStripsQueryAndFragment(String url, String expected) { + assertEquals(expected, OtelHttpSemantics.withoutQueryAndFragment(url)); + } + + @ParameterizedTest + @CsvSource({ + "http://host:8080/, 8080", + "http://host/, 80", + "https://host/, 443", + "ftp://host/, -1", + }) + void serverPortFallsBackToSchemeDefault(String url, int expectedPort) { + assertEquals(expectedPort, OtelHttpSemantics.serverPort(URI.create(url))); + } + + @Test + void setErrorTypeSetsStatusWhenAbsent() { + AgentSpan span = mock(AgentSpan.class); + when(span.getTag(DDTags.ERROR_TYPE)).thenReturn(null); + OtelHttpSemantics.setErrorType(span, 500); + verify(span).setTag(DDTags.ERROR_TYPE, "500"); + } + + @Test + void setErrorTypeDoesNotOverrideExistingErrorType() { + AgentSpan span = mock(AgentSpan.class); + when(span.getTag(DDTags.ERROR_TYPE)).thenReturn("java.net.UnknownHostException"); + OtelHttpSemantics.setErrorType(span, 500); + verify(span, never()).setTag(eq(DDTags.ERROR_TYPE), any(String.class)); + } +} diff --git a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java index cb6f554f0cb..16e2e75fd5b 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java @@ -64,6 +64,7 @@ public final class ConfigDefaults { static final boolean DEFAULT_TRACE_RESOLVER_ENABLED = true; static final boolean DEFAULT_HTTP_SERVER_TAG_QUERY_STRING = true; static final boolean DEFAULT_HTTP_SERVER_ROUTE_BASED_NAMING = true; + static final boolean DEFAULT_TRACE_OTEL_SEMANTICS_ENABLED = false; static final boolean DEFAULT_HTTP_CLIENT_TAG_QUERY_STRING = true; static final boolean DEFAULT_HTTP_CLIENT_SPLIT_BY_DOMAIN = false; static final boolean DEFAULT_DB_CLIENT_HOST_SPLIT_BY_INSTANCE = false; diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/TraceInstrumentationConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/TraceInstrumentationConfig.java index 6d44cf8b249..8579d0bc487 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/config/TraceInstrumentationConfig.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/TraceInstrumentationConfig.java @@ -52,6 +52,14 @@ public final class TraceInstrumentationConfig { "http.server.decoded.resource.preserve-spaces"; public static final String HTTP_SERVER_ROUTE_BASED_NAMING = "http.server.route-based-naming"; + /** + * When enabled, HTTP server and client spans emit ONLY OpenTelemetry HTTP semantic-convention + * attributes (e.g. {@code http.request.method}, {@code url.path}, {@code server.address}) instead + * of the Datadog attributes ({@code http.method}, {@code http.url}, ...). Opt-in, disabled by + * default. See https://opentelemetry.io/docs/specs/semconv/http/http-spans/ + */ + public static final String TRACE_OTEL_SEMANTICS_ENABLED = "trace.otel.semantics.enabled"; + // Use TRACE_HTTP_CLIENT_TAG_QUERY_STRING instead @Deprecated public static final String HTTP_CLIENT_TAG_QUERY_STRING = "http.client.tag.query-string"; diff --git a/dd-trace-core/src/main/java/datadog/trace/common/writer/FileBasedPayloadDispatcher.java b/dd-trace-core/src/main/java/datadog/trace/common/writer/FileBasedPayloadDispatcher.java index 810952292b8..4897fd18bac 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/writer/FileBasedPayloadDispatcher.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/writer/FileBasedPayloadDispatcher.java @@ -389,7 +389,7 @@ public void accept(Metadata metadata) { } } if (metadata.getHttpStatusCode() != null) { - w.name(Tags.HTTP_STATUS) + w.name(metadata.getHttpStatusKey().toString()) .value(truncate(metadata.getHttpStatusCode().toString(), MAX_META_STRING_VALUE_LENGTH)); } for (Map.Entry entry : tags.entrySet()) { diff --git a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java index a44ecc6aab1..40d2b397396 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java @@ -172,7 +172,7 @@ public void accept(Metadata metadata) { writable.writeUTF8(THREAD_NAME); writable.writeUTF8(metadata.getThreadName()); if (null != metadata.getHttpStatusCode()) { - writable.writeUTF8(HTTP_STATUS); + writable.writeUTF8(metadata.getHttpStatusKey()); writable.writeUTF8(metadata.getHttpStatusCode()); } if (null != metadata.getOrigin()) { diff --git a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_5.java b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_5.java index a993fdb3c26..a4e7e51e560 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_5.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_5.java @@ -259,7 +259,7 @@ public void accept(Metadata metadata) { writeDictionaryEncoded(writable, THREAD_NAME); writeDictionaryEncoded(writable, metadata.getThreadName()); if (null != metadata.getHttpStatusCode()) { - writeDictionaryEncoded(writable, HTTP_STATUS); + writeDictionaryEncoded(writable, metadata.getHttpStatusKey()); writeDictionaryEncoded(writable, metadata.getHttpStatusCode()); } if (null != metadata.getOrigin()) { diff --git a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV1.java b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV1.java index 4cb41c597f4..1ec92564866 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV1.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV1.java @@ -59,7 +59,6 @@ public final class TraceMapperV1 implements TraceMapper { // Decision maker tag key private static final String KEY_DECISION_MAKER = "_dd.p.dm"; - private static final String HTTP_STATUS = "http.status_code"; private final int bufferSize; private final StringTable stringTable; @@ -360,7 +359,8 @@ private void encodeSpanAttributes( Map baggage = meta.getBaggage(); String httpStatusCode = meta.getHttpStatusCode() == null ? null : meta.getHttpStatusCode().toString(); - boolean writeHttpStatus = httpStatusCode != null && tags.getString(HTTP_STATUS) == null; + String httpStatusKey = meta.getHttpStatusKey().toString(); + boolean writeHttpStatus = httpStatusCode != null && tags.getString(httpStatusKey) == null; boolean writeTopLevel = meta.topLevel(); int tagCount = 0; for (TagMap.EntryReader entry : tags) { @@ -393,7 +393,7 @@ private void encodeSpanAttributes( writeFlattenedTagAttribute(writable, entry); } if (writeHttpStatus) { - writeAttribute(writable, HTTP_STATUS, httpStatusCode); + writeAttribute(writable, httpStatusKey, httpStatusCode); } if (writeTopLevel) { writeAttribute(writable, InstrumentationTags.DD_TOP_LEVEL.toString(), 1); diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index e7038db5dbe..d8867b6560a 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -1244,6 +1244,9 @@ void processTagsAndBaggage( measured, topLevel, httpStatusCode == 0 ? null : HTTP_STATUSES.get(httpStatusCode), + Config.get().isTraceOtelSemanticsEnabled() + ? Metadata.HTTP_RESPONSE_STATUS_CODE_KEY + : Metadata.HTTP_STATUS_KEY, // Get origin from rootSpan.context getOrigin(), longRunningVersion, diff --git a/dd-trace-core/src/main/java/datadog/trace/core/Metadata.java b/dd-trace-core/src/main/java/datadog/trace/core/Metadata.java index b7f9a6b2cc2..d016c9795c5 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/Metadata.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/Metadata.java @@ -5,14 +5,23 @@ import datadog.trace.api.TagMap; import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink; +import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import java.util.List; import java.util.Map; public final class Metadata { + /** Serialized key for the HTTP status code in Datadog convention. */ + public static final UTF8BytesString HTTP_STATUS_KEY = UTF8BytesString.create(Tags.HTTP_STATUS); + + /** Serialized key for the HTTP status code in OpenTelemetry convention. */ + public static final UTF8BytesString HTTP_RESPONSE_STATUS_CODE_KEY = + UTF8BytesString.create(Tags.HTTP_RESPONSE_STATUS_CODE); + private final long threadId; private final UTF8BytesString threadName; private final UTF8BytesString httpStatusCode; + private final UTF8BytesString httpStatusKey; private final TagMap tags; private final Map baggage; @@ -37,9 +46,40 @@ public Metadata( int longRunningVersion, UTF8BytesString processTags, List spanLinks) { + this( + threadId, + threadName, + tags, + baggage, + samplingPriority, + measured, + topLevel, + httpStatusCode, + HTTP_STATUS_KEY, + origin, + longRunningVersion, + processTags, + spanLinks); + } + + public Metadata( + long threadId, + UTF8BytesString threadName, + TagMap tags, + Map baggage, + int samplingPriority, + boolean measured, + boolean topLevel, + UTF8BytesString httpStatusCode, + UTF8BytesString httpStatusKey, + CharSequence origin, + int longRunningVersion, + UTF8BytesString processTags, + List spanLinks) { this.threadId = threadId; this.threadName = threadName; this.httpStatusCode = httpStatusCode; + this.httpStatusKey = httpStatusKey; this.tags = tags; this.baggage = baggage; this.samplingPriority = samplingPriority; @@ -55,6 +95,11 @@ public UTF8BytesString getHttpStatusCode() { return httpStatusCode; } + /** The serialized attribute key to use for the HTTP status code (DD vs OTel convention). */ + public UTF8BytesString getHttpStatusKey() { + return httpStatusKey; + } + public CharSequence getOrigin() { return origin; } 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 5f317b40730..b4345838f2f 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 @@ -12,7 +12,6 @@ import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.DOUBLE_ATTRIBUTE; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.LONG_ATTRIBUTE; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.STRING_ATTRIBUTE; -import static datadog.trace.common.writer.RemoteMapper.HTTP_STATUS; import static datadog.trace.common.writer.ddagent.TraceMapper.ORIGIN_KEY; import static datadog.trace.common.writer.ddagent.TraceMapper.PROCESS_TAGS_KEY; import static datadog.trace.common.writer.ddagent.TraceMapper.SAMPLING_PRIORITY_KEY; @@ -303,7 +302,7 @@ public void accept(Metadata metadata) { writeSpanTag(buf, THREAD_ID, metadata.getThreadId()); writeSpanTag(buf, THREAD_NAME, metadata.getThreadName()); if (metadata.getHttpStatusCode() != null) { - writeSpanTag(buf, HTTP_STATUS, metadata.getHttpStatusCode()); + writeSpanTag(buf, metadata.getHttpStatusKey(), metadata.getHttpStatusCode()); } if (metadata.getOrigin() != null) { writeSpanTag(buf, ORIGIN_KEY, metadata.getOrigin()); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTest.java index 4185c9acdab..5ea8d7394db 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTest.java @@ -20,6 +20,7 @@ import static datadog.trace.core.DDSpanContext.SPAN_SAMPLING_MAX_PER_SECOND_TAG; import static datadog.trace.core.DDSpanContext.SPAN_SAMPLING_MECHANISM_TAG; import static datadog.trace.core.DDSpanContext.SPAN_SAMPLING_RULE_RATE_TAG; +import static datadog.trace.junit.utils.config.WithConfigExtension.injectSysConfig; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -70,6 +71,26 @@ void setup() { .build(); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void httpStatusKeyFollowsOtelSemanticsFlag(boolean otelEnabled) { + injectSysConfig("dd.trace.otel.semantics.enabled", String.valueOf(otelEnabled)); + AgentSpan span = tracer.buildSpan("datadog", "fakeOperation").start(); + span.setHttpStatusCode(200); + + Metadata[] captured = new Metadata[1]; + ((DDSpan) span).processTagsAndBaggage(md -> captured[0] = md); + + // The status value always lives in the dedicated field (so trace stats keep their status + // dimension); only the serialized attribute key changes under OTel semantics. + assertEquals("200", captured[0].getHttpStatusCode().toString()); + assertEquals( + otelEnabled ? "http.response.status_code" : "http.status_code", + captured[0].getHttpStatusKey().toString()); + + span.finish(); + } + @ParameterizedTest @ValueSource(strings = {DDTags.SERVICE_NAME, DDTags.RESOURCE_NAME, DDTags.SPAN_TYPE, "some.tag"}) void nullValuesForTagsDeleteExistingTags(String name) throws Exception { diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 6418bab301e..4ee57a72916 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -180,6 +180,7 @@ import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_LONG_RUNNING_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_LONG_RUNNING_FLUSH_INTERVAL; import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_LONG_RUNNING_INITIAL_FLUSH_INTERVAL; +import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_OTEL_SEMANTICS_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_POST_PROCESSING_TIMEOUT; import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_PROPAGATION_BEHAVIOR_EXTRACT; import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_PROPAGATION_EXTRACT_FIRST; @@ -619,6 +620,7 @@ import static datadog.trace.api.config.TraceInstrumentationConfig.SQS_BODY_PROPAGATION_ENABLED; import static datadog.trace.api.config.TraceInstrumentationConfig.TRACE_128_BIT_TRACEID_LOGGING_ENABLED; import static datadog.trace.api.config.TraceInstrumentationConfig.TRACE_HTTP_CLIENT_TAG_QUERY_STRING; +import static datadog.trace.api.config.TraceInstrumentationConfig.TRACE_OTEL_SEMANTICS_ENABLED; import static datadog.trace.api.config.TraceInstrumentationConfig.TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT; import static datadog.trace.api.config.TraceInstrumentationConfig.TRACE_RESOURCE_RENAMING_ENABLED; import static datadog.trace.api.config.TraceInstrumentationConfig.TRACE_WEBSOCKET_MESSAGES_INHERIT_SAMPLING; @@ -920,6 +922,7 @@ public static String getHostName() { private final boolean httpServerRawResource; private final boolean httpServerDecodedResourcePreserveSpaces; private final boolean httpServerRouteBasedNaming; + private final boolean traceOtelSemanticsEnabled; private final Map httpServerPathResourceNameMapping; private final Map httpClientPathResourceNameMapping; private final boolean httpResourceRemoveTrailingSlash; @@ -1764,6 +1767,10 @@ private Config(final ConfigProvider configProvider, final InstrumenterConfig ins configProvider.getBoolean( HTTP_SERVER_ROUTE_BASED_NAMING, DEFAULT_HTTP_SERVER_ROUTE_BASED_NAMING); + traceOtelSemanticsEnabled = + configProvider.getBoolean( + TRACE_OTEL_SEMANTICS_ENABLED, DEFAULT_TRACE_OTEL_SEMANTICS_ENABLED); + httpClientTagQueryString = configProvider.getBoolean( TRACE_HTTP_CLIENT_TAG_QUERY_STRING, @@ -3598,6 +3605,10 @@ public boolean isHttpServerRouteBasedNaming() { return httpServerRouteBasedNaming; } + public boolean isTraceOtelSemanticsEnabled() { + return traceOtelSemanticsEnabled; + } + public boolean isHttpClientTagQueryString() { return httpClientTagQueryString; } @@ -6273,6 +6284,8 @@ public String toString() { + httpServerRawResource + ", httpServerRouteBasedNaming=" + httpServerRouteBasedNaming + + ", traceOtelSemanticsEnabled=" + + traceOtelSemanticsEnabled + ", httpServerPathResourceNameMapping=" + httpServerPathResourceNameMapping + ", httpClientPathResourceNameMapping=" 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 14496e8b243..c3b26498678 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 @@ -20,6 +20,21 @@ public class Tags { public static final String HTTP_STATUS = "http.status_code"; public static final String HTTP_METHOD = "http.method"; public static final String HTTP_ENDPOINT = "http.endpoint"; + + // OpenTelemetry HTTP semantic conventions (emitted when DD_TRACE_OTEL_SEMANTICS_ENABLED=true). + // See https://opentelemetry.io/docs/specs/semconv/http/http-spans/ + public static final String HTTP_REQUEST_METHOD = "http.request.method"; + public static final String HTTP_REQUEST_METHOD_ORIGINAL = "http.request.method_original"; + public static final String HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"; + public static final String URL_FULL = "url.full"; + public static final String URL_PATH = "url.path"; + public static final String URL_SCHEME = "url.scheme"; + public static final String URL_QUERY = "url.query"; + public static final String SERVER_ADDRESS = "server.address"; + public static final String SERVER_PORT = "server.port"; + public static final String CLIENT_ADDRESS = "client.address"; + public static final String NETWORK_PEER_ADDRESS = "network.peer.address"; + public static final String USER_AGENT_ORIGINAL = "user_agent.original"; public static final String HTTP_FORWARDED = "http.forwarded"; public static final String HTTP_FORWARDED_PROTO = "http.forwarded.proto"; public static final String HTTP_FORWARDED_HOST = "http.forwarded.host"; diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index e2d171c3c3f..43afee962b3 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -8657,6 +8657,14 @@ "aliases": [] } ], + "DD_TRACE_OTEL_SEMANTICS_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "false", + "aliases": [] + } + ], "DD_TRACE_PARTIAL_FLUSH_ENABLED": [ { "version": "B",