Skip to content

Commit 5e9eb66

Browse files
committed
feat: capture HTTP full URL in traces
- Adds HTTP_URL_FULL_ATTRIBUTE to ObservabilityAttributes - Introduces requestUrlResolved into ApiTracer - Implements redaction logic in ObservabilityUtils - Passes tracer into HTTP/JSON transport to record the URL
1 parent adb06fe commit 5e9eb66

9 files changed

Lines changed: 173 additions & 1 deletion

File tree

gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallOptions.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import static com.google.api.gax.util.TimeConversionUtils.toThreetenInstant;
3636

3737
import com.google.api.core.ObsoleteApi;
38+
import com.google.api.gax.tracing.ApiTracer;
3839
import com.google.auth.Credentials;
3940
import com.google.auto.value.AutoValue;
4041
import com.google.protobuf.TypeRegistry;
@@ -71,6 +72,9 @@ public final org.threeten.bp.Instant getDeadline() {
7172
@Nullable
7273
public abstract TypeRegistry getTypeRegistry();
7374

75+
@Nullable
76+
public abstract ApiTracer getTracer();
77+
7478
public abstract Builder toBuilder();
7579

7680
public static Builder newBuilder() {
@@ -106,6 +110,11 @@ public HttpJsonCallOptions merge(HttpJsonCallOptions inputOptions) {
106110
builder.setTypeRegistry(newTypeRegistry);
107111
}
108112

113+
ApiTracer newTracer = inputOptions.getTracer();
114+
if (newTracer != null) {
115+
builder.setTracer(newTracer);
116+
}
117+
109118
return builder.build();
110119
}
111120

@@ -131,6 +140,8 @@ public final Builder setDeadline(org.threeten.bp.Instant value) {
131140

132141
public abstract Builder setTypeRegistry(TypeRegistry value);
133142

143+
public abstract Builder setTracer(ApiTracer value);
144+
134145
public abstract HttpJsonCallOptions build();
135146
}
136147
}

gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCalls.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,13 @@ public static <RequestT, ResponseT> HttpJsonClientCall<RequestT, ResponseT> newC
7676
// if the Universe Domain is valid.
7777
httpJsonContext.validateUniverseDomain();
7878

79+
HttpJsonCallOptions callOptions = httpJsonContext.getCallOptions();
80+
if (httpJsonContext.getTracer() != null) {
81+
callOptions = callOptions.toBuilder().setTracer(httpJsonContext.getTracer()).build();
82+
}
83+
7984
// TODO: add headers interceptor logic
80-
return httpJsonContext.getChannel().newCall(methodDescriptor, httpJsonContext.getCallOptions());
85+
return httpJsonContext.getChannel().newCall(methodDescriptor, callOptions);
8186
}
8287

8388
static <RequestT, ResponseT> ApiFuture<ResponseT> futureUnaryCall(

gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import com.google.api.client.json.JsonObjectParser;
4545
import com.google.api.client.json.gson.GsonFactory;
4646
import com.google.api.client.util.GenericData;
47+
import com.google.api.gax.tracing.ApiTracer;
4748
import com.google.auth.Credentials;
4849
import com.google.auth.http.HttpCredentialsAdapter;
4950
import com.google.auto.value.AutoValue;
@@ -188,6 +189,11 @@ HttpRequest createHttpRequest() throws IOException {
188189
}
189190
}
190191

192+
ApiTracer tracer = callOptions.getTracer();
193+
if (tracer != null) {
194+
tracer.requestUrlResolved(url.build());
195+
}
196+
191197
HttpRequest httpRequest = buildRequest(requestFactory, url, jsonHttpContent);
192198

