diff --git a/dd-smoke-tests/springboot/src/test/groovy/datadog/smoketest/HttpEndpointTaggingSmokeTest.groovy b/dd-smoke-tests/springboot/src/test/groovy/datadog/smoketest/HttpEndpointTaggingSmokeTest.groovy new file mode 100644 index 00000000000..0654293e277 --- /dev/null +++ b/dd-smoke-tests/springboot/src/test/groovy/datadog/smoketest/HttpEndpointTaggingSmokeTest.groovy @@ -0,0 +1,252 @@ +package datadog.smoketest + +import okhttp3.Request +import java.util.concurrent.atomic.AtomicInteger +import java.util.regex.Pattern + +class HttpEndpointTaggingSmokeTest extends AbstractServerSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + String springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") + + List command = new ArrayList<>() + command.add(javaPath()) + command.addAll(defaultJavaProperties) + command.addAll((String[]) [ + "-Ddd.writer.type=MultiWriter:TraceStructureWriter:${output.getAbsolutePath()}:includeResource,DDAgentWriter", + "-Ddd.trace.resource.renaming.enabled=true", + "-jar", + springBootShadowJar, + "--server.port=${httpPort}" + ]) + ProcessBuilder processBuilder = new ProcessBuilder(command) + processBuilder.directory(new File(buildDirectory)) + } + + @Override + File createTemporaryFile() { + return File.createTempFile("http-endpoint-tagging-trace", "out") + } + + @Override + protected Set expectedTraces() { + return [ + Pattern.quote("[servlet.request:GET /greeting[spring.handler:IastWebController.greeting]]") + ] + } + + @Override + protected Set assertTraceCounts(Set expected, Map traceCounts) { + List remaining = expected.collect { Pattern.compile(it) }.toList() + for (def i = remaining.size() - 1; i >= 0; i--) { + for (Map.Entry entry : traceCounts.entrySet()) { + if (entry.getValue() > 0 && remaining.get(i).matcher(entry.getKey()).matches()) { + remaining.remove(i) + break + } + } + } + return remaining.collect { it.pattern() }.toSet() + } + + def "test basic HTTP endpoint tagging"() { + setup: + String url = "http://localhost:${httpPort}/greeting" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + def responseBodyStr = response.body().string() + responseBodyStr != null + responseBodyStr.contains("Sup Dawg") + response.code() == 200 + waitForTraceCount(1) + } + + def "test URL parameterization for numeric IDs"() { + setup: + String url = "http://localhost:${httpPort}/users/123" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + // May return 404 since endpoint doesn't exist, but span should still be created + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test URL parameterization for hex patterns"() { + setup: + String url = "http://localhost:${httpPort}/session/abc123def456" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + // May return 404 since endpoint doesn't exist, but span should still be created + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test int_id pattern parameterization"() { + setup: + String url = "http://localhost:${httpPort}/api/versions/12.34.56" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test hex_id pattern parameterization"() { + setup: + String url = "http://localhost:${httpPort}/api/tokens/550e8400-e29b-41d4-a716-446655440000" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test str pattern parameterization for long strings"() { + setup: + String url = "http://localhost:${httpPort}/files/very-long-filename-that-exceeds-twenty-characters.pdf" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test str pattern parameterization for special characters"() { + setup: + String url = "http://localhost:${httpPort}/search/query%20with%20spaces" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test mixed URL patterns with multiple segments"() { + setup: + String url = "http://localhost:${httpPort}/api/users/123/orders/abc456def/items/789" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test URL with query parameters"() { + setup: + String url = "http://localhost:${httpPort}/api/users/123?filter=active&limit=10" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test URL with trailing slash"() { + setup: + String url = "http://localhost:${httpPort}/api/users/456/" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test static paths are preserved"() { + setup: + String url = "http://localhost:${httpPort}/health" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test root path handling"() { + setup: + String url = "http://localhost:${httpPort}/" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test pattern precedence - int pattern wins over int_id"() { + setup: + String url = "http://localhost:${httpPort}/api/items/12345" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test pattern precedence - hex pattern wins over hex_id"() { + setup: + String url = "http://localhost:${httpPort}/api/hashes/abc123def" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } + + def "test edge case - short segments not parameterized"() { + setup: + String url = "http://localhost:${httpPort}/api/x/y" + def request = new Request.Builder().url(url).get().build() + + when: + def response = client.newCall(request).execute() + + then: + response.code() in [200, 404] + waitForTraceCount(1) + } +} 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 b42612f89bd..5cf076b6948 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 @@ -170,6 +170,12 @@ public final class ConfigDefaults { "datadog.trace.*:org.apache.commons.*:org.mockito.*"; static final boolean DEFAULT_CIVISIBILITY_GIT_UPLOAD_ENABLED = true; static final boolean DEFAULT_CIVISIBILITY_GIT_UNSHALLOW_ENABLED = true; + + // HTTP Endpoint Tagging feature flags + static final boolean DEFAULT_RESOURCE_RENAMING_ENABLED = + false; // Default enablement of resource renaming + static final boolean DEFAULT_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT = + false; // Manual disablement of resource renaming static final long DEFAULT_CIVISIBILITY_GIT_COMMAND_TIMEOUT_MILLIS = 30_000; static final long DEFAULT_CIVISIBILITY_BACKEND_API_TIMEOUT_MILLIS = 30_000; static final long DEFAULT_CIVISIBILITY_GIT_UPLOAD_TIMEOUT_MILLIS = 60_000; diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java index d710d58ff29..618894efbc1 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java @@ -68,6 +68,11 @@ public final class TracerConfig { "trace.http.resource.remove-trailing-slash"; public static final String TRACE_HTTP_SERVER_PATH_RESOURCE_NAME_MAPPING = "trace.http.server.path-resource-name-mapping"; + + // HTTP Endpoint Tagging feature flags + public static final String TRACE_RESOURCE_RENAMING_ENABLED = "trace.resource.renaming.enabled"; + public static final String TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT = + "trace.resource.renaming.always-simplified-endpoint"; public static final String TRACE_HTTP_CLIENT_PATH_RESOURCE_NAME_MAPPING = "trace.http.client.path-resource-name-mapping"; // Use TRACE_HTTP_SERVER_ERROR_STATUSES instead diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/ConflatingMetricsAggregator.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/ConflatingMetricsAggregator.java index 6fe1608b245..91d4da26c80 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/ConflatingMetricsAggregator.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/ConflatingMetricsAggregator.java @@ -3,6 +3,8 @@ import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V6_METRICS_ENDPOINT; import static datadog.trace.api.DDTags.BASE_SERVICE; import static datadog.trace.api.Functions.UTF8_ENCODE; +import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ENDPOINT; +import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_METHOD; import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND; import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_CLIENT; import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_CONSUMER; @@ -100,6 +102,7 @@ public final class ConflatingMetricsAggregator implements MetricsAggregator, Eve private final TimeUnit reportingIntervalTimeUnit; private final DDAgentFeaturesDiscovery features; private final HealthMetrics healthMetrics; + private final boolean resourceRenamingEnabled; private volatile AgentTaskScheduler.Scheduled cancellation; @@ -120,7 +123,8 @@ public ConflatingMetricsAggregator( false, DEFAULT_HEADERS), config.getTracerMetricsMaxAggregates(), - config.getTracerMetricsMaxPending()); + config.getTracerMetricsMaxPending(), + config.isResourceRenamingEnabled()); } ConflatingMetricsAggregator( @@ -140,7 +144,30 @@ public ConflatingMetricsAggregator( maxAggregates, queueSize, 10, - SECONDS); + SECONDS, + false); + } + + ConflatingMetricsAggregator( + WellKnownTags wellKnownTags, + Set ignoredResources, + DDAgentFeaturesDiscovery features, + HealthMetrics healthMetric, + Sink sink, + int maxAggregates, + int queueSize, + boolean resourceRenamingEnabled) { + this( + wellKnownTags, + ignoredResources, + features, + healthMetric, + sink, + maxAggregates, + queueSize, + 10, + SECONDS, + resourceRenamingEnabled); } ConflatingMetricsAggregator( @@ -152,7 +179,8 @@ public ConflatingMetricsAggregator( int maxAggregates, int queueSize, long reportingInterval, - TimeUnit timeUnit) { + TimeUnit timeUnit, + boolean resourceRenamingEnabled) { this( ignoredResources, features, @@ -162,7 +190,8 @@ public ConflatingMetricsAggregator( maxAggregates, queueSize, reportingInterval, - timeUnit); + timeUnit, + resourceRenamingEnabled); } ConflatingMetricsAggregator( @@ -174,7 +203,8 @@ public ConflatingMetricsAggregator( int maxAggregates, int queueSize, long reportingInterval, - TimeUnit timeUnit) { + TimeUnit timeUnit, + boolean resourceRenamingEnabled) { this.ignoredResources = ignoredResources; this.inbox = new MpscCompoundQueue<>(queueSize); this.batchPool = new SpmcArrayQueue<>(maxAggregates); @@ -183,6 +213,7 @@ public ConflatingMetricsAggregator( this.features = features; this.healthMetrics = healthMetric; this.sink = sink; + this.resourceRenamingEnabled = resourceRenamingEnabled; this.aggregator = new Aggregator( metricWriter, @@ -307,6 +338,9 @@ private boolean spanKindEligible(CoreSpan span) { private boolean publish(CoreSpan span, boolean isTopLevel) { final CharSequence spanKind = span.getTag(SPAN_KIND, ""); + // Only include HTTP tags in metric key when resource renaming is enabled + final CharSequence httpMethod = resourceRenamingEnabled ? span.getTag(HTTP_METHOD, "") : ""; + final CharSequence httpEndpoint = resourceRenamingEnabled ? span.getTag(HTTP_ENDPOINT, "") : ""; MetricKey newKey = new MetricKey( span.getResourceName(), @@ -318,7 +352,9 @@ private boolean publish(CoreSpan span, boolean isTopLevel) { span.getParentId() == 0, SPAN_KINDS.computeIfAbsent( spanKind, UTF8BytesString::create), // save repeated utf8 conversions - getPeerTags(span, spanKind.toString())); + getPeerTags(span, spanKind.toString()), + httpMethod, + httpEndpoint); boolean isNewKey = false; MetricKey key = keys.putIfAbsent(newKey, newKey); if (null == key) { diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricKey.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricKey.java index 73aca7d6daf..e5fb9bd1652 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricKey.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricKey.java @@ -18,6 +18,8 @@ public final class MetricKey { private final boolean isTraceRoot; private final UTF8BytesString spanKind; private final List peerTags; + private final UTF8BytesString httpMethod; + private final UTF8BytesString httpEndpoint; public MetricKey( CharSequence resource, @@ -28,7 +30,9 @@ public MetricKey( boolean synthetics, boolean isTraceRoot, CharSequence spanKind, - List peerTags) { + List peerTags, + CharSequence httpMethod, + CharSequence httpEndpoint) { this.resource = null == resource ? EMPTY : UTF8BytesString.create(resource); this.service = null == service ? EMPTY : UTF8BytesString.create(service); this.operationName = null == operationName ? EMPTY : UTF8BytesString.create(operationName); @@ -38,6 +42,8 @@ public MetricKey( this.isTraceRoot = isTraceRoot; this.spanKind = null == spanKind ? EMPTY : UTF8BytesString.create(spanKind); this.peerTags = peerTags == null ? Collections.emptyList() : peerTags; + this.httpMethod = null == httpMethod ? EMPTY : UTF8BytesString.create(httpMethod); + this.httpEndpoint = null == httpEndpoint ? EMPTY : UTF8BytesString.create(httpEndpoint); // Unrolled polynomial hashcode to avoid varargs allocation // and eliminate data dependency between iterations as in Arrays.hashCode. @@ -55,6 +61,8 @@ public MetricKey( + 29791 * this.operationName.hashCode() + 961 * this.type.hashCode() + 31 * httpStatusCode + + -1796951359 * this.httpMethod.hashCode() + + 129082719 * this.httpEndpoint.hashCode() + (this.synthetics ? 1 : 0); } @@ -94,6 +102,14 @@ public List getPeerTags() { return peerTags; } + public UTF8BytesString getHttpMethod() { + return httpMethod; + } + + public UTF8BytesString getHttpEndpoint() { + return httpEndpoint; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -110,7 +126,9 @@ public boolean equals(Object o) { && type.equals(metricKey.type) && isTraceRoot == metricKey.isTraceRoot && spanKind.equals(metricKey.spanKind) - && peerTags.equals(metricKey.peerTags); + && peerTags.equals(metricKey.peerTags) + && httpMethod.equals(metricKey.httpMethod) + && httpEndpoint.equals(metricKey.httpEndpoint); } return false; } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java index 05a1ec50eb8..35610b9674d 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java @@ -149,6 +149,7 @@ private void finishAndAddToTrace(final long durationNano) { if (DURATION_NANO_UPDATER.compareAndSet(this, 0, Math.max(1, durationNano))) { setLongRunningVersion(-this.longRunningVersion); this.metrics.onSpanFinished(); + TraceCollector.PublishState publishState = context.getTraceCollector().onPublish(this); log.debug("Finished span ({}): {}", publishState, this); } else { diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java new file mode 100644 index 00000000000..fb120130abb --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java @@ -0,0 +1,31 @@ +package datadog.trace.core.tagprocessor; + +import datadog.trace.api.Config; +import datadog.trace.api.TagMap; +import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink; +import datadog.trace.core.DDSpanContext; +import java.util.List; + +/** + * TagsPostProcessor that applies HTTP endpoint tagging logic to spans. This processor computes and + * sets the http.endpoint tag based on http.route and http.url tags when appropriate. + */ +public final class HttpEndpointPostProcessor extends TagsPostProcessor { + private final Config config; + + public HttpEndpointPostProcessor() { + this(Config.get()); + } + + // Visible for testing + HttpEndpointPostProcessor(Config config) { + this.config = config; + } + + @Override + public void processTags( + TagMap unsafeTags, DDSpanContext spanContext, List spanLinks) { + // Use direct map access for better performance + HttpEndpointTagging.setEndpointTag(unsafeTags, config); + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointTagging.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointTagging.java new file mode 100644 index 00000000000..db03f94283a --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointTagging.java @@ -0,0 +1,298 @@ +package datadog.trace.core.tagprocessor; + +import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ENDPOINT; +import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ROUTE; +import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_URL; + +import datadog.trace.api.Config; +import datadog.trace.core.CoreSpan; +import datadog.trace.core.DDSpanContext; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class for HTTP endpoint tagging logic. Handles route eligibility checks and URL path + * parameterization for spans. + * + *

