Skip to content

Commit 2013c61

Browse files
authored
feat: allow HTTP operations to be configurable via environment variable (#1352)
*Issue #, if available:* *Description of changes:* When HTTP span names don't contain a URL path, we generate the HTTP operation by truncating the URL path to only the first trailing value to preserve low cardinality (i.e. /api/v1/users -> /api). This can result in overly broad operation groupings for services with endpoint paths of various depths. This PR introduces an environment variable configuration, `OTEL_AWS_HTTP_OPERATION_PATHS`, which allows users to configure their own HTTP endpoint paths. If this variable is provided, the span name's URL path will resolve to the longest matching path. Wildcards are supported with the following syntaxes: `{version}`, `:version`, or simply `*`. This way, users can decide how their service endpoint are grouped into operation names shown in CloudWatch. Added unit and integration tests to verify behavior, and did some E2E testing with an instrumented HTTP server. By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent cd1acb9 commit 2013c61

6 files changed

Lines changed: 584 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ If your change does not need a CHANGELOG entry, add the "skip changelog" label t
1313

1414
## Unreleased
1515

16+
- Support environment-configured endpoint visibility for HTTP operation names
17+
([#1352](https://github.com/aws-observability/aws-otel-java-instrumentation/pull/1352))
1618
- Bump Netty to 4.1.132.Final to fix CVE-2026-33870 and CVE-2026-33871
1719
([#1348](https://github.com/aws-observability/aws-otel-java-instrumentation/pull/1348))
1820

awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/AwsMetricAttributesSpanExporter.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ private List<SpanData> addMetricAttributes(Collection<SpanData> spans) {
8888
List<SpanData> modifiedSpans = new ArrayList<>();
8989

9090
for (SpanData span : spans) {
91+
// If OTEL_AWS_HTTP_OPERATION_PATHS is configured and matches, wrap the span with the
92+
// overridden name so that the exported trace carries the correct span name. This ensures
93+
// getIngressOperation (called below) derives aws.local.operation from the overridden name.
94+
span = AwsSpanProcessingUtil.applyOperationPathSpanName(span);
95+
9196
// If the map has no items, no modifications are required. If there is one item, it means the
9297
// span either produces Service or Dependency metric attributes, and in either case we want to
9398
// modify the span with them. If there are two items, the span produces both Service and

awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/AwsSpanMetricsProcessor.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ public boolean isStartRequired() {
132132
public void onEnd(ReadableSpan span) {
133133
SpanData spanData = span.toSpanData();
134134

135+
// If OTEL_AWS_HTTP_OPERATION_PATHS is configured, wrap the span with the overridden name
136+
// so that metrics use the configured operation path instead of the original span name.
137+
spanData = AwsSpanProcessingUtil.applyOperationPathSpanName(spanData);
138+
135139
Map<String, Attributes> attributeMap =
136140
generator.generateMetricAttributeMapFromSpan(spanData, resource);
137141

awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/AwsSpanProcessingUtil.java

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@
4343
import io.opentelemetry.api.trace.SpanKind;
4444
import io.opentelemetry.sdk.common.InstrumentationScopeInfo;
4545
import io.opentelemetry.sdk.trace.ReadableSpan;
46+
import io.opentelemetry.sdk.trace.data.DelegatingSpanData;
4647
import io.opentelemetry.sdk.trace.data.SpanData;
4748
import java.io.IOException;
4849
import java.io.InputStream;
4950
import java.util.ArrayList;
51+
import java.util.Collections;
5052
import java.util.List;
5153
import java.util.regex.Pattern;
5254

@@ -78,6 +80,153 @@ final class AwsSpanProcessingUtil {
7880
static final String LAMBDA_SCOPE_PREFIX = "io.opentelemetry.aws-lambda-";
7981
static final String SERVLET_SCOPE_PREFIX = "io.opentelemetry.servlet-";
8082

83+
// Environment variable for configurable operation name paths
84+
static final String OTEL_AWS_HTTP_OPERATION_PATHS_CONFIG = "OTEL_AWS_HTTP_OPERATION_PATHS";
85+
86+
// Parsed and sorted (longest first) operation paths from env var, computed once
87+
private static volatile List<String> operationPaths;
88+
89+
/**
90+
* Parse the OTEL_AWS_HTTP_OPERATION_PATHS env var into a sorted list of path templates (longest
91+
* first). Returns an empty list if the env var is not set.
92+
*/
93+
static List<String> getOperationPaths() {
94+
if (operationPaths == null) {
95+
synchronized (AwsSpanProcessingUtil.class) {
96+
if (operationPaths == null) {
97+
String config = System.getenv(OTEL_AWS_HTTP_OPERATION_PATHS_CONFIG);
98+
if (config == null || config.trim().isEmpty()) {
99+
operationPaths = Collections.emptyList();
100+
} else {
101+
List<String> paths = new ArrayList<>();
102+
for (String path : config.split(",")) {
103+
String trimmed = path.trim();
104+
if (!trimmed.isEmpty()) {
105+
paths.add(trimmed);
106+
}
107+
}
108+
// Sort longest first so longest prefix match wins. For patterns with the same
109+
// number of segments, the original configuration order is preserved (stable sort).
110+
paths.sort(
111+
(a, b) -> {
112+
int aSegments = a.split("/").length;
113+
int bSegments = b.split("/").length;
114+
return Integer.compare(bSegments, aSegments);
115+
});
116+
operationPaths = Collections.unmodifiableList(paths);
117+
}
118+
}
119+
}
120+
}
121+
return operationPaths;
122+
}
123+
124+
// Visible for testing — allows tests to reset the cached paths
125+
static void resetOperationPaths() {
126+
synchronized (AwsSpanProcessingUtil.class) {
127+
operationPaths = null;
128+
}
129+
}
130+
131+
/**
132+
* If OTEL_AWS_HTTP_OPERATION_PATHS is configured and a pattern matches the span's URL path,
133+
* returns a wrapped SpanData with the span name overridden to "METHOD /path/template". Returns
134+
* the original span unchanged if no config is set or no pattern matches.
135+
*/
136+
static SpanData applyOperationPathSpanName(SpanData span) {
137+
List<String> paths = getOperationPaths();
138+
if (paths.isEmpty()) {
139+
return span;
140+
}
141+
142+
String urlPath = getUrlPath(span);
143+
if (urlPath == null || urlPath.isEmpty()) {
144+
return span;
145+
}
146+
147+
// Strip query string and fragment (relevant for http.target)
148+
int idx = urlPath.indexOf('?');
149+
if (idx >= 0) {
150+
urlPath = urlPath.substring(0, idx);
151+
}
152+
idx = urlPath.indexOf('#');
153+
if (idx >= 0) {
154+
urlPath = urlPath.substring(0, idx);
155+
}
156+
157+
// Normalize trailing slashes
158+
while (urlPath.endsWith("/") && urlPath.length() > 1) {
159+
urlPath = urlPath.substring(0, urlPath.length() - 1);
160+
}
161+
162+
String[] urlSegments = urlPath.split("/", -1);
163+
for (String pattern : paths) {
164+
String normalizedPattern = pattern;
165+
while (normalizedPattern.endsWith("/") && normalizedPattern.length() > 1) {
166+
normalizedPattern = normalizedPattern.substring(0, normalizedPattern.length() - 1);
167+
}
168+
if (segmentsMatch(urlSegments, normalizedPattern.split("/", -1))) {
169+
String httpMethod = getHttpMethod(span);
170+
String newName = httpMethod != null ? httpMethod + " " + pattern : pattern;
171+
return new DelegatingSpanData(span) {
172+
@Override
173+
public String getName() {
174+
return newName;
175+
}
176+
};
177+
}
178+
}
179+
return span;
180+
}
181+
182+
/** Return the URL path from server span attributes, preferring url.path over http.target. */
183+
private static String getUrlPath(SpanData span) {
184+
if (isKeyPresent(span, URL_PATH)) {
185+
return span.getAttributes().get(URL_PATH);
186+
}
187+
if (isKeyPresent(span, HTTP_TARGET)) {
188+
return span.getAttributes().get(HTTP_TARGET);
189+
}
190+
return null;
191+
}
192+
193+
/**
194+
* Check if URL segments match a pattern's segments. Only pattern segments can be wildcards
195+
* ({param}, :param, or *) — URL segments are always treated as literals. A wildcard pattern
196+
* segment matches any non-empty URL segment. The pattern acts as a prefix — extra URL segments
197+
* after the pattern are allowed.
198+
*/
199+
private static boolean segmentsMatch(String[] urlSegments, String[] patternSegments) {
200+
for (int i = 0; i < patternSegments.length; i++) {
201+
if (i >= urlSegments.length) {
202+
return false;
203+
}
204+
String ps = patternSegments[i];
205+
String us = urlSegments[i];
206+
207+
// Pattern wildcard matches any non-empty URL segment
208+
if (isWildcardSegment(ps)) {
209+
if (us.isEmpty()) {
210+
return false;
211+
}
212+
continue;
213+
}
214+
215+
// Both literal — must be equal
216+
if (!ps.equals(us)) {
217+
return false;
218+
}
219+
}
220+
return true;
221+
}
222+
223+
/** A segment is a wildcard if it uses {param}, :param, or * format. */
224+
private static boolean isWildcardSegment(String segment) {
225+
return (segment.startsWith("{") && segment.endsWith("}"))
226+
|| segment.startsWith(":")
227+
|| segment.equals("*");
228+
}
229+
81230
static List<String> getDialectKeywords() {
82231
try (InputStream jsonFile =
83232
AwsSpanProcessingUtil.class
@@ -123,6 +272,7 @@ static String getIngressOperation(SpanData span) {
123272
}
124273
return getFunctionNameFromEnv() + "/FunctionHandler";
125274
}
275+
126276
String operation = span.getName();
127277
if (shouldUseInternalOperation(span)) {
128278
operation = INTERNAL_OPERATION;
@@ -132,6 +282,16 @@ static String getIngressOperation(SpanData span) {
132282
return operation;
133283
}
134284

285+
/** Get the HTTP method from the span, checking new and deprecated semconv attributes. */
286+
private static String getHttpMethod(SpanData span) {
287+
if (isKeyPresent(span, HTTP_REQUEST_METHOD)) {
288+
return span.getAttributes().get(HTTP_REQUEST_METHOD);
289+
} else if (isKeyPresent(span, HTTP_METHOD)) {
290+
return span.getAttributes().get(HTTP_METHOD);
291+
}
292+
return null;
293+
}
294+
135295
// define a function so that we can mock it in unit test
136296
static String getFunctionNameFromEnv() {
137297
return System.getenv(AWS_LAMBDA_FUNCTION_NAME_CONFIG);
@@ -256,11 +416,8 @@ private static boolean isValidOperation(SpanData span, String operation) {
256416
if (operation == null || operation.equals(UNKNOWN_OPERATION)) {
257417
return false;
258418
}
259-
if (isKeyPresent(span, HTTP_REQUEST_METHOD)) {
260-
String httpMethod = span.getAttributes().get(HTTP_REQUEST_METHOD);
261-
return !operation.equals(httpMethod);
262-
} else if (isKeyPresent(span, HTTP_METHOD)) {
263-
String httpMethod = span.getAttributes().get(HTTP_METHOD);
419+
String httpMethod = getHttpMethod(span);
420+
if (httpMethod != null) {
264421
return !operation.equals(httpMethod);
265422
}
266423
return true;

awsagentprovider/src/test/java/software/amazon/opentelemetry/javaagent/providers/AwsSpanMetricsProcessorTest.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515

1616
package software.amazon.opentelemetry.javaagent.providers;
1717

18+
import static io.opentelemetry.semconv.HttpAttributes.HTTP_REQUEST_METHOD;
1819
import static io.opentelemetry.semconv.HttpAttributes.HTTP_RESPONSE_STATUS_CODE;
20+
import static io.opentelemetry.semconv.UrlAttributes.URL_PATH;
1921
import static org.assertj.core.api.Assertions.assertThat;
2022
import static org.mockito.ArgumentMatchers.any;
2123
import static org.mockito.ArgumentMatchers.eq;
@@ -625,4 +627,41 @@ private void verifyHistogramRecords(
625627
verify(latencyHistogramMock, times(wantedDependencyMetricInvocation))
626628
.record(eq(TEST_LATENCY_MILLIS), eq(metricAttributesMap.get(DEPENDENCY_METRIC)));
627629
}
630+
631+
@Test
632+
public void testOnEndAppliesOperationPathSpanNameBeforeMetrics() {
633+
// Build a span with url.path that matches a configured operation path
634+
Attributes spanAttributes =
635+
Attributes.builder()
636+
.put(URL_PATH, "/api/users/42/stats")
637+
.put(HTTP_REQUEST_METHOD, "GET")
638+
.put(HTTP_RESPONSE_STATUS_CODE, 200L)
639+
.build();
640+
ReadableSpan readableSpanMock = buildReadableSpanMock(spanAttributes);
641+
642+
try (org.mockito.MockedStatic<AwsSpanProcessingUtil> utilStatic =
643+
org.mockito.Mockito.mockStatic(
644+
AwsSpanProcessingUtil.class,
645+
org.mockito.Mockito.withSettings()
646+
.defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS))) {
647+
utilStatic
648+
.when(AwsSpanProcessingUtil::getOperationPaths)
649+
.thenReturn(java.util.List.of("/api/users/{userId}/stats"));
650+
651+
// Capture the SpanData passed to the generator
652+
org.mockito.ArgumentCaptor<io.opentelemetry.sdk.trace.data.SpanData> spanCaptor =
653+
org.mockito.ArgumentCaptor.forClass(io.opentelemetry.sdk.trace.data.SpanData.class);
654+
Map<String, Attributes> metricAttributesMap =
655+
buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock.toSpanData());
656+
when(generatorMock.generateMetricAttributeMapFromSpan(any(), eq(testResource)))
657+
.thenReturn(metricAttributesMap);
658+
659+
awsSpanMetricsProcessor.onEnd(readableSpanMock);
660+
661+
// Verify the generator received a span with the overridden name
662+
verify(generatorMock)
663+
.generateMetricAttributeMapFromSpan(spanCaptor.capture(), eq(testResource));
664+
assertThat(spanCaptor.getValue().getName()).isEqualTo("GET /api/users/{userId}/stats");
665+
}
666+
}
628667
}

0 commit comments

Comments
 (0)