193199
for (Map.Entry<String, Object> entry : headers.getHeaders().entrySet()) {

gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracer.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,14 @@ default void requestSent() {}
192192
default void batchRequestSent(long elementCount, long requestSize) {}
193193
;
194194

195+
/**
196+
* Annotates the attempt with the full resolved HTTP URL. Only relevant for HTTP transport.
197+
*
198+
* @param requestUrl the full URL of the request
199+
*/
200+
default void requestUrlResolved(String requestUrl) {}
201+
;
202+
195203
/**
196204
* A context class to be used with {@link #inScope()} and a try-with-resources block. Closing a
197205
* {@link Scope} removes any context that the underlying implementation might've set in {@link

gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,7 @@ public class ObservabilityAttributes {
9090

9191
/** The resend count of the request. Only used in gRPC transport. */
9292
public static final String GRPC_RESEND_COUNT_ATTRIBUTE = "gcp.grpc.resend_count";
93+
94+
/** The full URL of the HTTP request, with sensitive query parameters redacted. */
95+
public static final String HTTP_URL_FULL_ATTRIBUTE = "url.full";
9396
}

gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131

3232
import com.google.api.gax.rpc.ApiException;
3333
import com.google.api.gax.rpc.StatusCode;
34+
import com.google.common.base.Joiner;
35+
import com.google.common.base.Splitter;
36+
import com.google.common.collect.ImmutableSet;
3437
import io.opentelemetry.api.common.Attributes;
3538
import io.opentelemetry.api.common.AttributesBuilder;
3639
import java.util.Map;
@@ -39,6 +42,89 @@
3942

4043
class ObservabilityUtils {
4144

45+
private static final String REDACTED_VALUE = "REDACTED";
46+
47+
/**
48+
* A set of lowercase query parameter keys whose values should be redacted in URLs for
49+
* observability. These include direct credentials (access keys), cryptographic signatures (to
50+
* prevent replay attacks or leak of authorization), and session identifiers (like upload_id).
51+
*
52+
* <p>This is a curated list that aligns with cross-SDK standards for protecting sensitive URL
53+
* parameters from being recorded in distributed traces or logs.
54+
*/
55+
private static final ImmutableSet<String> SENSITIVE_QUERY_KEYS =
56+
ImmutableSet.of(
57+
"awsaccesskeyid", // AWS S3-compatible access keys
58+
"signature", // General cryptographic signature
59+
"sig", // General cryptographic signature (abbreviated)
60+
"x-goog-signature", // Google Cloud specific signature
61+
"upload_id", // Resumable upload session identifiers
62+
"access_token", // OAuth2 explicit tokens
63+
"key", // API Keys
64+
"api_key"); // API Keys
65+
66+
/**
67+
* Sanitizes an HTTP URL by redacting sensitive query parameters and credentials in the user-info
68+
* component. If the provided URL cannot be parsed (e.g. invalid syntax), it gracefully returns
69+
* the original string.
70+
*
71+
* @param url the raw URL string
72+
* @return the sanitized URL string, or the original if unparsable
73+
*/
74+
static String sanitizeUrlFull(String url) {
75+
try {
76+
java.net.URI uri = new java.net.URI(url);
77+
String sanitizedUserInfo =
78+
uri.getRawUserInfo() != null ? REDACTED_VALUE + ":" + REDACTED_VALUE : null;
79+
String sanitizedQuery = redactSensitiveQueryValues(uri.getRawQuery());
80+
java.net.URI sanitizedUri =
81+
new java.net.URI(
82+
uri.getScheme(),
83+
sanitizedUserInfo,
84+
uri.getHost(),
85+
uri.getPort(),
86+
uri.getRawPath(),
87+
sanitizedQuery,
88+
uri.getRawFragment());
89+
return sanitizedUri.toString();
90+
} catch (java.net.URISyntaxException | IllegalArgumentException ex) {
91+
return url;
92+
}
93+
}
94+
95+
/**
96+
* Redacts the values of sensitive keys within a raw URI query string.
97+
*
98+
* <p>This logic splits the query string by the `&` delimiter without full URL decoding, ensures
99+
* only values belonging to predefined sensitive keys are replaced with {@code REDACTED_VALUE}.
100+
* The check is strictly case-insensitive.
101+
*
102+
* @param rawQuery the raw query string from a java.net.URI (e.g., "key1=value1&key2=value2")
103+
* @return a reconstructed query sequence with sensitive values redacted
104+
*/
105+
private static String redactSensitiveQueryValues(String rawQuery) {
106+
if (rawQuery == null || rawQuery.isEmpty()) {
107+
return rawQuery;
108+
}
109+
110+
java.util.List<String> redactedParams =
111+
Splitter.on('&').splitToList(rawQuery).stream()
112+
.map(
113+
param -> {
114+
int equalsIndex = param.indexOf('=');
115+
String key = equalsIndex >= 0 ? param.substring(0, equalsIndex) : param;
116+
// Case-insensitive match utilizing the fact that all predefined keys are in
117+
// lowercase
118+
if (SENSITIVE_QUERY_KEYS.contains(key.toLowerCase(java.util.Locale.US))) {
119+
return key + "=" + REDACTED_VALUE;
120+
}
121+
return param;
122+
})
123+
.collect(java.util.stream.Collectors.toList());
124+
125+
return Joiner.on('&').join(redactedParams);
126+
}
127+
42128
/** Function to extract the status of the error as a string */
43129
static String extractStatus(@Nullable Throwable error) {
44130
final String statusString;

gax-java/gax/src/main/java/com/google/api/gax/tracing/SpanTracer.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,12 @@ private void endAttempt() {
160160
attemptSpan = null;
161161
}
162162
}
163+
164+
@Override
165+
public void requestUrlResolved(String url) {
166+
if (attemptSpan != null && url != null) {
167+
attemptSpan.setAttribute(
168+
ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE, ObservabilityUtils.sanitizeUrlFull(url));
169+
}
170+
}
163171
}