This implementation ensures: 1. Only applies to HTTP service entry spans (server spans) 2. + * Limits cardinality through URL path parameterization 3. Uses http.route when available and + * eligible (90% accuracy constraint) 4. Provides failsafe endpoint computation from http.url + */ +public final class HttpEndpointTagging { + + private static final Logger log = LoggerFactory.getLogger(HttpEndpointTagging.class); + + private static final Pattern URL_PATTERN = + Pattern.compile("^(?[a-z]+://(?[^?/]+))?(?/[^?]*)(?(\\?).*)?$"); + + // Applied in order - first match wins + private static final Pattern PARAM_INT_PATTERN = Pattern.compile("[1-9][0-9]+"); + private static final Pattern PARAM_INT_ID_PATTERN = Pattern.compile("(?=.*[0-9].*)[0-9._-]{3,}"); + private static final Pattern PARAM_HEX_PATTERN = Pattern.compile("(?=.*[0-9].*)[A-Fa-f0-9]{6,}"); + private static final Pattern PARAM_HEX_ID_PATTERN = + Pattern.compile("(?=.*[0-9].*)[A-Fa-f0-9._-]{6,}"); + private static final Pattern PARAM_STR_PATTERN = Pattern.compile(".{20,}|.*[%&'()*+,:=@].*"); + private static final Pattern SLASH_PATTERN = Pattern.compile("/", Pattern.LITERAL); + + private static final int MAX_PATH_ELEMENTS = 8; + + private HttpEndpointTagging() { + // Utility class - no instantiation + } + + /** + * Determines if an HTTP route is eligible for use as endpoint tag. Routes must meet accuracy + * requirements (90% constraint) to be considered eligible. + * + * @param route the HTTP route to check + * @return true if route is eligible, false otherwise + */ + public static boolean isRouteEligible(String route) { + if (route == null) { + return false; + } + + route = route.trim(); + + if (route.isEmpty()) { + return false; + } + + // Route must start with / to be a valid path + if (!route.startsWith("/")) { + return false; + } + + // Reject overly generic routes that don't provide meaningful endpoint information + if ("/".equals(route) || "/*".equals(route) || "*".equals(route)) { + return false; + } + + // Route is eligible for endpoint tagging + return true; + } + + /** + * Parameterizes a URL path by replacing dynamic segments with {param:type} tokens. Splits path on + * '/', discards empty elements, keeps first 8 elements, and applies regex patterns in order. + * + * @param path the URL path to parameterize + * @return parameterized path with dynamic segments replaced by {param:type} tokens + */ + public static String parameterizeUrlPath(String path) { + if (path == null) { + return null; + } + + if (path.isEmpty() || "/".equals(path)) { + return path; + } + + int queryIndex = path.indexOf('?'); + if (queryIndex != -1) { + path = path.substring(0, queryIndex); + } + + int fragmentIndex = path.indexOf('#'); + if (fragmentIndex != -1) { + path = path.substring(0, fragmentIndex); + } + + String[] allSegments = SLASH_PATTERN.split(path); + List nonEmptySegments = new ArrayList<>(); + + for (String segment : allSegments) { + if (!segment.isEmpty()) { + nonEmptySegments.add(segment); + } + } + + List segments = + nonEmptySegments.size() > MAX_PATH_ELEMENTS + ? nonEmptySegments.subList(0, MAX_PATH_ELEMENTS) + : nonEmptySegments; + + StringBuilder result = new StringBuilder(); + for (String segment : segments) { + result.append("/"); + + // First match wins + if (PARAM_INT_PATTERN.matcher(segment).matches()) { + result.append("{param:int}"); + } else if (PARAM_INT_ID_PATTERN.matcher(segment).matches()) { + result.append("{param:int_id}"); + } else if (PARAM_HEX_PATTERN.matcher(segment).matches()) { + result.append("{param:hex}"); + } else if (PARAM_HEX_ID_PATTERN.matcher(segment).matches()) { + result.append("{param:hex_id}"); + } else if (PARAM_STR_PATTERN.matcher(segment).matches()) { + result.append("{param:str}"); + } else { + result.append(segment); + } + } + + String parameterized = result.toString(); + return parameterized.isEmpty() ? "/" : parameterized; + } + + /** + * Computes endpoint from HTTP URL using regex parsing. Returns '/' when URL is unavailable or + * invalid. + * + * @param url the HTTP URL to process + * @return parameterized endpoint path or '/' + */ + public static String computeEndpointFromUrl(String url) { + if (url == null) { + return "/"; + } + + url = url.trim(); + if (url.isEmpty()) { + return "/"; + } + + Matcher matcher = URL_PATTERN.matcher(url); + if (!matcher.matches()) { + log.debug("Failed to parse URL for endpoint computation: {}", url); + return "/"; + } + + String path = matcher.group("path"); + if (path == null || path.isEmpty()) { + return "/"; + } + + return parameterizeUrlPath(path); + } + + /** + * Sets the HTTP endpoint tag on a span if conditions are met. Only applies to HTTP service entry + * spans when: 1. http.route is missing, empty, or not eligible 2. http.url is available for + * endpoint computation + * + *

This method is designed for testing and backward compatibility. Production usage should + * integrate with feature flags and span kind checks. + * + * @param span The span to potentially tag + */ + public static void setEndpointTag(CoreSpan span) { + Object route = span.getTag(HTTP_ROUTE); + + // If route exists and is eligible, don't set endpoint tag + if (route != null && isRouteEligible(route.toString())) { + return; + } + + // Try to compute endpoint from URL + Object url = span.getTag(HTTP_URL); + if (url != null) { + String endpoint = computeEndpointFromUrl(url.toString()); + if (endpoint != null) { + span.setTag(HTTP_ENDPOINT, endpoint); + } + } + } + + /** + * Sets the HTTP endpoint tag on a span context based on configuration flags. This overload + * accepts DDSpanContext for use in tag post-processors and other core components. + * + * @param spanContext The span context to potentially tag + * @param config The tracer configuration containing feature flags + * @deprecated Use {@link #setEndpointTag(java.util.Map, Config)} for better performance in + * post-processors + */ + @Deprecated + public static void setEndpointTag(DDSpanContext spanContext, Config config) { + if (!config.isResourceRenamingEnabled()) { + return; + } + + Object route = spanContext.unsafeGetTag(HTTP_ROUTE); + + // Check if we should use route (when not forcing simplified endpoints) + if (!config.isResourceRenamingAlwaysSimplifiedEndpoint() + && route != null + && isRouteEligible(route.toString())) { + return; + } + + // Try to compute endpoint from URL + Object url = spanContext.unsafeGetTag(HTTP_URL); + if (url != null) { + String endpoint = computeEndpointFromUrl(url.toString()); + if (endpoint != null) { + spanContext.setTag(HTTP_ENDPOINT, endpoint); + } + } + } + + /** + * Sets the HTTP endpoint tag directly on the unsafe tags map based on configuration flags. This + * method is optimized for use in TagPostProcessors where direct map access is more efficient than + * using getTag/setTag methods. + * + * @param unsafeTags The unsafe tags map to potentially modify + * @param config The tracer configuration containing feature flags + */ + public static void setEndpointTag(java.util.Map unsafeTags, Config config) { + if (!config.isResourceRenamingEnabled()) { + return; + } + + Object route = unsafeTags.get(HTTP_ROUTE); + + // Check if we should use route (when not forcing simplified endpoints) + if (!config.isResourceRenamingAlwaysSimplifiedEndpoint() + && route != null + && isRouteEligible(route.toString())) { + return; + } + + // Try to compute endpoint from URL + Object url = unsafeTags.get(HTTP_URL); + if (url != null) { + String endpoint = computeEndpointFromUrl(url.toString()); + if (endpoint != null) { + unsafeTags.put(HTTP_ENDPOINT, endpoint); + } + } + } + + /** + * Sets the HTTP endpoint tag on a span based on configuration flags. This is the production + * method that respects feature flags. + * + * @param span The span to potentially tag + * @param config The tracer configuration containing feature flags + */ + public static void setEndpointTag(CoreSpan span, Config config) { + if (!config.isResourceRenamingEnabled()) { + return; + } + + Object route = span.getTag(HTTP_ROUTE); + + // Check if we should use route (when not forcing simplified endpoints) + if (!config.isResourceRenamingAlwaysSimplifiedEndpoint() + && route != null + && isRouteEligible(route.toString())) { + return; + } + + // Try to compute endpoint from URL + Object url = span.getTag(HTTP_URL); + if (url != null) { + String endpoint = computeEndpointFromUrl(url.toString()); + if (endpoint != null) { + span.setTag(HTTP_ENDPOINT, endpoint); + } + } + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java index 2687211e5b2..d0e0015acaf 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java @@ -13,8 +13,11 @@ private static class Lazy { private static TagsPostProcessor lazyProcessor = createLazyChain(); private static TagsPostProcessor createEagerChain() { - final List processors = new ArrayList<>(2); + final List processors = new ArrayList<>(3); processors.add(new PeerServiceCalculator()); + if (Config.get().isResourceRenamingEnabled()) { + processors.add(new HttpEndpointPostProcessor()); + } if (addBaseService) { processors.add(new BaseServiceAdder(Config.get().getServiceName())); } diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/AggregateMetricTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/AggregateMetricTest.groovy index 3c7a247cae3..ae09ded402e 100644 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/AggregateMetricTest.groovy +++ b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/AggregateMetricTest.groovy @@ -52,7 +52,7 @@ class AggregateMetricTest extends DDSpecification { given: AggregateMetric aggregate = new AggregateMetric().recordDurations(3, new AtomicLongArray(0L, 0L, 0L | ERROR_TAG | TOP_LEVEL_TAG)) - Batch batch = new Batch().reset(new MetricKey("foo", "bar", "qux", "type", 0, false, true, "corge", [UTF8BytesString.create("grault:quux")])) + Batch batch = new Batch().reset(new MetricKey("foo", "bar", "qux", "type", 0, false, true, "corge", [UTF8BytesString.create("grault:quux")], "GET", "/api/endpoint")) batch.add(0L, 10) batch.add(0L, 10) batch.add(0L, 10) @@ -127,7 +127,7 @@ class AggregateMetricTest extends DDSpecification { def "consistent under concurrent attempts to read and write"() { given: AggregateMetric aggregate = new AggregateMetric() - MetricKey key = new MetricKey("foo", "bar", "qux", "type", 0, false, true, "corge", [UTF8BytesString.create("grault:quux")]) + MetricKey key = new MetricKey("foo", "bar", "qux", "type", 0, false, true, "corge", [UTF8BytesString.create("grault:quux")], "GET", "/api/endpoint") BlockingDeque queue = new LinkedBlockingDeque<>(1000) ExecutorService reader = Executors.newSingleThreadExecutor() int writerCount = 10 diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/ConflatingMetricAggregatorTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/ConflatingMetricAggregatorTest.groovy index 52c1bb34de1..41a2f07ea31 100644 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/ConflatingMetricAggregatorTest.groovy +++ b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/ConflatingMetricAggregatorTest.groovy @@ -44,7 +44,8 @@ class ConflatingMetricAggregatorTest extends DDSpecification { 10, queueSize, 1, - MILLISECONDS + MILLISECONDS, + false ) aggregator.start() @@ -74,7 +75,8 @@ class ConflatingMetricAggregatorTest extends DDSpecification { 10, queueSize, 1, - MILLISECONDS + MILLISECONDS, + false ) aggregator.start() @@ -104,7 +106,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS, false) aggregator.start() when: @@ -128,7 +130,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "baz", - [] + [], + null, + null ), _) >> { MetricKey key, AggregateMetric value -> value.getHitCount() == 1 && value.getTopLevelCount() == 1 && value.getDuration() == 100 } @@ -146,7 +150,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS, false) aggregator.start() when: @@ -170,7 +174,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, kind, - [] + [], + null, + null ), { AggregateMetric aggregateMetric -> aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 0 && aggregateMetric.getDuration() == 100 }) @@ -197,7 +203,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >>> [["country"], ["country", "georegion"],] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS, false) aggregator.start() when: @@ -224,7 +230,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "client", - [UTF8BytesString.create("country:france")] + [UTF8BytesString.create("country:france")], + null, + null ), { AggregateMetric aggregateMetric -> aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 0 && aggregateMetric.getDuration() == 100 }) @@ -238,7 +246,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "client", - [UTF8BytesString.create("country:france"), UTF8BytesString.create("georegion:europe")] + [UTF8BytesString.create("country:france"), UTF8BytesString.create("georegion:europe")], + null, + null ), { AggregateMetric aggregateMetric -> aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 0 && aggregateMetric.getDuration() == 100 }) @@ -256,7 +266,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> ["peer.hostname", "_dd.base_service"] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS, false) aggregator.start() when: @@ -281,7 +291,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, kind, - expectedPeerTags + expectedPeerTags, + null, + null ), { AggregateMetric aggregateMetric -> aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 0 && aggregateMetric.getDuration() == 100 }) @@ -305,7 +317,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, features, HealthMetrics.NO_OP, - sink, writer, 10, queueSize, reportingInterval, SECONDS) + sink, writer, 10, queueSize, reportingInterval, SECONDS, false) aggregator.start() when: @@ -329,7 +341,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "baz", - [] + [], + null, + null ), { AggregateMetric value -> value.getHitCount() == 1 && value.getTopLevelCount() == topLevelCount && value.getDuration() == 100 }) @@ -353,7 +367,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS, false) long duration = 100 List trace = [ new SimpleSpan("service", "operation", "resource", "type", true, false, false, 0, duration, HTTP_OK).setTag(SPAN_KIND, "baz"), @@ -384,7 +398,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "baz", - [] + [], + null, + null ), { AggregateMetric value -> value.getHitCount() == count && value.getDuration() == count * duration }) @@ -397,7 +413,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "baz", - [] + [], + null, + null ), { AggregateMetric value -> value.getHitCount() == count && value.getDuration() == count * duration * 2 }) @@ -418,7 +436,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, reportingInterval, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, reportingInterval, SECONDS, false) long duration = 100 aggregator.start() @@ -446,7 +464,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "baz", - [] + [], + null, + null ), _) >> { MetricKey key, AggregateMetric value -> value.getHitCount() == 1 && value.getDuration() == duration } @@ -460,7 +480,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "baz", - [] + [], + null, + null ), _) 1 * writer.finishBucket() >> { latch.countDown() } @@ -477,7 +499,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, reportingInterval, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, reportingInterval, SECONDS, false) long duration = 100 aggregator.start() @@ -505,7 +527,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "baz", - [] + [], + null, + null ), { AggregateMetric value -> value.getHitCount() == 1 && value.getDuration() == duration }) @@ -536,7 +560,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "baz", - [] + [], + null, + null ), { AggregateMetric value -> value.getHitCount() == 1 && value.getDuration() == duration }) @@ -550,7 +576,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "baz", - [] + [], + null, + null ), _) 1 * writer.finishBucket() >> { latch.countDown() } @@ -567,7 +595,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, reportingInterval, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, reportingInterval, SECONDS, false) long duration = 100 aggregator.start() @@ -595,7 +623,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, false, "quux", - [] + [], + null, + null ), { AggregateMetric value -> value.getHitCount() == 1 && value.getDuration() == duration }) @@ -623,7 +653,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, 1, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, 1, SECONDS, false) long duration = 100 aggregator.start() @@ -650,7 +680,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, true, "garply", - [] + [], + null, + null ), { AggregateMetric value -> value.getHitCount() == 1 && value.getDuration() == duration }) @@ -670,7 +702,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, 1, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, 1, SECONDS, false) long duration = 100 aggregator.start() @@ -709,7 +741,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> true features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, 1, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, 1, SECONDS, false) long duration = 100 aggregator.start() @@ -740,7 +772,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { Sink sink = Stub(Sink) DDAgentFeaturesDiscovery features = Mock(DDAgentFeaturesDiscovery) ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, 1, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, 1, SECONDS, false) aggregator.start() when: @@ -762,7 +794,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { features.supportsMetrics() >> false features.peerTags() >> [] ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, 200, MILLISECONDS) + features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, 200, MILLISECONDS, false) final spans = [ new SimpleSpan("service", "operation", "resource", "type", false, true, false, 0, 10, HTTP_OK) ] @@ -794,7 +826,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { DDAgentFeaturesDiscovery features = Mock(DDAgentFeaturesDiscovery) features.supportsMetrics() >> true ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, 1, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, maxAggregates, queueSize, 1, SECONDS, false) when: def async = CompletableFuture.supplyAsync(new Supplier() { @@ -827,7 +859,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification { DDAgentFeaturesDiscovery features = Mock(DDAgentFeaturesDiscovery) features.supportsMetrics() >> true ConflatingMetricsAggregator aggregator = new ConflatingMetricsAggregator(empty, - features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS) + features, HealthMetrics.NO_OP, sink, writer, 10, queueSize, reportingInterval, SECONDS, false) aggregator.start() when: @@ -852,7 +884,9 @@ class ConflatingMetricAggregatorTest extends DDSpecification { false, true, "", - [] + [], + null, + null ), { AggregateMetric aggregateMetric -> aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 1 && aggregateMetric.getDuration() == 100 }) diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/FootprintForkedTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/FootprintForkedTest.groovy index 4a96460d604..716e07c6160 100644 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/FootprintForkedTest.groovy +++ b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/FootprintForkedTest.groovy @@ -39,7 +39,8 @@ class FootprintForkedTest extends DDSpecification { 1000, 1000, 100, - SECONDS) + SECONDS, + false) // Removing the 'features' as it's a mock, and mocks are heavyweight, e.g. around 22MiB def baseline = footprint(aggregator, features) aggregator.start() diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/SerializingMetricWriterTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/SerializingMetricWriterTest.groovy index 88ef1cbc66a..1eb2bda6722 100644 --- a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/SerializingMetricWriterTest.groovy +++ b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/SerializingMetricWriterTest.groovy @@ -57,7 +57,9 @@ class SerializingMetricWriterTest extends DDSpecification { UTF8BytesString.create("country:canada"), UTF8BytesString.create("georegion:amer"), UTF8BytesString.create("peer.service:remote-service") - ] + ], + null, + null ), new AggregateMetric().recordDurations(10, new AtomicLongArray(1L)) ), @@ -76,6 +78,8 @@ class SerializingMetricWriterTest extends DDSpecification { UTF8BytesString.create("georegion:amer"), UTF8BytesString.create("peer.service:remote-service") ], + null, + null ), new AggregateMetric().recordDurations(9, new AtomicLongArray(1L)) ) @@ -91,7 +95,9 @@ class SerializingMetricWriterTest extends DDSpecification { false, false, "producer", - [UTF8BytesString.create("messaging.destination:dest" + i)] + [UTF8BytesString.create("messaging.destination:dest" + i)], + null, + null ), new AggregateMetric().recordDurations(10, new AtomicLongArray(1L)) ) diff --git a/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointPostProcessorTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointPostProcessorTest.groovy new file mode 100644 index 00000000000..166125c9622 --- /dev/null +++ b/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointPostProcessorTest.groovy @@ -0,0 +1,159 @@ +package datadog.trace.core.tagprocessor + +import datadog.trace.api.Config +import datadog.trace.bootstrap.instrumentation.api.Tags +import datadog.trace.core.DDSpanContext +import datadog.trace.test.util.DDSpecification + +class HttpEndpointPostProcessorTest extends DDSpecification { + + def "should not set http.endpoint when resource renaming is disabled"() { + setup: + def config = Mock(Config) + config.isResourceRenamingEnabled() >> false + + def processor = new HttpEndpointPostProcessor(config) + def spanContext = Mock(DDSpanContext) + + when: + def tags = [(Tags.HTTP_URL): "http://example.com/users/123"] + def enrichedTags = processor.processTags(tags, spanContext, []) + + then: + enrichedTags[Tags.HTTP_ENDPOINT] == null + } + + def "should set http.endpoint from URL when route is missing"() { + setup: + def config = Mock(Config) + config.isResourceRenamingEnabled() >> true + config.isResourceRenamingAlwaysSimplifiedEndpoint() >> false + + def processor = new HttpEndpointPostProcessor(config) + def spanContext = Mock(DDSpanContext) + + when: + def tags = [(Tags.HTTP_URL): "http://example.com/users/123"] + def enrichedTags = processor.processTags(tags, spanContext, []) + + then: + enrichedTags[Tags.HTTP_ENDPOINT] == "/users/{param:int}" + } + + def "should not set http.endpoint when http.route is eligible"() { + setup: + def config = Mock(Config) + config.isResourceRenamingEnabled() >> true + config.isResourceRenamingAlwaysSimplifiedEndpoint() >> false + + def processor = new HttpEndpointPostProcessor(config) + def spanContext = Mock(DDSpanContext) + + when: + def tags = [ + (Tags.HTTP_ROUTE): "/api/users/{id}", + (Tags.HTTP_URL): "http://example.com/api/users/12345" + ] + def enrichedTags = processor.processTags(tags, spanContext, []) + + then: + // When route is eligible, http.endpoint is NOT set + enrichedTags[Tags.HTTP_ENDPOINT] == null + } + + def "should set http.endpoint from URL when http.route is not eligible"() { + setup: + def config = Mock(Config) + config.isResourceRenamingEnabled() >> true + config.isResourceRenamingAlwaysSimplifiedEndpoint() >> false + + def processor = new HttpEndpointPostProcessor(config) + def spanContext = Mock(DDSpanContext) + + when: + def tags = [ + (Tags.HTTP_ROUTE): ineligibleRoute, + (Tags.HTTP_URL): "http://example.com/api/users/12345" + ] + def enrichedTags = processor.processTags(tags, spanContext, []) + + then: + enrichedTags[Tags.HTTP_ENDPOINT] == "/api/users/{param:int}" + + where: + ineligibleRoute | _ + "/" | _ + "/*" | _ + "*" | _ + } + + def "should set http.endpoint from URL in always simplified endpoint mode"() { + setup: + def config = Mock(Config) + config.isResourceRenamingEnabled() >> true + config.isResourceRenamingAlwaysSimplifiedEndpoint() >> true + + def processor = new HttpEndpointPostProcessor(config) + def spanContext = Mock(DDSpanContext) + + when: + def tags = [ + (Tags.HTTP_ROUTE): "/api/users/{id}", + (Tags.HTTP_URL): "http://example.com/api/users/12345" + ] + def enrichedTags = processor.processTags(tags, spanContext, []) + + then: + // In always simplified mode, URL parameterization is used instead of route + enrichedTags[Tags.HTTP_ENDPOINT] == "/api/users/{param:int}" + } + + def "should handle various URL patterns"() { + setup: + def config = Mock(Config) + config.isResourceRenamingEnabled() >> true + config.isResourceRenamingAlwaysSimplifiedEndpoint() >> false + + def processor = new HttpEndpointPostProcessor(config) + def spanContext = Mock(DDSpanContext) + + when: + def enrichedTags = processor.processTags([(Tags.HTTP_URL): url], spanContext, []) + + then: + enrichedTags[Tags.HTTP_ENDPOINT] == expectedEndpoint + + where: + url | expectedEndpoint + "http://example.com/" | "/" + "http://example.com/api/users/123" | "/api/users/{param:int}" + "http://example.com/api/resource/abc123def456" | "/api/resource/{param:hex}" + "http://example.com/api/resource/abc-123-def" | "/api/resource/{param:hex_id}" + "http://example.com/api/resource/123.456.789" | "/api/resource/{param:int_id}" + "http://example.com/api/very-long-string-over-twenty-chars" | "/api/{param:str}" + } + + def "should not set http.endpoint when URL is missing"() { + setup: + def config = Mock(Config) + config.isResourceRenamingEnabled() >> true + + def processor = new HttpEndpointPostProcessor(config) + def spanContext = Mock(DDSpanContext) + + when: + def enrichedTags = processor.processTags([:], spanContext, []) + + then: + // When URL is missing, http.endpoint is not set + enrichedTags[Tags.HTTP_ENDPOINT] == null + } + + def "should use default constructor"() { + when: + def processor = new HttpEndpointPostProcessor() + + then: + processor != null + } +} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointTaggingConfigTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointTaggingConfigTest.groovy new file mode 100644 index 00000000000..a75ec5be0c9 --- /dev/null +++ b/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointTaggingConfigTest.groovy @@ -0,0 +1,49 @@ +package datadog.trace.core.tagprocessor + +import datadog.trace.api.Config +import datadog.trace.test.util.DDSpecification + +class HttpEndpointTaggingConfigTest extends DDSpecification { + + def "should be disabled by default"() { + expect: + !Config.get().isResourceRenamingEnabled() + } + + def "should be enabled with DD_TRACE_RESOURCE_RENAMING_ENABLED=true"() { + setup: + injectSysConfig("dd.trace.resource.renaming.enabled", "true") + + expect: + Config.get().isResourceRenamingEnabled() + } + + def "should be disabled with DD_TRACE_RESOURCE_RENAMING_ENABLED=false"() { + setup: + injectSysConfig("dd.trace.resource.renaming.enabled", "false") + + expect: + !Config.get().isResourceRenamingEnabled() + } + + def "should support simplified endpoint override with DD_TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT=true"() { + setup: + injectSysConfig("dd.trace.resource.renaming.always-simplified-endpoint", "true") + + expect: + Config.get().isResourceRenamingAlwaysSimplifiedEndpoint() + } + + def "should support simplified endpoint override with DD_TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT=1"() { + setup: + injectSysConfig("dd.trace.resource.renaming.always-simplified-endpoint", "1") + + expect: + Config.get().isResourceRenamingAlwaysSimplifiedEndpoint() + } + + def "should not enable simplified endpoint override by default"() { + expect: + !Config.get().isResourceRenamingAlwaysSimplifiedEndpoint() + } +} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointTaggingIntegrationTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointTaggingIntegrationTest.groovy new file mode 100644 index 00000000000..b801d4a5e78 --- /dev/null +++ b/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointTaggingIntegrationTest.groovy @@ -0,0 +1,91 @@ +package datadog.trace.core.tagprocessor + +import datadog.trace.common.metrics.MetricKey +import datadog.trace.test.util.DDSpecification + +class HttpEndpointTaggingIntegrationTest extends DDSpecification { + + def "should test http.route vs http.endpoint priority logic"() { + expect: "route eligibility determines priority" + HttpEndpointTagging.isRouteEligible("/api/users/{id}") + !HttpEndpointTagging.isRouteEligible("*") + !HttpEndpointTagging.isRouteEligible("/*") + !HttpEndpointTagging.isRouteEligible("/") + } + + def "should test endpoint computation from URLs for stats bucket enrichment"() { + expect: "URLs are parameterized for cardinality control" + HttpEndpointTagging.computeEndpointFromUrl("http://api.com/users/123") == "/users/{param:int}" + HttpEndpointTagging.computeEndpointFromUrl("http://api.com/sessions/abc123def") == "/sessions/{param:hex}" + HttpEndpointTagging.computeEndpointFromUrl("http://api.com/tokens/550e8400-e29b-41d4-a716-446655440000") == "/tokens/{param:hex_id}" + HttpEndpointTagging.computeEndpointFromUrl("http://api.com/files/very-long-filename-that-exceeds-twenty-characters.pdf") == "/files/{param:str}" + } + + def "should test aggregation key enhancement with HTTP tags"() { + setup: + def key1 = new MetricKey( + "GET /users/{param:int}", + "web-service", + "servlet.request", + "web", + 200, + false, + true, + "server", + [], + "GET", + "/users/{param:int}" + ) + + def key2 = new MetricKey( + "GET /users/{param:int}", + "web-service", + "servlet.request", + "web", + 200, + false, + true, + "server", + [], + "GET", + "/users/{param:int}" + ) + + def key3 = new MetricKey( + "GET /users/{param:int}", + "web-service", + "servlet.request", + "web", + 200, + false, + true, + "server", + [], + "POST", // Different method + "/users/{param:int}" + ) + + expect: "HTTP method and endpoint are part of aggregation key" + key1.equals(key2) + !key1.equals(key3) // Different HTTP method should create different key + key1.hashCode() == key2.hashCode() + key1.hashCode() != key3.hashCode() + } + + def "should test backend reliability scenario"() { + expect: "eligible routes should be preferred over URL parameterization" + HttpEndpointTagging.isRouteEligible("/api/v1/users/{userId}/orders/{orderId}") + HttpEndpointTagging.isRouteEligible("/health/check") + HttpEndpointTagging.isRouteEligible("/api/*") // This is eligible because it contains non-wildcard content + !HttpEndpointTagging.isRouteEligible("*") + !HttpEndpointTagging.isRouteEligible("/*") // This should be ineligible + !HttpEndpointTagging.isRouteEligible("/") // This should be ineligible + } + + def "should provide cardinality control for service entry spans"() { + expect: "service entry spans get parameterized endpoints for identification" + HttpEndpointTagging.computeEndpointFromUrl("http://api.service.com/health/check") == "/health/check" + HttpEndpointTagging.computeEndpointFromUrl("http://api.service.com/metrics") == "/metrics" + HttpEndpointTagging.computeEndpointFromUrl("http://api.service.com/api/users/123/profile") == "/api/users/{param:int}/profile" + } +} diff --git a/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointTaggingTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointTaggingTest.groovy new file mode 100644 index 00000000000..d791bcfc918 --- /dev/null +++ b/dd-trace-core/src/test/groovy/datadog/trace/core/tagprocessor/HttpEndpointTaggingTest.groovy @@ -0,0 +1,214 @@ +package datadog.trace.core.tagprocessor + +import datadog.trace.test.util.DDSpecification + +class HttpEndpointTaggingTest extends DDSpecification { + + def "should accept valid routes"() { + expect: + HttpEndpointTagging.isRouteEligible("/api/users/{id}") + HttpEndpointTagging.isRouteEligible("/v1/orders/{orderId}/items") + HttpEndpointTagging.isRouteEligible("/users") + HttpEndpointTagging.isRouteEligible("/health") + HttpEndpointTagging.isRouteEligible("/api/v2/products/{productId}") + } + + def "should reject invalid routes"() { + expect: + !HttpEndpointTagging.isRouteEligible(null) + !HttpEndpointTagging.isRouteEligible("") + !HttpEndpointTagging.isRouteEligible(" ") + !HttpEndpointTagging.isRouteEligible("not-a-path") + !HttpEndpointTagging.isRouteEligible("ftp://example.com") + !HttpEndpointTagging.isRouteEligible("relative/path") + } + + def "should parameterize UUID segments with hex_id token"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/api/users/123e4567-e89b-12d3-a456-426614174000") == "/api/users/{param:hex_id}" + HttpEndpointTagging.parameterizeUrlPath("/api/users/550e8400-e29b-41d4-a716-446655440000/orders") == "/api/users/{param:hex_id}/orders" + HttpEndpointTagging.parameterizeUrlPath("/api/users/f47ac10b-58cc-4372-a567-0e02b2c3d479/profile") == "/api/users/{param:hex_id}/profile" + } + + def "should parameterize numeric segments with int token"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/api/users/12345") == "/api/users/{param:int}" + HttpEndpointTagging.parameterizeUrlPath("/api/orders/67890/items/123") == "/api/orders/{param:int}/items/{param:int}" + HttpEndpointTagging.parameterizeUrlPath("/api/products/999") == "/api/products/{param:int}" + } + + def "should parameterize hex segments with hex token"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/api/sessions/abc123def456") == "/api/sessions/{param:hex}" + HttpEndpointTagging.parameterizeUrlPath("/api/tokens/deadbeef") == "/api/tokens/deadbeef" // No digits, so no match + HttpEndpointTagging.parameterizeUrlPath("/api/hashes/1a2b3c4d5e6f") == "/api/hashes/{param:hex}" + } + + def "should parameterize int_id segments with int_id token"() { + expect: + // Pattern: (?=.*[0-9].*)[0-9._-]{3,} → {param:int_id} + // This pattern matches strings that: + // 1. Have at least one digit ((?=.*[0-9].*) lookahead) + // 2. Contain only digits, dots, underscores, or dashes ([0-9._-]) + // 3. Are at least 3 characters long ({3,}) + + // Test cases that should match int_id pattern + HttpEndpointTagging.parameterizeUrlPath("/api/orders/0.99") == "/api/orders/{param:int_id}" // Starts with 0, has dot, 3+ chars + HttpEndpointTagging.parameterizeUrlPath("/api/versions/0123") == "/api/versions/{param:int_id}" // Starts with 0, 3+ chars + HttpEndpointTagging.parameterizeUrlPath("/api/refs/12.34") == "/api/refs/{param:int_id}" // Has dot, 3+ chars + HttpEndpointTagging.parameterizeUrlPath("/api/items/1.2.3") == "/api/items/{param:int_id}" // Has dots, won't match int pattern first + HttpEndpointTagging.parameterizeUrlPath("/api/ids/123-456") == "/api/ids/{param:int_id}" // Has dash, 3+ chars + HttpEndpointTagging.parameterizeUrlPath("/api/codes/9_8_7") == "/api/codes/{param:int_id}" // Has underscores, 3+ chars + + // Test cases that should NOT match int_id pattern + HttpEndpointTagging.parameterizeUrlPath("/api/users/abc") == "/api/users/abc" // No digits + HttpEndpointTagging.parameterizeUrlPath("/api/users/10") == "/api/users/{param:int}" // Matches int pattern first (starts with 1, has digits) + HttpEndpointTagging.parameterizeUrlPath("/api/mixed/12a") == "/api/mixed/12a" // Contains letter 'a', not allowed in int_id + + // Test edge cases for int_id pattern + HttpEndpointTagging.parameterizeUrlPath("/api/test/01") == "/api/test/01" // Starts with 0, only 2 chars, too short for int_id + HttpEndpointTagging.parameterizeUrlPath("/api/test/012") == "/api/test/{param:int_id}" // Starts with 0, 3+ chars, matches int_id + } + + + def "should parameterize hex_id segments with hex_id token"() { + expect: + // Pattern: (?=.*[0-9].*)[A-Fa-f0-9._-]{6,} → {param:hex_id} + // This pattern matches strings that: + // 1. Have at least one digit ((?=.*[0-9].*) lookahead) + // 2. Contain only hex digits, dots, underscores, or dashes ([A-Fa-f0-9._-]) + // 3. Are at least 6 characters long ({6,}) + + // Test cases that should match hex_id pattern + HttpEndpointTagging.parameterizeUrlPath("/api/sessions/abc123-def456") == "/api/sessions/{param:hex_id}" // Has digits, hex chars, delimiters, 6+ chars + HttpEndpointTagging.parameterizeUrlPath("/api/tokens/deadbeef.123") == "/api/tokens/{param:hex_id}" // Has digits, hex chars, delimiters, 6+ chars + HttpEndpointTagging.parameterizeUrlPath("/api/hashes/1a2b3c_4d5e6f") == "/api/hashes/{param:hex_id}" // Has digits, hex chars, delimiters, 6+ chars + HttpEndpointTagging.parameterizeUrlPath("/api/uuids/550e8400-e29b-41d4-a716-446655440000") == "/api/uuids/{param:hex_id}" // UUID format + HttpEndpointTagging.parameterizeUrlPath("/api/keys/abc123def") == "/api/keys/{param:hex}" // Matches hex pattern first (pure hex chars), not hex_id + + // Test cases that should NOT match hex_id pattern + HttpEndpointTagging.parameterizeUrlPath("/api/pure/abcdef") == "/api/pure/abcdef" // No digits, so no match + HttpEndpointTagging.parameterizeUrlPath("/api/short/a1b2c") == "/api/short/a1b2c" // Only 5 chars, less than required 6 + HttpEndpointTagging.parameterizeUrlPath("/api/invalid/123xyz") == "/api/invalid/123xyz" // Contains 'x', 'y', 'z' which are not in [A-Fa-f0-9._-] + + // Test cases that might match other patterns first + HttpEndpointTagging.parameterizeUrlPath("/api/numbers/123456") == "/api/numbers/{param:int}" // Matches int pattern first (starts with 1, all digits) + HttpEndpointTagging.parameterizeUrlPath("/api/tokens/deadbeef") == "/api/tokens/deadbeef" + // Should not match if less than 6 characters + HttpEndpointTagging.parameterizeUrlPath("/api/tokens/abc12") == "/api/tokens/abc12" + } + + + def "should parameterize mixed segments correctly"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/api/users/123/orders/abc456def/items/789") == "/api/users/{param:int}/orders/{param:hex}/items/{param:int}" + HttpEndpointTagging.parameterizeUrlPath("/api/v1/users/550e8400-e29b-41d4-a716-446655440000/sessions/deadbeef") == "/api/v1/users/{param:hex_id}/sessions/deadbeef" // deadbeef has no digits + } + + def "should preserve static segments"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/api/users") == "/api/users" + HttpEndpointTagging.parameterizeUrlPath("/health") == "/health" + } + + def "should reject ineligible routes according to specification"() { + expect: + !HttpEndpointTagging.isRouteEligible(null) + !HttpEndpointTagging.isRouteEligible("") + !HttpEndpointTagging.isRouteEligible(" ") + !HttpEndpointTagging.isRouteEligible("*") + !HttpEndpointTagging.isRouteEligible("/*") + !HttpEndpointTagging.isRouteEligible("/") + !HttpEndpointTagging.isRouteEligible("**") + !HttpEndpointTagging.isRouteEligible("*/") + !HttpEndpointTagging.isRouteEligible("no-leading-slash") + + HttpEndpointTagging.isRouteEligible("/api/users") + HttpEndpointTagging.isRouteEligible("/health") + HttpEndpointTagging.isRouteEligible("/api/v1/users/{id}") + } + + def "should handle edge cases"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/") == "/" + HttpEndpointTagging.parameterizeUrlPath("") == "" + HttpEndpointTagging.parameterizeUrlPath(null) == null + HttpEndpointTagging.parameterizeUrlPath("/api/users/123/") == "/api/users/{param:int}" + } + + def "should handle query parameters"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/api/users/123?param=value") == "/api/users/{param:int}" + HttpEndpointTagging.parameterizeUrlPath("/api/search?q=test&limit=10") == "/api/search" + HttpEndpointTagging.parameterizeUrlPath("/api/users/abc123?filter=active") == "/api/users/{param:hex}" + } + + def "should handle fragments"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/api/users/123#section") == "/api/users/{param:int}" + HttpEndpointTagging.parameterizeUrlPath("/api/docs#introduction") == "/api/docs" + } + + def "should compute endpoint from valid URLs"() { + expect: + HttpEndpointTagging.computeEndpointFromUrl("http://example.com/api/users/123") == "/api/users/{param:int}" + HttpEndpointTagging.computeEndpointFromUrl("https://api.example.com/api/orders/456") == "/api/orders/{param:int}" + HttpEndpointTagging.computeEndpointFromUrl("http://localhost:8080/health") == "/health" + HttpEndpointTagging.computeEndpointFromUrl("https://example.com:443/api/v1/users/789?param=value") == "/api/v1/users/{param:int}" + } + + def "should return default endpoint for invalid URLs"() { + expect: + HttpEndpointTagging.computeEndpointFromUrl(null) == "/" + HttpEndpointTagging.computeEndpointFromUrl("") == "/" + HttpEndpointTagging.computeEndpointFromUrl(" ") == "/" + HttpEndpointTagging.computeEndpointFromUrl("not-a-url") == "/" + HttpEndpointTagging.computeEndpointFromUrl("http://") == "/" + HttpEndpointTagging.computeEndpointFromUrl("://example.com/path") == "/" + HttpEndpointTagging.computeEndpointFromUrl("http:///path") == "/" + HttpEndpointTagging.computeEndpointFromUrl("ftp://example.com/file.txt") == "/file.txt" // FTP URL matches pattern and extracts path + } + + def "should return root path for root URLs"() { + expect: + HttpEndpointTagging.computeEndpointFromUrl("http://example.com/") == "/" + HttpEndpointTagging.computeEndpointFromUrl("https://example.com") == "/" + } + + def "should handle complex paths"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/api/v2/users/123/orders/abc456/items/789/reviews/def123") == "/api/v2/users/{param:int}/orders/{param:hex}/items/{param:int}" // Limited to 8 elements: api,v2,users,123,orders,abc456,items,789 + HttpEndpointTagging.parameterizeUrlPath("/files/2023/12/document123.pdf") == "/files/{param:int}/{param:int}/document123.pdf" // document123.pdf is <20 chars and no special chars + HttpEndpointTagging.parameterizeUrlPath("/api/sessions/550e8400-e29b-41d4-a716-446655440000/refresh") == "/api/sessions/{param:hex_id}/refresh" + } + + def "should preserve static segments in complex paths"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/api/v1/users/123/profile/settings") == "/api/v1/users/{param:int}/profile/settings" + HttpEndpointTagging.parameterizeUrlPath("/admin/dashboard/users/456/edit") == "/admin/dashboard/users/{param:int}/edit" + HttpEndpointTagging.parameterizeUrlPath("/public/assets/images/user-789/avatar.png") == "/public/assets/images/user-789/avatar.png" // user-789 doesn't match int_id pattern + } + + def "should limit cardinality through parameterization"() { + expect: + HttpEndpointTagging.parameterizeUrlPath("/api/users/1") == "/api/users/1" // Single digit not matched by [1-9][0-9]+ + HttpEndpointTagging.parameterizeUrlPath("/api/users/99") == "/api/users/{param:int}" + HttpEndpointTagging.parameterizeUrlPath("/api/users/123456789") == "/api/users/{param:int}" + + // Test that hex strings with digits are parameterized to limit cardinality + HttpEndpointTagging.parameterizeUrlPath("/api/tokens/abcdef") == "/api/tokens/abcdef" // No digits, no match + HttpEndpointTagging.parameterizeUrlPath("/api/tokens/123abc") == "/api/tokens/{param:hex}" + HttpEndpointTagging.parameterizeUrlPath("/api/tokens/deadbeef123") == "/api/tokens/{param:hex}" + } + + def "should handle empty path elements correctly"() { + expect: + // Test paths with double slashes (empty elements) + HttpEndpointTagging.parameterizeUrlPath("/api//users/123") == "/api/users/{param:int}" // Empty element discarded + HttpEndpointTagging.parameterizeUrlPath("//api/users/123//") == "/api/users/{param:int}" // Multiple empty elements discarded + HttpEndpointTagging.parameterizeUrlPath("/api/v1//users//123//orders//456") == "/api/v1/users/{param:int}/orders/{param:int}" // All empty elements discarded + + // Test 8-element limit with empty elements mixed in + HttpEndpointTagging.parameterizeUrlPath("/a//b//c//d//e//f//g//h//i//j") == "/a/b/c/d/e/f/g/h" // Only first 8 non-empty elements kept + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/MetricKeyTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/MetricKeyTest.java new file mode 100644 index 00000000000..bb2be4f865f --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/MetricKeyTest.java @@ -0,0 +1,213 @@ +package datadog.trace.common.metrics; + +import static org.junit.Assert.*; + +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import java.util.Collections; +import org.junit.Test; + +public class MetricKeyTest { + + @Test + public void testMetricKeyWithHttpMethodAndEndpoint() { + MetricKey key = + new MetricKey( + "GET /api/users/?", + "my-service", + "http.request", + "web", + 200, + false, + true, + "server", + Collections.emptyList(), + "GET", + "/api/users/?"); + + assertEquals(UTF8BytesString.create("GET /api/users/?"), key.getResource()); + assertEquals(UTF8BytesString.create("my-service"), key.getService()); + assertEquals(UTF8BytesString.create("http.request"), key.getOperationName()); + assertEquals(UTF8BytesString.create("web"), key.getType()); + assertEquals(200, key.getHttpStatusCode()); + assertFalse(key.isSynthetics()); + assertTrue(key.isTraceRoot()); + assertEquals(UTF8BytesString.create("server"), key.getSpanKind()); + assertEquals(Collections.emptyList(), key.getPeerTags()); + assertEquals(UTF8BytesString.create("GET"), key.getHttpMethod()); + assertEquals(UTF8BytesString.create("/api/users/?"), key.getHttpEndpoint()); + } + + @Test + public void testMetricKeyWithNullHttpMethodAndEndpoint() { + MetricKey key = + new MetricKey( + "resource", + "service", + "operation", + "type", + 0, + false, + false, + "client", + Collections.emptyList(), + null, + null); + + assertEquals(UTF8BytesString.EMPTY, key.getHttpMethod()); + assertEquals(UTF8BytesString.EMPTY, key.getHttpEndpoint()); + } + + @Test + public void testMetricKeyEqualsWithHttpMethodAndEndpoint() { + MetricKey key1 = + new MetricKey( + "resource", + "service", + "operation", + "type", + 200, + false, + true, + "server", + Collections.emptyList(), + "POST", + "/api/orders/?"); + + MetricKey key2 = + new MetricKey( + "resource", + "service", + "operation", + "type", + 200, + false, + true, + "server", + Collections.emptyList(), + "POST", + "/api/orders/?"); + + assertEquals(key1, key2); + assertEquals(key1.hashCode(), key2.hashCode()); + } + + @Test + public void testMetricKeyNotEqualsWithDifferentHttpMethod() { + MetricKey key1 = + new MetricKey( + "resource", + "service", + "operation", + "type", + 200, + false, + true, + "server", + Collections.emptyList(), + "GET", + "/api/users/?"); + + MetricKey key2 = + new MetricKey( + "resource", + "service", + "operation", + "type", + 200, + false, + true, + "server", + Collections.emptyList(), + "POST", + "/api/users/?"); + + assertNotEquals(key1, key2); + } + + @Test + public void testMetricKeyNotEqualsWithDifferentHttpEndpoint() { + MetricKey key1 = + new MetricKey( + "resource", + "service", + "operation", + "type", + 200, + false, + true, + "server", + Collections.emptyList(), + "GET", + "/api/users/?"); + + MetricKey key2 = + new MetricKey( + "resource", + "service", + "operation", + "type", + 200, + false, + true, + "server", + Collections.emptyList(), + "GET", + "/api/orders/?"); + + assertNotEquals(key1, key2); + } + + @Test + public void testMetricKeyHashCodeIncludesHttpMethodAndEndpoint() { + MetricKey key1 = + new MetricKey( + "resource", + "service", + "operation", + "type", + 200, + false, + true, + "server", + Collections.emptyList(), + "GET", + "/api/users/?"); + + MetricKey key2 = + new MetricKey( + "resource", + "service", + "operation", + "type", + 200, + false, + true, + "server", + Collections.emptyList(), + "POST", + "/api/users/?"); + + // Hash codes should be different when HTTP method differs + assertNotEquals(key1.hashCode(), key2.hashCode()); + } + + @Test + public void testMetricKeyWithEmptyHttpMethodAndEndpoint() { + MetricKey key = + new MetricKey( + "resource", + "service", + "operation", + "type", + 0, + false, + false, + "client", + Collections.emptyList(), + "", + ""); + + assertEquals(UTF8BytesString.create(""), key.getHttpMethod()); + assertEquals(UTF8BytesString.create(""), key.getHttpEndpoint()); + } +} diff --git a/dd-trace-core/src/traceAgentTest/groovy/MetricsIntegrationTest.groovy b/dd-trace-core/src/traceAgentTest/groovy/MetricsIntegrationTest.groovy index 9984b9700d0..7a7b90b5348 100644 --- a/dd-trace-core/src/traceAgentTest/groovy/MetricsIntegrationTest.groovy +++ b/dd-trace-core/src/traceAgentTest/groovy/MetricsIntegrationTest.groovy @@ -35,11 +35,11 @@ class MetricsIntegrationTest extends AbstractTraceAgentTest { ) writer.startBucket(2, System.nanoTime(), SECONDS.toNanos(10)) writer.add( - new MetricKey("resource1", "service1", "operation1", "sql", 0, false, true, "xyzzy", [UTF8BytesString.create("grault:quux")]), + new MetricKey("resource1", "service1", "operation1", "sql", 0, false, true, "xyzzy", [UTF8BytesString.create("grault:quux")], "", ""), new AggregateMetric().recordDurations(5, new AtomicLongArray(2, 1, 2, 250, 4, 5)) ) writer.add( - new MetricKey("resource2", "service2", "operation2", "web", 200, false, true, "xyzzy", [UTF8BytesString.create("grault:quux")]), + new MetricKey("resource2", "service2", "operation2", "web", 200, false, true, "xyzzy", [UTF8BytesString.create("grault:quux")], "GET", "/api"), new AggregateMetric().recordDurations(10, new AtomicLongArray(1, 1, 200, 2, 3, 4, 5, 6, 7, 8, 9)) ) writer.finishBucket() 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 ef0a02114da..2c4f6cfa589 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -124,6 +124,8 @@ import static datadog.trace.api.ConfigDefaults.DEFAULT_REMOTE_CONFIG_POLL_INTERVAL_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_REMOTE_CONFIG_TARGETS_KEY; import static datadog.trace.api.ConfigDefaults.DEFAULT_REMOTE_CONFIG_TARGETS_KEY_ID; +import static datadog.trace.api.ConfigDefaults.DEFAULT_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT; +import static datadog.trace.api.ConfigDefaults.DEFAULT_RESOURCE_RENAMING_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_RUM_MAJOR_VERSION; import static datadog.trace.api.ConfigDefaults.DEFAULT_SCOPE_DEPTH_LIMIT; import static datadog.trace.api.ConfigDefaults.DEFAULT_SCOPE_ITERATION_KEEP_ALIVE; @@ -639,6 +641,8 @@ import static datadog.trace.api.config.TracerConfig.TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED; import static datadog.trace.api.config.TracerConfig.TRACE_REPORT_HOSTNAME; import static datadog.trace.api.config.TracerConfig.TRACE_RESOLVER_ENABLED; +import static datadog.trace.api.config.TracerConfig.TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT; +import static datadog.trace.api.config.TracerConfig.TRACE_RESOURCE_RENAMING_ENABLED; import static datadog.trace.api.config.TracerConfig.TRACE_SAMPLE_RATE; import static datadog.trace.api.config.TracerConfig.TRACE_SAMPLING_OPERATION_RULES; import static datadog.trace.api.config.TracerConfig.TRACE_SAMPLING_RULES; @@ -833,6 +837,10 @@ public static String getHostName() { private final Map httpServerPathResourceNameMapping; private final Map httpClientPathResourceNameMapping; private final boolean httpResourceRemoveTrailingSlash; + + // HTTP Endpoint Tagging feature flags + private final boolean resourceRenamingEnabled; + private final boolean resourceRenamingAlwaysSimplifiedEndpoint; private final boolean httpClientTagQueryString; private final boolean httpClientTagHeaders; private final boolean httpClientSplitByDomain; @@ -1578,6 +1586,15 @@ private Config(final ConfigProvider configProvider, final InstrumenterConfig ins TRACE_HTTP_RESOURCE_REMOVE_TRAILING_SLASH, DEFAULT_TRACE_HTTP_RESOURCE_REMOVE_TRAILING_SLASH); + // HTTP Endpoint Tagging feature flags + resourceRenamingEnabled = + configProvider.getBoolean( + TRACE_RESOURCE_RENAMING_ENABLED, DEFAULT_RESOURCE_RENAMING_ENABLED); + resourceRenamingAlwaysSimplifiedEndpoint = + configProvider.getBoolean( + TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT, + DEFAULT_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT); + httpServerErrorStatuses = configProvider.getIntegerRange( TRACE_HTTP_SERVER_ERROR_STATUSES, @@ -3126,6 +3143,14 @@ public boolean isHttpServerRouteBasedNaming() { return httpServerRouteBasedNaming; } + public boolean isResourceRenamingEnabled() { + return resourceRenamingEnabled; + } + + public boolean isResourceRenamingAlwaysSimplifiedEndpoint() { + return resourceRenamingAlwaysSimplifiedEndpoint; + } + public boolean isHttpClientTagQueryString() { return httpClientTagQueryString; } 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 3eaa1e292cc..5f4cd800b44 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 @@ -19,6 +19,7 @@ public class Tags { public static final String HTTP_ROUTE = "http.route"; 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"; 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";