diff --git a/google-http-client/pom.xml b/google-http-client/pom.xml index 69bf450f4..81414b925 100644 --- a/google-http-client/pom.xml +++ b/google-http-client/pom.xml @@ -175,7 +175,14 @@ com.google.j2objc j2objc-annotations - 1.1 + + + io.opencensus + opencensus-api + + + io.opencensus + opencensus-contrib-http-util diff --git a/google-http-client/src/main/java/com/google/api/client/http/HttpRequest.java b/google-http-client/src/main/java/com/google/api/client/http/HttpRequest.java index 856666a68..1c68d9036 100644 --- a/google-http-client/src/main/java/com/google/api/client/http/HttpRequest.java +++ b/google-http-client/src/main/java/com/google/api/client/http/HttpRequest.java @@ -18,11 +18,18 @@ import com.google.api.client.util.IOUtils; import com.google.api.client.util.LoggingStreamingContent; import com.google.api.client.util.ObjectParser; +import com.google.api.client.util.OpenCensusUtils; import com.google.api.client.util.Preconditions; import com.google.api.client.util.Sleeper; import com.google.api.client.util.StreamingContent; import com.google.api.client.util.StringUtils; +import io.opencensus.common.Scope; +import io.opencensus.contrib.http.util.HttpTraceAttributeConstants; +import io.opencensus.trace.AttributeValue; +import io.opencensus.trace.Span; +import io.opencensus.trace.Tracer; + import java.io.IOException; import java.io.InputStream; import java.util.concurrent.Callable; @@ -215,6 +222,9 @@ static String executeAndGetValueOfSomeCustomHeader(HttpRequest request) { /** Sleeper. */ private Sleeper sleeper = Sleeper.DEFAULT; + /** OpenCensus tracing component. */ + private Tracer tracer = OpenCensusUtils.getTracer(); + /** * @param transport HTTP transport * @param requestMethod HTTP request method or {@code null} for none @@ -883,7 +893,12 @@ public HttpResponse execute() throws IOException { Preconditions.checkNotNull(requestMethod); Preconditions.checkNotNull(url); + Span span = tracer + .spanBuilder(OpenCensusUtils.SPAN_NAME_HTTP_REQUEST_EXECUTE) + .setRecordEvents(OpenCensusUtils.isRecordEvent()) + .startSpan(); do { + span.addAnnotation("retry #" + (numRetries - retriesRemaining)); // Cleanup any unneeded response from a previous iteration if (response != null) { response.ignore(); @@ -898,6 +913,11 @@ public HttpResponse execute() throws IOException { } // build low-level HTTP request String urlString = url.build(); + addSpanAttribute(span, HttpTraceAttributeConstants.HTTP_METHOD, requestMethod); + addSpanAttribute(span, HttpTraceAttributeConstants.HTTP_HOST, url.getHost()); + addSpanAttribute(span, HttpTraceAttributeConstants.HTTP_PATH, url.getRawPath()); + addSpanAttribute(span, HttpTraceAttributeConstants.HTTP_URL, urlString); + LowLevelHttpRequest lowLevelHttpRequest = transport.buildRequest(requestMethod, urlString); Logger logger = HttpTransport.LOGGER; boolean loggable = loggingEnabled && logger.isLoggable(Level.CONFIG); @@ -923,10 +943,15 @@ public HttpResponse execute() throws IOException { if (!suppressUserAgentSuffix) { if (originalUserAgent == null) { headers.setUserAgent(USER_AGENT_SUFFIX); + addSpanAttribute(span, HttpTraceAttributeConstants.HTTP_USER_AGENT, USER_AGENT_SUFFIX); } else { - headers.setUserAgent(originalUserAgent + " " + USER_AGENT_SUFFIX); + String newUserAgent = originalUserAgent + " " + USER_AGENT_SUFFIX; + headers.setUserAgent(newUserAgent); + addSpanAttribute(span, HttpTraceAttributeConstants.HTTP_USER_AGENT, newUserAgent); } } + OpenCensusUtils.propagateTracingContext(span, headers); + // headers HttpHeaders.serializeHeaders(headers, logbuf, curlbuf, logger, lowLevelHttpRequest); if (!suppressUserAgentSuffix) { @@ -1007,8 +1032,16 @@ public HttpResponse execute() throws IOException { // execute lowLevelHttpRequest.setTimeout(connectTimeout, readTimeout); lowLevelHttpRequest.setWriteTimeout(writeTimeout); + + // switch tracing scope to current span + @SuppressWarnings("MustBeClosedChecker") + Scope ws = tracer.withSpan(span); + OpenCensusUtils.recordSentMessageEvent(span, lowLevelHttpRequest.getContentLength()); try { LowLevelHttpResponse lowLevelHttpResponse = lowLevelHttpRequest.execute(); + if (lowLevelHttpResponse != null) { + OpenCensusUtils.recordReceivedMessageEvent(span, lowLevelHttpResponse.getContentLength()); + } // Flag used to indicate if an exception is thrown before the response is constructed. boolean responseConstructed = false; try { @@ -1032,6 +1065,8 @@ public HttpResponse execute() throws IOException { if (loggable) { logger.log(Level.WARNING, "exception thrown while executing request", e); } + } finally { + ws.close(); } // Flag used to indicate if an exception is thrown before the response has completed @@ -1087,6 +1122,7 @@ public HttpResponse execute() throws IOException { } } } while (retryRequest); + span.end(OpenCensusUtils.getEndSpanOptions(response == null ? null : response.getStatusCode())); if (response == null) { // Retries did not help resolve the execute exception, re-throw it. @@ -1201,4 +1237,10 @@ public HttpRequest setSleeper(Sleeper sleeper) { this.sleeper = Preconditions.checkNotNull(sleeper); return this; } + + private static void addSpanAttribute(Span span, String key, String value) { + if (value != null) { + span.putAttribute(key, AttributeValue.stringAttributeValue(value)); + } + } } diff --git a/google-http-client/src/main/java/com/google/api/client/util/OpenCensusUtils.java b/google-http-client/src/main/java/com/google/api/client/util/OpenCensusUtils.java new file mode 100644 index 000000000..c2a2cc000 --- /dev/null +++ b/google-http-client/src/main/java/com/google/api/client/util/OpenCensusUtils.java @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2018 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.api.client.util; + +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpStatusCodes; +import com.google.common.annotations.VisibleForTesting; + +import io.opencensus.contrib.http.util.HttpPropagationUtil; +import io.opencensus.trace.BlankSpan; +import io.opencensus.trace.EndSpanOptions; +import io.opencensus.trace.NetworkEvent; +import io.opencensus.trace.NetworkEvent.Type; +import io.opencensus.trace.Span; +import io.opencensus.trace.Status; +import io.opencensus.trace.Tracer; +import io.opencensus.trace.Tracing; +import io.opencensus.trace.propagation.TextFormat; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; + +/** + * Utilities for Census monitoring and tracing. + * + * @author Hailong Wen + * @since 1.24 + */ +public class OpenCensusUtils { + + private static final Logger logger = Logger.getLogger(OpenCensusUtils.class.getName()); + + /** + * Span name for tracing {@link HttpRequest#execute()}. + */ + public static final String SPAN_NAME_HTTP_REQUEST_EXECUTE = + "Sent." + HttpRequest.class.getName() + ".execute"; + + /** + * OpenCensus tracing component. When no OpenCensus implementation is provided, it will return a + * no-op tracer. + */ + private static Tracer tracer = Tracing.getTracer(); + + /** + * Sequence id generator for message event. + */ + private static AtomicLong idGenerator = new AtomicLong(); + + /** + * Whether spans should be recorded locally. Defaults to true. + */ + private static volatile boolean isRecordEvent = true; + + /** + * {@link TextFormat} used in tracing context propagation. + */ + @Nullable + @VisibleForTesting + static volatile TextFormat propagationTextFormat = null; + + /** + * {@link TextFormat.Setter} for {@link #propagationTextFormat}. + */ + @Nullable + @VisibleForTesting + static volatile TextFormat.Setter propagationTextFormatSetter = null; + + /** + * Sets the {@link TextFormat} used in context propagation. + * + *

This API allows users of google-http-client to specify other text format, or disable context + * propagation by setting it to {@code null}. It should be used along with {@link + * #setPropagationTextFormatSetter} for setting purpose.

+ * + * @param textFormat the text format. + */ + public static void setPropagationTextFormat(@Nullable TextFormat textFormat) { + propagationTextFormat = textFormat; + } + + /** + * Sets the {@link TextFormat.Setter} used in context propagation. + * + *

This API allows users of google-http-client to specify other text format setter, or disable + * context propagation by setting it to {@code null}. It should be used along with {@link + * #setPropagationTextFormat} for setting purpose.

+ * + * @param textFormatSetter the {@code TextFormat.Setter} for the text format. + */ + public static void setPropagationTextFormatSetter(@Nullable TextFormat.Setter textFormatSetter) { + propagationTextFormatSetter = textFormatSetter; + } + + /** + * Sets whether spans should be recorded locally. + * + *

This API allows users of google-http-client to turn on/off local span collection.

+ * + * @param recordEvent record span locally if true. + */ + public static void setIsRecordEvent(boolean recordEvent) { + isRecordEvent = recordEvent; + } + + /** + * Returns the tracing component of OpenCensus. + * + * @return the tracing component of OpenCensus. + */ + public static Tracer getTracer() { + return tracer; + } + + /** + * Returns whether spans should be recorded locally. + * + * @return whether spans should be recorded locally. + */ + public static boolean isRecordEvent() { + return isRecordEvent; + } + + /** + * Propagate information of current tracing context. This information will be injected into HTTP + * header. + * + * @param span the span to be propagated. + * @param headers the headers used in propagation. + */ + public static void propagateTracingContext(Span span, HttpHeaders headers) { + Preconditions.checkArgument(span != null, "span should not be null."); + Preconditions.checkArgument(headers != null, "headers should not be null."); + if (propagationTextFormat != null && propagationTextFormatSetter != null) { + if (!span.equals(BlankSpan.INSTANCE)) { + propagationTextFormat.inject(span.getContext(), headers, propagationTextFormatSetter); + } + } + } + + /** + * Returns an {@link EndSpanOptions} to end a http span according to the status code. + * + * @param statusCode the status code, can be null to represent no valid response is returned. + * @return an {@code EndSpanOptions} that best suits the status code. + */ + public static EndSpanOptions getEndSpanOptions(@Nullable Integer statusCode) { + // Always sample the span, but optionally export it. + EndSpanOptions.Builder builder = EndSpanOptions.builder(); + if (statusCode == null) { + builder.setStatus(Status.UNKNOWN); + } else if (!HttpStatusCodes.isSuccess(statusCode)) { + switch (statusCode) { + case HttpStatusCodes.STATUS_CODE_BAD_REQUEST: + builder.setStatus(Status.INVALID_ARGUMENT); + break; + case HttpStatusCodes.STATUS_CODE_UNAUTHORIZED: + builder.setStatus(Status.UNAUTHENTICATED); + break; + case HttpStatusCodes.STATUS_CODE_FORBIDDEN: + builder.setStatus(Status.PERMISSION_DENIED); + break; + case HttpStatusCodes.STATUS_CODE_NOT_FOUND: + builder.setStatus(Status.NOT_FOUND); + break; + case HttpStatusCodes.STATUS_CODE_PRECONDITION_FAILED: + builder.setStatus(Status.FAILED_PRECONDITION); + break; + case HttpStatusCodes.STATUS_CODE_SERVER_ERROR: + builder.setStatus(Status.UNAVAILABLE); + break; + default: + builder.setStatus(Status.UNKNOWN); + } + } else { + builder.setStatus(Status.OK); + } + return builder.build(); + } + + /** + * Records a new message event which contains the size of the request content. Note that the size + * represents the message size in application layer, i.e., content-length. + * + * @param span The {@code span} in which the send event occurs. + * @param size Size of the request. + */ + public static void recordSentMessageEvent(Span span, long size) { + recordMessageEvent(span, size, Type.SENT); + } + + /** + * Records a new message event which contains the size of the response content. Note that the size + * represents the message size in application layer, i.e., content-length. + * + * @param span The {@code span} in which the receive event occurs. + * @param size Size of the response. + */ + public static void recordReceivedMessageEvent(Span span, long size) { + recordMessageEvent(span, size, Type.RECV); + } + + /** + * Records a message event of a certain {@link NetworkEvent.Type}. This method is package + * protected since {@link NetworkEvent} might be deprecated in future releases. + * + * @param span The {@code span} in which the event occurs. + * @param size Size of the message. + * @param eventType The {@code NetworkEvent.Type} of the message event. + */ + @VisibleForTesting + static void recordMessageEvent(Span span, long size, Type eventType) { + Preconditions.checkArgument(span != null, "span should not be null."); + if (size < 0) { + size = 0; + } + NetworkEvent event = NetworkEvent + .builder(eventType, idGenerator.getAndIncrement()) + .setUncompressedMessageSize(size) + .build(); + span.addNetworkEvent(event); + } + + static { + try { + propagationTextFormat = HttpPropagationUtil.getCloudTraceFormat(); + propagationTextFormatSetter = new TextFormat.Setter() { + @Override + public void put(HttpHeaders carrier, String key, String value) { + carrier.set(key, value); + } + }; + } catch (Exception e) { + logger.log( + Level.WARNING, "Cannot initialize default OpenCensus HTTP propagation text format.", e); + } + + try { + Tracing.getExportComponent().getSampledSpanStore().registerSpanNamesForCollection( + Collections.singletonList(SPAN_NAME_HTTP_REQUEST_EXECUTE)); + } catch (Exception e) { + logger.log( + Level.WARNING, "Cannot register default OpenCensus span names for collection.", e); + } + } + + private OpenCensusUtils() {} +} diff --git a/google-http-client/src/test/java/com/google/api/client/util/OpenCensusUtilsTest.java b/google-http-client/src/test/java/com/google/api/client/util/OpenCensusUtilsTest.java new file mode 100644 index 000000000..069b051ec --- /dev/null +++ b/google-http-client/src/test/java/com/google/api/client/util/OpenCensusUtilsTest.java @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2018 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.api.client.util; + +import com.google.api.client.http.HttpHeaders; + +import io.opencensus.trace.BlankSpan; +import io.opencensus.trace.EndSpanOptions; +import io.opencensus.trace.Annotation; +import io.opencensus.trace.AttributeValue; +import io.opencensus.trace.Link; +import io.opencensus.trace.NetworkEvent; +import io.opencensus.trace.Span; +import io.opencensus.trace.SpanContext; +import io.opencensus.trace.Status; +import io.opencensus.trace.Tracer; +import io.opencensus.trace.propagation.TextFormat; +import java.util.List; +import java.util.Map; +import junit.framework.TestCase; +import org.junit.Assert; +import org.junit.Rule; + +/** + * Tests {@link OpenCensusUtils}. + * + * @author Hailong Wen + */ +public class OpenCensusUtilsTest extends TestCase { + + TextFormat mockTextFormat; + TextFormat.Setter mockTextFormatSetter; + TextFormat originTextFormat; + TextFormat.Setter originTextFormatSetter; + Span mockSpan; + HttpHeaders headers; + Tracer tracer; + + public OpenCensusUtilsTest(String testName) { + super(testName); + } + + @Override + public void setUp() { + mockTextFormat = new TextFormat() { + @Override + public List fields() { + throw new UnsupportedOperationException("TextFormat.fields"); + } + + @Override + public void inject(SpanContext spanContext, C carrier, Setter setter) { + throw new UnsupportedOperationException("TextFormat.inject"); + } + + @Override + public SpanContext extract(C carrier, Getter getter) { + throw new UnsupportedOperationException("TextFormat.extract"); + } + }; + mockTextFormatSetter = new TextFormat.Setter() { + @Override + public void put(HttpHeaders carrier, String key, String value) { + throw new UnsupportedOperationException("TextFormat.Setter.put"); + } + }; + headers = new HttpHeaders(); + tracer = OpenCensusUtils.getTracer(); + mockSpan = new Span(tracer.getCurrentSpan().getContext(), null) { + + @Override + public void addAnnotation(String description, Map attributes) {} + + @Override + public void addAnnotation(Annotation annotation) {} + + @Override + public void addNetworkEvent(NetworkEvent event) { + throw new UnsupportedOperationException("Span.addNetworkEvent"); + } + + @Override + public void addLink(Link link) {} + + @Override + public void end(EndSpanOptions options) {} + }; + originTextFormat = OpenCensusUtils.propagationTextFormat; + originTextFormatSetter = OpenCensusUtils.propagationTextFormatSetter; + } + + @Override + public void tearDown() { + OpenCensusUtils.setPropagationTextFormat(originTextFormat); + OpenCensusUtils.setPropagationTextFormatSetter(originTextFormatSetter); + } + + public void testInitializatoin() { + assertNotNull(OpenCensusUtils.getTracer()); + assertNotNull(OpenCensusUtils.propagationTextFormat); + assertNotNull(OpenCensusUtils.propagationTextFormatSetter); + } + + public void testSetPropagationTextFormat() { + OpenCensusUtils.setPropagationTextFormat(mockTextFormat); + assertEquals(mockTextFormat, OpenCensusUtils.propagationTextFormat); + } + + public void testSetPropagationTextFormatSetter() { + OpenCensusUtils.setPropagationTextFormatSetter(mockTextFormatSetter); + assertEquals(mockTextFormatSetter, OpenCensusUtils.propagationTextFormatSetter); + } + + public void testPropagateTracingContextInjection() { + OpenCensusUtils.setPropagationTextFormat(mockTextFormat); + try { + OpenCensusUtils.propagateTracingContext(mockSpan, headers); + fail("expected " + UnsupportedOperationException.class); + } catch (UnsupportedOperationException e) { + assertEquals(e.getMessage(), "TextFormat.inject"); + } + } + + public void testPropagateTracingContextHeader() { + OpenCensusUtils.setPropagationTextFormatSetter(mockTextFormatSetter); + try { + OpenCensusUtils.propagateTracingContext(mockSpan, headers); + fail("expected " + UnsupportedOperationException.class); + } catch (UnsupportedOperationException e) { + assertEquals(e.getMessage(), "TextFormat.Setter.put"); + } + } + + public void testPropagateTracingContextNullSpan() { + OpenCensusUtils.setPropagationTextFormat(mockTextFormat); + try { + OpenCensusUtils.propagateTracingContext(null, headers); + fail("expected " + IllegalArgumentException.class); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "span should not be null."); + } + } + + public void testPropagateTracingContextNullHeaders() { + OpenCensusUtils.setPropagationTextFormat(mockTextFormat); + try { + OpenCensusUtils.propagateTracingContext(mockSpan, null); + fail("expected " + IllegalArgumentException.class); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "headers should not be null."); + } + } + + public void testPropagateTracingContextInvalidSpan() { + OpenCensusUtils.setPropagationTextFormat(mockTextFormat); + // No injection. No exceptions should be thrown. + OpenCensusUtils.propagateTracingContext(BlankSpan.INSTANCE, headers); + } + + public void testGetEndSpanOptionsNoResponse() { + EndSpanOptions expected = EndSpanOptions.builder().setStatus(Status.UNKNOWN).build(); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(null)); + } + + public void testGetEndSpanOptionsSuccess() { + EndSpanOptions expected = EndSpanOptions.builder().setStatus(Status.OK).build(); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(200)); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(201)); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(202)); + } + + public void testGetEndSpanOptionsBadRequest() { + EndSpanOptions expected = EndSpanOptions.builder().setStatus(Status.INVALID_ARGUMENT).build(); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(400)); + } + + public void testGetEndSpanOptionsUnauthorized() { + EndSpanOptions expected = EndSpanOptions.builder().setStatus(Status.UNAUTHENTICATED).build(); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(401)); + } + + public void testGetEndSpanOptionsForbidden() { + EndSpanOptions expected = EndSpanOptions.builder().setStatus(Status.PERMISSION_DENIED).build(); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(403)); + } + + public void testGetEndSpanOptionsNotFound() { + EndSpanOptions expected = EndSpanOptions.builder().setStatus(Status.NOT_FOUND).build(); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(404)); + } + + public void testGetEndSpanOptionsPreconditionFailed() { + EndSpanOptions expected = EndSpanOptions.builder().setStatus(Status.FAILED_PRECONDITION).build(); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(412)); + } + + public void testGetEndSpanOptionsServerError() { + EndSpanOptions expected = EndSpanOptions.builder().setStatus(Status.UNAVAILABLE).build(); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(500)); + } + + public void testGetEndSpanOptionsOther() { + EndSpanOptions expected = EndSpanOptions.builder().setStatus(Status.UNKNOWN).build(); + // test some random unsupported statuses + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(301)); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(402)); + assertEquals(expected, OpenCensusUtils.getEndSpanOptions(501)); + } + + public void testRecordMessageEventInNullSpan() { + try { + OpenCensusUtils.recordMessageEvent(null, 0, NetworkEvent.Type.SENT); + fail("expected " + IllegalArgumentException.class); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "span should not be null."); + } + } + + public void testRecordMessageEvent() { + try { + OpenCensusUtils.recordMessageEvent(mockSpan, 0, NetworkEvent.Type.SENT); + fail("expected " + UnsupportedOperationException.class); + } catch (UnsupportedOperationException e) { + assertEquals(e.getMessage(), "Span.addNetworkEvent"); + } + } +} diff --git a/pom.xml b/pom.xml index 3794f581a..56c870e1b 100644 --- a/pom.xml +++ b/pom.xml @@ -268,6 +268,16 @@ j2objc-annotations 1.1 + + io.opencensus + opencensus-api + ${project.opencensus.version} + + + io.opencensus + opencensus-contrib-http-util + ${project.opencensus.version} + @@ -550,6 +560,7 @@ 3.2.1 3.2.1 4.0.3 + 0.18.0