4343import io .opentelemetry .api .trace .SpanKind ;
4444import io .opentelemetry .sdk .common .InstrumentationScopeInfo ;
4545import io .opentelemetry .sdk .trace .ReadableSpan ;
46+ import io .opentelemetry .sdk .trace .data .DelegatingSpanData ;
4647import io .opentelemetry .sdk .trace .data .SpanData ;
4748import java .io .IOException ;
4849import java .io .InputStream ;
4950import java .util .ArrayList ;
51+ import java .util .Collections ;
5052import java .util .List ;
5153import 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 ;
0 commit comments