Skip to content

Commit 1d87cd7

Browse files
committed
Merge remote-tracking branch 'origin/main' into observability/tracing-attr/status-code
2 parents 11868c1 + 40ac6e4 commit 1d87cd7

File tree

10 files changed

+250
-3
lines changed

10 files changed

+250
-3
lines changed

sdk-platform-java/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 = httpJsonCallOptions.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()) {

sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import com.google.api.client.http.EmptyContent;
3333
import com.google.api.client.http.HttpRequest;
3434
import com.google.api.client.testing.http.MockHttpTransport;
35+
import com.google.api.gax.tracing.ApiTracer;
3536
import com.google.common.truth.Truth;
3637
import com.google.longrunning.ListOperationsRequest;
3738
import com.google.protobuf.Empty;
@@ -123,6 +124,32 @@ void testRequestUrl() throws IOException {
123124
Truth.assertThat(httpRequest.getUrl().toString()).isEqualTo(expectedUrl);
124125
}
125126

127+
@Test
128+
void testApiTracerRequestUrlResolved() throws IOException {
129+
ApiTracer tracer = Mockito.mock(ApiTracer.class);
130+
ApiMethodDescriptor<Field, Empty> methodDescriptor =
131+
ApiMethodDescriptor.<Field, Empty>newBuilder()
132+
.setFullMethodName("house.cat.get")
133+
.setHttpMethod(null)
134+
.setRequestFormatter(requestFormatter)
135+
.setResponseParser(responseParser)
136+
.build();
137+
138+
HttpRequestRunnable<Field, Empty> httpRequestRunnable =
139+
new HttpRequestRunnable<>(
140+
requestMessage,
141+
methodDescriptor,
142+
ENDPOINT,
143+
HttpJsonCallOptions.newBuilder().setTracer(tracer).build(),
144+
new MockHttpTransport(),
145+
HttpJsonMetadata.newBuilder().build(),
146+
(result) -> {});
147+
148+
httpRequestRunnable.createHttpRequest();
149+
String expectedUrl = ENDPOINT + "/name/feline" + "?food=bird&food=mouse&size=small";
150+
Mockito.verify(tracer).requestUrlResolved(expectedUrl);
151+
}
152+
126153
@Test
127154
void testRequestUrlUnnormalized() throws IOException {
128155
ApiMethodDescriptor<Field, Empty> methodDescriptor =

sdk-platform-java/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
@@ -196,6 +196,14 @@ default void requestSent() {}
196196
default void batchRequestSent(long elementCount, long requestSize) {}
197197
;
198198

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

sdk-platform-java/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
@@ -100,6 +100,9 @@ public class ObservabilityAttributes {
100100
/** The destination resource id of the request (e.g. projects/p/locations/l/topics/t). */
101101
public static final String DESTINATION_RESOURCE_ID_ATTRIBUTE = "gcp.resource.destination.id";
102102

103+
/** The full URL of the HTTP request, with sensitive query parameters redacted. */
104+
public static final String HTTP_URL_FULL_ATTRIBUTE = "url.full";
105+
103106
/** The type of error that occurred (e.g., from google.rpc.ErrorInfo.reason). */
104107
public static final String ERROR_TYPE_ATTRIBUTE = "error.type";
105108

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

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,17 @@
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 com.google.rpc.ErrorInfo;
3538
import io.opentelemetry.api.common.Attributes;
3639
import io.opentelemetry.api.common.AttributesBuilder;
3740
import java.util.Map;
3841
import java.util.concurrent.CancellationException;
3942
import javax.annotation.Nullable;
4043

41-
class ObservabilityUtils {
44+
final class ObservabilityUtils {
4245

4346
static Object extractStatus(@Nullable Throwable error, ApiTracerContext.Transport transport) {
4447
if (transport == ApiTracerContext.Transport.HTTP) {
@@ -47,6 +50,111 @@ static Object extractStatus(@Nullable Throwable error, ApiTracerContext.Transpor
4750
return extractGrpcStatus(error);
4851
}
4952

53+
/** Constant for redacted values. */
54+
private static final String REDACTED_VALUE = "REDACTED";
55+
56+
/**
57+
* A set of lowercase query parameter keys whose values should be redacted in URLs for
58+
* observability. These include direct credentials (access keys), cryptographic signatures (to
59+
* prevent replay attacks or leak of authorization), and session identifiers (like upload_id).
60+
*/
61+
private static final ImmutableSet<String> SENSITIVE_QUERY_KEYS =
62+
ImmutableSet.of(
63+
"awsaccesskeyid", // AWS S3-compatible access keys
64+
"signature", // General cryptographic signature
65+
"sig", // General cryptographic signature (abbreviated)
66+
"x-goog-signature", // Google Cloud specific signature
67+
"upload_id", // Resumable upload session identifiers
68+
"access_token", // OAuth2 explicit tokens
69+
"key", // API Keys
70+
"api_key"); // API Keys
71+
72+
/**
73+
* Sanitizes an HTTP URL by redacting sensitive query parameters and credentials in the user-info
74+
* component. If the provided URL cannot be parsed (e.g. invalid syntax), it returns the original
75+
* string.
76+
*
77+
* <p>This sanitization process conforms to the recommendations in footnote 3 of the OpenTelemetry
78+
* semantic conventions for HTTP URL attributes:
79+
* https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/
80+
*
81+
* <ul>
82+
* <li><i>"url.full MUST NOT contain credentials passed via URL in form of
83+
* https://user:pass@example.com/. In such case username and password SHOULD be redacted and
84+
* attribute's value SHOULD be https://REDACTED:REDACTED@example.com/."</i> - Handled by
85+
* stripping the raw user info component.
86+
* <li><i>"url.full SHOULD capture the absolute URL when it is available (or can be
87+
* reconstructed)."</i> - Handled by parsing and rebuilding the generic URI.
88+
* <li><i>"When a query string value is redacted, the query string key SHOULD still be
89+
* preserved, e.g. https://www.example.com/path?color=blue&sig=REDACTED."</i> - Handled by
90+
* the redactSensitiveQueryValues method.
91+
* </ul>
92+
*
93+
* @param url the raw URL string
94+
* @return the sanitized URL string, or the original if unparsable
95+
*/
96+
static String sanitizeUrlFull(final String url) {
97+
try {
98+
java.net.URI uri = new java.net.URI(url);
99+
String sanitizedUserInfo =
100+
uri.getRawUserInfo() != null ? REDACTED_VALUE + ":" + REDACTED_VALUE : null;
101+
String sanitizedQuery = redactSensitiveQueryValues(uri.getRawQuery());
102+
java.net.URI sanitizedUri =
103+
new java.net.URI(
104+
uri.getScheme(),
105+
sanitizedUserInfo,
106+
uri.getHost(),
107+
uri.getPort(),
108+
uri.getRawPath(),
109+
sanitizedQuery,
110+
uri.getRawFragment());
111+
return sanitizedUri.toString();
112+
} catch (java.net.URISyntaxException | IllegalArgumentException ex) {
113+
return "";
114+
}
115+
}
116+
117+
/**
118+
* Redacts the values of sensitive keys within a raw URI query string.
119+
*
120+
* <p>This logic splits the query string by the {@code &} delimiter without full URL decoding,
121+
* ensures only values belonging to predefined sensitive keys are replaced with {@code
122+
* REDACTED_VALUE}. The check is strictly case-insensitive.
123+
*
124+
* <p>Note regarding Footnote 3: The OpenTelemetry spec recommends case-sensitive matching for
125+
* query parameters. However, we intentionally utilize case-insensitive matching (by lowercasing
126+
* all query keys) to prevent credentials bypassing validation when sent with mixed casings (e.g.,
127+
* Sig=..., API_KEY=...).
128+
*
129+
* @param rawQuery the raw query string from a java.net.URI
130+
* @return a reconstructed query sequence with sensitive values redacted
131+
*/
132+
private static String redactSensitiveQueryValues(final String rawQuery) {
133+
if (rawQuery == null || rawQuery.isEmpty()) {
134+
return rawQuery;
135+
}
136+
137+
java.util.List<String> redactedParams =
138+
Splitter.on('&').splitToList(rawQuery).stream()
139+
.map(
140+
param -> {
141+
int equalsIndex = param.indexOf('=');
142+
if (equalsIndex < 0) {
143+
return param;
144+
}
145+
String key = param.substring(0, equalsIndex);
146+
// Case-insensitive match utilizing the fact that all
147+
// predefined keys are in lowercase
148+
if (SENSITIVE_QUERY_KEYS.contains(key.toLowerCase())) {
149+
return key + "=" + REDACTED_VALUE;
150+
}
151+
return param;
152+
})
153+
.collect(java.util.stream.Collectors.toList());
154+
155+
return Joiner.on('&').join(redactedParams);
156+
}
157+
50158
private static String extractGrpcStatus(@Nullable Throwable error) {
51159
final String statusString;
52160
if (error == null) {
@@ -67,7 +175,8 @@ private static Long extractHttpStatus(@Nullable Throwable error) {
67175
} else if (error instanceof ApiException) {
68176
Object transportCode = ((ApiException) error).getStatusCode().getTransportCode();
69177
// HttpJsonStatusCode.getTransportCode() returns an Integer (HTTP status code).
70-
// GrpcStatusCode returns a Status.Code enum, and FakeStatusCode (in tests) returns
178+
// GrpcStatusCode returns a Status.Code enum, and FakeStatusCode (in tests)
179+
// returns
71180
// a StatusCode.Code enum. If it's not an Integer, we fall back to the mapped
72181
// HTTP status code of the canonical code.
73182
if (transportCode instanceof Integer) {

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,16 @@ private void endAttempt(Throwable error) {
213213
attemptSpan = null;
214214
}
215215
}
216+
217+
@Override
218+
public void requestUrlResolved(String url) {
219+
if (attemptSpan == null) {
220+
return;
221+
}
222+
String sanitizedUrlString = ObservabilityUtils.sanitizeUrlFull(url);
223+
if (sanitizedUrlString.isEmpty()) {
224+
return;
225+
}
226+
attemptSpan.setAttribute(ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE, sanitizedUrlString);
227+
}
216228
}

sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/retrying/RetryAlgorithmTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ void testCreateFirstAttemptWithUnusedContext() {
6464
void testCreateFirstAttemptWithContext() {
6565
TimedRetryAlgorithmWithContext timedAlgorithm = mock(TimedRetryAlgorithmWithContext.class);
6666
RetryAlgorithm<Void> algorithm =
67-
new RetryAlgorithm<>(mock(ResultRetryAlgorithmWithContext.class), timedAlgorithm);
67+
new RetryAlgorithm<>(
68+
(ResultRetryAlgorithmWithContext<Void>) mock(ResultRetryAlgorithmWithContext.class),
69+
(TimedRetryAlgorithmWithContext) timedAlgorithm);
6870

6971
RetryingContext context = mock(RetryingContext.class);
7072
algorithm.createFirstAttempt(context);

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,43 @@ void testPopulateStatusAttributes_http_cancellationException() {
222222
void testToOtelAttributes_shouldReturnEmptyAttributes_nullInput() {
223223
assertThat(ObservabilityUtils.toOtelAttributes(null)).isEqualTo(Attributes.empty());
224224
}
225+
226+
@Test
227+
void testSanitizeUrlFull_redactsUserInfo() {
228+
String url = "https://user:password@example.com/some/path?foo=bar";
229+
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
230+
assertThat(sanitized).isEqualTo("https://REDACTED:REDACTED@example.com/some/path?foo=bar");
231+
}
232+
233+
@Test
234+
void testSanitizeUrlFull_redactsSensitiveQueryParameters_caseInsensitive() {
235+
String url =
236+
"https://example.com/some/path?upload_Id=secret&AWSAccessKeyId=123&foo=bar&API_KEY=my_key";
237+
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
238+
assertThat(sanitized)
239+
.isEqualTo(
240+
"https://example.com/some/path?upload_Id=REDACTED&AWSAccessKeyId=REDACTED&foo=bar&API_KEY=REDACTED");
241+
}
242+
243+
@Test
244+
void testSanitizeUrlFull_handlesKeyOnlyParameters() {
245+
String url = "https://example.com/some/path?api_key&foo=bar";
246+
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
247+
assertThat(sanitized).isEqualTo("https://example.com/some/path?api_key&foo=bar");
248+
}
249+
250+
@Test
251+
void testSanitizeUrlFull_handlesMalformedUrl() {
252+
String url = "invalid::url:";
253+
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
254+
// Unparsable URLs should be returned as empty string
255+
assertThat(sanitized).isEmpty();
256+
}
257+
258+
@Test
259+
void testSanitizeUrlFull_noQueryOrUserInfo() {
260+
String url = "https://example.com/some/path";
261+
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
262+
assertThat(sanitized).isEqualTo(url);
263+
}
225264
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
import static com.google.common.truth.Truth.assertThat;
3333
import static org.mockito.ArgumentMatchers.any;
3434
import static org.mockito.ArgumentMatchers.anyString;
35+
import static org.mockito.ArgumentMatchers.eq;
36+
import static org.mockito.Mockito.never;
3537
import static org.mockito.Mockito.verify;
3638
import static org.mockito.Mockito.when;
3739

@@ -372,4 +374,28 @@ void testAttemptStarted_retryAttributes_http() {
372374
ObservabilityAttributes.HTTP_RESEND_COUNT_ATTRIBUTE),
373375
5L);
374376
}
377+
378+
@Test
379+
void testRequestUrlResolved_setsAttribute() {
380+
spanTracer.attemptStarted(new Object(), 1);
381+
382+
String rawUrl = "https://example.com?api_key=secret";
383+
spanTracer.requestUrlResolved(rawUrl);
384+
385+
verify(span)
386+
.setAttribute(
387+
ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE,
388+
"https://example.com?api_key=REDACTED");
389+
}
390+
391+
@Test
392+
void testRequestUrlResolved_badUrl_notSet() {
393+
spanTracer.attemptStarted(new Object(), 1);
394+
395+
String rawUrl = "htps:::://the-example";
396+
spanTracer.requestUrlResolved(rawUrl);
397+
398+
verify(span, never())
399+
.setAttribute(eq(ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE), anyString());
400+
}
375401
}

sdk-platform-java/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelTracing.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import com.google.rpc.Status;
4747
import com.google.showcase.v1beta1.EchoClient;
4848
import com.google.showcase.v1beta1.EchoRequest;
49+
import com.google.showcase.v1beta1.EchoResponse;
4950
import com.google.showcase.v1beta1.EchoSettings;
5051
import com.google.showcase.v1beta1.it.util.TestClientInitializer;
5152
import com.google.showcase.v1beta1.stub.EchoStub;
@@ -69,6 +70,7 @@ class ITOtelTracing {
6970
private static final long SHOWCASE_SERVER_PORT = 7469;
7071
private static final String SHOWCASE_REPO = "googleapis/sdk-platform-java";
7172
private static final String SHOWCASE_ARTIFACT = "com.google.cloud:gapic-showcase";
73+
private static final String SHOWCASE_USER_URL = "http://localhost:7469/v1beta1/echo:echo";
7274

7375
private InMemorySpanExporter spanExporter;
7476
private OpenTelemetrySdk openTelemetrySdk;
@@ -206,6 +208,19 @@ void testTracing_successfulEcho_httpjson() throws Exception {
206208
.getAttributes()
207209
.get(AttributeKey.stringKey(ObservabilityAttributes.HTTP_URL_TEMPLATE_ATTRIBUTE)))
208210
.isEqualTo("v1beta1/echo:echo");
211+
assertThat(
212+
attemptSpan
213+
.getAttributes()
214+
.get(AttributeKey.stringKey(ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE)))
215+
.isEqualTo(SHOWCASE_USER_URL);
216+
EchoResponse fetchedEcho = EchoResponse.newBuilder().setContent("tracing-test").build();
217+
long expectedMagnitude = computeExpectedHttpJsonResponseSize(fetchedEcho);
218+
Long observedMagnitude =
219+
attemptSpan
220+
.getAttributes()
221+
.get(AttributeKey.longKey(ObservabilityAttributes.HTTP_RESPONSE_BODY_SIZE));
222+
assertThat(observedMagnitude).isNotNull();
223+
assertThat(observedMagnitude).isAtLeast((long) (expectedMagnitude * (1 - 0.15)));
209224
assertThat(attemptSpan.getInstrumentationScopeInfo().getName()).isEqualTo(SHOWCASE_ARTIFACT);
210225
}
211226
}

0 commit comments

Comments
 (0)