gax-java/gax/src/test/java/com/google/api/gax/tracing/ObservabilityUtilsTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,36 @@ void testToOtelAttributes_shouldMapIntAttributes() {
118118
void testToOtelAttributes_shouldReturnEmptyAttributes_nullInput() {
119119
assertThat(ObservabilityUtils.toOtelAttributes(null)).isEqualTo(Attributes.empty());
120120
}
121+
122+
@Test
123+
void testSanitizeUrlFull_redactsUserInfo() {
124+
String url = "https://user:password@example.com/some/path?foo=bar";
125+
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
126+
assertThat(sanitized).isEqualTo("https://REDACTED:REDACTED@example.com/some/path?foo=bar");
127+
}
128+
129+
@Test
130+
void testSanitizeUrlFull_redactsSensitiveQueryParameters_caseInsensitive() {
131+
String url =
132+
"https://example.com/some/path?upload_Id=secret&AWSAccessKeyId=123&foo=bar&API_KEY=my_key";
133+
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
134+
assertThat(sanitized)
135+
.isEqualTo(
136+
"https://example.com/some/path?upload_Id=REDACTED&AWSAccessKeyId=REDACTED&foo=bar&API_KEY=REDACTED");
137+
}
138+
139+
@Test
140+
void testSanitizeUrlFull_handlesMalformedUrl() {
141+
String url = "invalid::url:";
142+
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
143+
// Unparsable URLs should be returned as-is
144+
assertThat(sanitized).isEqualTo(url);
145+
}
146+
147+
@Test
148+
void testSanitizeUrlFull_noQueryOrUserInfo() {
149+
String url = "https://example.com/some/path";
150+
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
151+
assertThat(sanitized).isEqualTo(url);
152+
}
121153
}

gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,17 @@ void testAttemptStarted_retryAttributes_http() {
186186
ObservabilityAttributes.HTTP_RESEND_COUNT_ATTRIBUTE),
187187
5L);
188188
}
189+
190+
@Test
191+
void testRequestUrlResolved_setsAttribute() {
192+
spanTracer.attemptStarted(new Object(), 1);
193+
194+
String rawUrl = "https://example.com?api_key=secret";
195+
spanTracer.requestUrlResolved(rawUrl);
196+
197+
verify(span)
198+
.setAttribute(
199+
ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE,
200+
"https://example.com?api_key=REDACTED");
201+
}
189202
}

0 commit comments

Comments
 (0)