Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.GenericData;
import com.google.api.gax.tracing.ApiTracer;
import com.google.auth.Credentials;
import com.google.auth.http.HttpCredentialsAdapter;
import com.google.auto.value.AutoValue;
Expand Down Expand Up @@ -188,6 +189,11 @@ HttpRequest createHttpRequest() throws IOException {
}
}

ApiTracer tracer = httpJsonCallOptions.getTracer();
if (tracer != null) {
tracer.requestUrlResolved(url.build());
}

HttpRequest httpRequest = buildRequest(requestFactory, url, jsonHttpContent);

for (Map.Entry<String, Object> entry : headers.getHeaders().entrySet()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import com.google.api.client.http.EmptyContent;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.testing.http.MockHttpTransport;
import com.google.api.gax.tracing.ApiTracer;
import com.google.common.truth.Truth;
import com.google.longrunning.ListOperationsRequest;
import com.google.protobuf.Empty;
Expand Down Expand Up @@ -123,6 +124,32 @@ void testRequestUrl() throws IOException {
Truth.assertThat(httpRequest.getUrl().toString()).isEqualTo(expectedUrl);
}

@Test
void testApiTracerRequestUrlResolved() throws IOException {
ApiTracer tracer = Mockito.mock(ApiTracer.class);
ApiMethodDescriptor<Field, Empty> methodDescriptor =
ApiMethodDescriptor.<Field, Empty>newBuilder()
.setFullMethodName("house.cat.get")
.setHttpMethod(null)
.setRequestFormatter(requestFormatter)
.setResponseParser(responseParser)
.build();

HttpRequestRunnable<Field, Empty> httpRequestRunnable =
new HttpRequestRunnable<>(
requestMessage,
methodDescriptor,
ENDPOINT,
HttpJsonCallOptions.newBuilder().setTracer(tracer).build(),
new MockHttpTransport(),
HttpJsonMetadata.newBuilder().build(),
(result) -> {});

httpRequestRunnable.createHttpRequest();
String expectedUrl = ENDPOINT + "/name/feline" + "?food=bird&food=mouse&size=small";
Mockito.verify(tracer).requestUrlResolved(expectedUrl);
}

@Test
void testRequestUrlUnnormalized() throws IOException {
ApiMethodDescriptor<Field, Empty> methodDescriptor =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ default void requestSent() {}
default void batchRequestSent(long elementCount, long requestSize) {}
;

/**
* Annotates the attempt with the full resolved HTTP URL. Only relevant for HTTP transport.
*
* @param requestUrl the full URL of the request
*/
default void requestUrlResolved(String requestUrl) {}
;

/**
* A context class to be used with {@link #inScope()} and a try-with-resources block. Closing a
* {@link Scope} removes any context that the underlying implementation might've set in {@link
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,7 @@ public class ObservabilityAttributes {

/** The destination resource id of the request (e.g. projects/p/locations/l/topics/t). */
public static final String DESTINATION_RESOURCE_ID_ATTRIBUTE = "gcp.resource.destination.id";

/** The full URL of the HTTP request, with sensitive query parameters redacted. */
public static final String HTTP_URL_FULL_ATTRIBUTE = "url.full";
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,131 @@

import com.google.api.gax.rpc.ApiException;
import com.google.api.gax.rpc.StatusCode;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableSet;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributesBuilder;
import java.util.Map;
import java.util.concurrent.CancellationException;
import javax.annotation.Nullable;

class ObservabilityUtils {
final class ObservabilityUtils {

/** Function to extract the status of the error as a string */
static String extractStatus(@Nullable Throwable error) {
private ObservabilityUtils() {}

/** Constant for redacted values. */
private static final String REDACTED_VALUE = "REDACTED";

/**
* A set of lowercase query parameter keys whose values should be redacted in URLs for
* observability. These include direct credentials (access keys), cryptographic signatures (to
* prevent replay attacks or leak of authorization), and session identifiers (like upload_id).
*/
private static final ImmutableSet<String> SENSITIVE_QUERY_KEYS =
ImmutableSet.of(
"awsaccesskeyid", // AWS S3-compatible access keys
"signature", // General cryptographic signature
"sig", // General cryptographic signature (abbreviated)
"x-goog-signature", // Google Cloud specific signature
"upload_id", // Resumable upload session identifiers
"access_token", // OAuth2 explicit tokens
"key", // API Keys
"api_key"); // API Keys

/**
* Sanitizes an HTTP URL by redacting sensitive query parameters and credentials in the user-info
* component. If the provided URL cannot be parsed (e.g. invalid syntax), it returns the original
* string.
*
* <p>This sanitization process conforms to the recommendations in footnote 3 of the OpenTelemetry
* semantic conventions for HTTP URL attributes:
* https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/
*
* <ul>
* <li><i>"url.full MUST NOT contain credentials passed via URL in form of
* https://user:pass@example.com/. In such case username and password SHOULD be redacted and
* attribute's value SHOULD be https://REDACTED:REDACTED@example.com/."</i> - Handled by
* stripping the raw user info component.
* <li><i>"url.full SHOULD capture the absolute URL when it is available (or can be
* reconstructed)."</i> - Handled by parsing and rebuilding the generic URI.
* <li><i>"When a query string value is redacted, the query string key SHOULD still be
* preserved, e.g. https://www.example.com/path?color=blue&sig=REDACTED."</i> - Handled by
* the redactSensitiveQueryValues method.
* </ul>
*
* @param url the raw URL string
* @return the sanitized URL string, or the original if unparsable
*/
static String sanitizeUrlFull(final String url) {
try {
java.net.URI uri = new java.net.URI(url);
String sanitizedUserInfo =
uri.getRawUserInfo() != null ? REDACTED_VALUE + ":" + REDACTED_VALUE : null;
String sanitizedQuery = redactSensitiveQueryValues(uri.getRawQuery());
java.net.URI sanitizedUri =
new java.net.URI(
uri.getScheme(),
sanitizedUserInfo,
uri.getHost(),
uri.getPort(),
uri.getRawPath(),
sanitizedQuery,
uri.getRawFragment());
return sanitizedUri.toString();
} catch (java.net.URISyntaxException | IllegalArgumentException ex) {
return "";
}
}

/**
* Redacts the values of sensitive keys within a raw URI query string.
*
* <p>This logic splits the query string by the {@code &} delimiter without full URL decoding,
* ensures only values belonging to predefined sensitive keys are replaced with {@code
* REDACTED_VALUE}. The check is strictly case-insensitive.
*
* <p>Note regarding Footnote 3: The OpenTelemetry spec recommends case-sensitive matching for
* query parameters. However, we intentionally utilize case-insensitive matching (by lowercasing
* all query keys) to prevent credentials bypassing validation when sent with mixed casings (e.g.,
* Sig=..., API_KEY=...).
*
* @param rawQuery the raw query string from a java.net.URI
* @return a reconstructed query sequence with sensitive values redacted
*/
private static String redactSensitiveQueryValues(final String rawQuery) {
if (rawQuery == null || rawQuery.isEmpty()) {
return rawQuery;
}

java.util.List<String> redactedParams =
Splitter.on('&').splitToList(rawQuery).stream()
.map(
param -> {
int equalsIndex = param.indexOf('=');
if (equalsIndex < 0) {
return param;
}
String key = param.substring(0, equalsIndex);
// Case-insensitive match utilizing the fact that all
// predefined keys are in lowercase
if (SENSITIVE_QUERY_KEYS.contains(key.toLowerCase())) {
return key + "=" + REDACTED_VALUE;
}
return param;
})
.collect(java.util.stream.Collectors.toList());

return Joiner.on('&').join(redactedParams);
}

/**
* Function to extract the status of the error as a string.
*
* @param error the thrown throwable error
* @return the extracted status string
*/
static String extractStatus(@Nullable final Throwable error) {
final String statusString;

if (error == null) {
Expand All @@ -56,7 +171,7 @@ static String extractStatus(@Nullable Throwable error) {
return statusString;
}

static Attributes toOtelAttributes(Map<String, Object> attributes) {
static Attributes toOtelAttributes(final Map<String, Object> attributes) {
AttributesBuilder attributesBuilder = Attributes.builder();
if (attributes == null) {
return attributesBuilder.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,16 @@ private void endAttempt() {
attemptSpan = null;
}
}

@Override
public void requestUrlResolved(String url) {
if (attemptSpan == null) {
return;
}
String sanitizedUrlString = ObservabilityUtils.sanitizeUrlFull(url);
if (sanitizedUrlString.isEmpty()) {
return;
}
attemptSpan.setAttribute(ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE, sanitizedUrlString);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ void testCreateFirstAttemptWithUnusedContext() {
void testCreateFirstAttemptWithContext() {
TimedRetryAlgorithmWithContext timedAlgorithm = mock(TimedRetryAlgorithmWithContext.class);
RetryAlgorithm<Void> algorithm =
new RetryAlgorithm<>(mock(ResultRetryAlgorithmWithContext.class), timedAlgorithm);
new RetryAlgorithm<>(
(ResultRetryAlgorithmWithContext<Void>) mock(ResultRetryAlgorithmWithContext.class),
(TimedRetryAlgorithmWithContext) timedAlgorithm);

RetryingContext context = mock(RetryingContext.class);
algorithm.createFirstAttempt(context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,43 @@ void testToOtelAttributes_shouldMapIntAttributes() {
void testToOtelAttributes_shouldReturnEmptyAttributes_nullInput() {
assertThat(ObservabilityUtils.toOtelAttributes(null)).isEqualTo(Attributes.empty());
}

@Test
void testSanitizeUrlFull_redactsUserInfo() {
String url = "https://user:password@example.com/some/path?foo=bar";
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
assertThat(sanitized).isEqualTo("https://REDACTED:REDACTED@example.com/some/path?foo=bar");
}

@Test
void testSanitizeUrlFull_redactsSensitiveQueryParameters_caseInsensitive() {
String url =
"https://example.com/some/path?upload_Id=secret&AWSAccessKeyId=123&foo=bar&API_KEY=my_key";
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
assertThat(sanitized)
.isEqualTo(
"https://example.com/some/path?upload_Id=REDACTED&AWSAccessKeyId=REDACTED&foo=bar&API_KEY=REDACTED");
}

@Test
void testSanitizeUrlFull_handlesKeyOnlyParameters() {
String url = "https://example.com/some/path?api_key&foo=bar";
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
assertThat(sanitized).isEqualTo("https://example.com/some/path?api_key&foo=bar");
}

@Test
void testSanitizeUrlFull_handlesMalformedUrl() {
String url = "invalid::url:";
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
// Unparsable URLs should be returned as empty string
assertThat(sanitized).isEmpty();
}

@Test
void testSanitizeUrlFull_noQueryOrUserInfo() {
String url = "https://example.com/some/path";
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
assertThat(sanitized).isEqualTo(url);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

Expand Down Expand Up @@ -246,4 +248,28 @@ void testAttemptStarted_retryAttributes_http() {
ObservabilityAttributes.HTTP_RESEND_COUNT_ATTRIBUTE),
5L);
}

@Test
void testRequestUrlResolved_setsAttribute() {
spanTracer.attemptStarted(new Object(), 1);

String rawUrl = "https://example.com?api_key=secret";
spanTracer.requestUrlResolved(rawUrl);

verify(span)
.setAttribute(
ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE,
"https://example.com?api_key=REDACTED");
}

@Test
void testRequestUrlResolved_badUrl_notSet() {
spanTracer.attemptStarted(new Object(), 1);

String rawUrl = "htps:::://the-example";
spanTracer.requestUrlResolved(rawUrl);

verify(span, never())
.setAttribute(eq(ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE), anyString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import com.google.showcase.v1beta1.EchoClient;
import com.google.showcase.v1beta1.EchoRequest;
import com.google.showcase.v1beta1.EchoSettings;
import com.google.showcase.v1beta1.User;
import com.google.showcase.v1beta1.it.util.TestClientInitializer;
import com.google.showcase.v1beta1.stub.EchoStub;
import com.google.showcase.v1beta1.stub.EchoStubSettings;
Expand All @@ -69,6 +70,7 @@ class ITOtelTracing {
private static final long SHOWCASE_SERVER_PORT = 7469;
private static final String SHOWCASE_REPO = "googleapis/sdk-platform-java";
private static final String SHOWCASE_ARTIFACT = "com.google.cloud:gapic-showcase";
private static final String SHOWCASE_USER_URL = "http://localhost:7469/v1beta1/users/";

private InMemorySpanExporter spanExporter;
private OpenTelemetrySdk openTelemetrySdk;
Expand Down Expand Up @@ -170,6 +172,7 @@ void testTracing_successfulEcho_httpjson() throws Exception {
.findFirst()
.orElseThrow(
() -> new AssertionError("Attempt span 'POST v1beta1/echo:echo' not found"));
assertThat(attemptSpan.getInstrumentationScopeInfo().getName()).isEqualTo(SHOWCASE_ARTIFACT);
assertThat(attemptSpan.getKind()).isEqualTo(SpanKind.CLIENT);
assertThat(
attemptSpan
Expand Down Expand Up @@ -205,8 +208,28 @@ void testTracing_successfulEcho_httpjson() throws Exception {
attemptSpan
.getAttributes()
.get(AttributeKey.stringKey(ObservabilityAttributes.HTTP_URL_TEMPLATE_ATTRIBUTE)))
.isEqualTo("v1beta1/echo:echo");
assertThat(attemptSpan.getInstrumentationScopeInfo().getName()).isEqualTo(SHOWCASE_ARTIFACT);
.isEqualTo("v1beta1/{name=users/*}");
assertThat(
attemptSpan
.getAttributes()
.get(AttributeKey.stringKey(ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE)))
.isEqualTo(SHOWCASE_USER_URL + "test-user");
assertThat(
attemptSpan
.getAttributes()
.get(
AttributeKey.stringKey(
ObservabilityAttributes.DESTINATION_RESOURCE_ID_ATTRIBUTE)))
.isEqualTo("users/test-user");

User fetchedUser = User.newBuilder().setName("users/test-user").build();
long expectedMagnitude = computeExpectedHttpJsonResponseSize(fetchedUser);
Long observedMagnitude =
attemptSpan
.getAttributes()
.get(AttributeKey.longKey(ObservabilityAttributes.HTTP_RESPONSE_BODY_SIZE));
assertThat(observedMagnitude).isNotNull();
assertThat(observedMagnitude).isAtLeast((long) (expectedMagnitude * (1 - 0.15)));
}
}

Expand Down
Loading