diff --git a/pom.xml b/pom.xml index 00147ac8..12def3f9 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,7 @@ 2.8.3 2.0.2 3.0.1 + 0.26.0 ${project.groupId}.githubclient.shade @@ -170,6 +171,11 @@ jjwt-api 0.10.5 + + io.opencensus + opencensus-api + ${opencensus.version} + io.jsonwebtoken jjwt-impl @@ -218,6 +224,18 @@ ${mockito-core.version} test + + io.opencensus + opencensus-testing + ${opencensus.version} + test + + + io.opencensus + opencensus-impl + ${opencensus.version} + test + com.squareup.okhttp3 mockwebserver diff --git a/src/main/java/com/spotify/github/Span.java b/src/main/java/com/spotify/github/Span.java new file mode 100644 index 00000000..70b2e939 --- /dev/null +++ b/src/main/java/com/spotify/github/Span.java @@ -0,0 +1,33 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2021 Spotify AB + * -- + * 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.spotify.github; + +public interface Span extends AutoCloseable { + + Span success(); + + Span failure(Throwable t); + + /** Close span. Must be called for any opened span. */ + @Override + void close(); +} + diff --git a/src/main/java/com/spotify/github/Tracer.java b/src/main/java/com/spotify/github/Tracer.java new file mode 100644 index 00000000..5d5bcd9c --- /dev/null +++ b/src/main/java/com/spotify/github/Tracer.java @@ -0,0 +1,32 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2021 Spotify AB + * -- + * 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.spotify.github; + +import java.util.concurrent.CompletionStage; + +public interface Tracer { + + /** Create scoped span. Span will be closed when future completes. */ + Span span( + String path, String method, CompletionStage future); + +} + diff --git a/src/main/java/com/spotify/github/opencensus/OpenCensusSpan.java b/src/main/java/com/spotify/github/opencensus/OpenCensusSpan.java new file mode 100644 index 00000000..a48fcb09 --- /dev/null +++ b/src/main/java/com/spotify/github/opencensus/OpenCensusSpan.java @@ -0,0 +1,63 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2016 - 2021 Spotify AB + * -- + * 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.spotify.github.opencensus; +import static java.util.Objects.requireNonNull; +import com.spotify.github.Span; +import com.spotify.github.v3.exceptions.RequestNotOkException; +import io.opencensus.trace.AttributeValue; +import io.opencensus.trace.Status; + +class OpenCensusSpan implements Span { + + public static final int NOT_FOUND = 404; + public static final int INTERNAL_SERVER_ERROR = 500; + private final io.opencensus.trace.Span span; + + OpenCensusSpan(final io.opencensus.trace.Span span) { + this.span = requireNonNull(span); + } + + @Override + public Span success() { + span.setStatus(Status.OK); + return this; + } + + @Override + public Span failure(final Throwable t) { + if (t instanceof RequestNotOkException) { + RequestNotOkException ex = (RequestNotOkException) t; + span.putAttribute("http.status_code", AttributeValue.longAttributeValue(ex.statusCode())); + span.putAttribute("message", AttributeValue.stringAttributeValue(ex.getMessage())); + if (ex.statusCode() - INTERNAL_SERVER_ERROR >= 0) { + span.putAttribute("error", AttributeValue.booleanAttributeValue(true)); + } + } + span.setStatus(Status.UNKNOWN); + return this; + } + + @Override + public void close() { + span.end(); + } +} + diff --git a/src/main/java/com/spotify/github/opencensus/OpenCensusTracer.java b/src/main/java/com/spotify/github/opencensus/OpenCensusTracer.java new file mode 100644 index 00000000..d4834b34 --- /dev/null +++ b/src/main/java/com/spotify/github/opencensus/OpenCensusTracer.java @@ -0,0 +1,71 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2021 Spotify AB + * -- + * 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.spotify.github.opencensus; + +import com.spotify.github.Span; +import com.spotify.github.Tracer; +import io.opencensus.trace.Tracing; + +import java.util.concurrent.CompletionStage; + +import static io.opencensus.trace.AttributeValue.stringAttributeValue; +import static io.opencensus.trace.Span.Kind.CLIENT; +import static java.util.Objects.requireNonNull; + +public class OpenCensusTracer implements Tracer { + + private static final io.opencensus.trace.Tracer TRACER = Tracing.getTracer(); + + @Override + public Span span(final String name, final String method, final CompletionStage future) { + return internalSpan(name, method, future); + } + + @SuppressWarnings("MustBeClosedChecker") + private Span internalSpan( + final String path, + final String method, + final CompletionStage future) { + requireNonNull(path); + requireNonNull(future); + + final io.opencensus.trace.Span ocSpan = + TRACER.spanBuilder("GitHub Request").setSpanKind(CLIENT).startSpan(); + + ocSpan.putAttribute("component", stringAttributeValue("github-api-client")); + ocSpan.putAttribute("peer.service", stringAttributeValue("github")); + ocSpan.putAttribute("http.url", stringAttributeValue(path)); + ocSpan.putAttribute("method", stringAttributeValue(method)); + final Span span = new OpenCensusSpan(ocSpan); + + future.whenComplete( + (result, t) -> { + if (t == null) { + span.success(); + } else { + span.failure(t); + } + span.close(); + }); + + return span; + } +} diff --git a/src/main/java/com/spotify/github/v3/clients/GitHubClient.java b/src/main/java/com/spotify/github/v3/clients/GitHubClient.java index 23d6699f..40170820 100644 --- a/src/main/java/com/spotify/github/v3/clients/GitHubClient.java +++ b/src/main/java/com/spotify/github/v3/clients/GitHubClient.java @@ -24,6 +24,7 @@ import static okhttp3.MediaType.parse; import com.fasterxml.jackson.core.type.TypeReference; +import com.spotify.github.Tracer; import com.spotify.github.jackson.Json; import com.spotify.github.v3.checks.AccessToken; import com.spotify.github.v3.comment.Comment; @@ -65,6 +66,8 @@ */ public class GitHubClient { + private Tracer tracer = NoopTracer.INSTANCE; + static final Consumer IGNORE_RESPONSE_CONSUMER = (response) -> { if (response.body() != null) { response.body().close(); @@ -300,6 +303,11 @@ static String responseBodyUnchecked(final Response response) { } } + public GitHubClient withTracer(final Tracer tracer) { + this.tracer = tracer; + return this; + } + public Optional getPrivateKey() { return Optional.ofNullable(privateKey); } @@ -724,7 +732,7 @@ public void onResponse(final Call call, final Response response) { }); } }); - + tracer.span(request.url().toString(), request.method(), future); return future; } diff --git a/src/main/java/com/spotify/github/v3/clients/NoopTracer.java b/src/main/java/com/spotify/github/v3/clients/NoopTracer.java new file mode 100644 index 00000000..5b3c769e --- /dev/null +++ b/src/main/java/com/spotify/github/v3/clients/NoopTracer.java @@ -0,0 +1,57 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2021 Spotify AB + * -- + * 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.spotify.github.v3.clients; +import com.spotify.github.Span; +import com.spotify.github.Tracer; + +import java.util.concurrent.CompletionStage; + +public class NoopTracer implements Tracer { + + public static final NoopTracer INSTANCE = new NoopTracer(); + private static final Span SPAN = + new Span() { + @Override + public Span success() { + return this; + } + + @Override + public Span failure(final Throwable t) { + return this; + } + + @Override + public void close() {} + }; + + private NoopTracer() {} + + @Override + public Span span( + final String path, + final String method, + final CompletionStage future) { + return SPAN; + } + +} + diff --git a/src/test/java/com/spotify/github/opencensus/OpenCensusSpanTest.java b/src/test/java/com/spotify/github/opencensus/OpenCensusSpanTest.java new file mode 100644 index 00000000..59428b7e --- /dev/null +++ b/src/test/java/com/spotify/github/opencensus/OpenCensusSpanTest.java @@ -0,0 +1,69 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2021 Spotify AB + * -- + * 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.spotify.github.opencensus; + +import com.spotify.github.Span; +import com.spotify.github.v3.exceptions.RequestNotOkException; +import io.opencensus.trace.AttributeValue; +import io.opencensus.trace.Status; +import org.junit.jupiter.api.Test; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class OpenCensusSpanTest { + private io.opencensus.trace.Span wrapped = mock(io.opencensus.trace.Span.class); + + @Test + public void succeed() { + final Span span = new OpenCensusSpan(wrapped); + span.success(); + span.close(); + + verify(wrapped).setStatus(Status.OK); + verify(wrapped).end(); + } + + @Test + public void fail() { + final Span span = new OpenCensusSpan(wrapped); + span.failure(new RequestNotOkException("path", 404, "Not found")); + span.close(); + + verify(wrapped).setStatus(Status.UNKNOWN); + verify(wrapped).putAttribute("http.status_code", AttributeValue.longAttributeValue(404)); + verify(wrapped).end(); + } + + @Test + public void failOnServerError() { + final Span span = new OpenCensusSpan(wrapped); + span.failure(new RequestNotOkException("path", 500, "Internal Server Error")); + span.close(); + + verify(wrapped).setStatus(Status.UNKNOWN); + verify(wrapped).putAttribute("http.status_code", AttributeValue.longAttributeValue(500)); + verify(wrapped).putAttribute("error", AttributeValue.booleanAttributeValue(true)); + verify(wrapped).end(); + } + +} diff --git a/src/test/java/com/spotify/github/opencensus/OpenCensusTracerTest.java b/src/test/java/com/spotify/github/opencensus/OpenCensusTracerTest.java new file mode 100644 index 00000000..add341d6 --- /dev/null +++ b/src/test/java/com/spotify/github/opencensus/OpenCensusTracerTest.java @@ -0,0 +1,132 @@ +/*- + * -\-\- + * github-client + * -- + * Copyright (C) 2016 - 2021 Spotify AB + * -- + * 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.spotify.github.opencensus; + + +import io.grpc.Context; +import io.opencensus.trace.*; +import io.opencensus.trace.config.TraceConfig; +import io.opencensus.trace.config.TraceParams; +import io.opencensus.trace.export.SpanData; +import io.opencensus.trace.samplers.Samplers; +import io.opencensus.trace.unsafe.ContextUtils; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static io.opencensus.trace.AttributeValue.stringAttributeValue; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class OpenCensusTracerTest { + + + private final String rootSpanName = "root span"; + private TestExportHandler spanExporterHandler; + + /** + * Test that trace() a) returns a future that completes when the input future completes and b) + * sets up the Spans appropriately so that the Span for the operation is exported with the + * rootSpan set as the parent. + */ + @Test + public void testTrace_CompletionStage_Simple() throws Exception { + Span rootSpan = startRootSpan(); + final CompletableFuture future = new CompletableFuture<>(); + OpenCensusTracer tracer = new OpenCensusTracer(); + + tracer.span("path", "GET", future); + future.complete("all done"); + rootSpan.end(); + + List exportedSpans = spanExporterHandler.waitForSpansToBeExported(2); + assertEquals(2, exportedSpans.size()); + + SpanData root = findSpan(exportedSpans, rootSpanName); + SpanData inner = findSpan(exportedSpans, "GitHub Request"); + + assertEquals(root.getContext().getTraceId(), inner.getContext().getTraceId()); + assertEquals(root.getContext().getSpanId(), inner.getParentSpanId()); + final Map attributes = inner.getAttributes().getAttributeMap(); + assertEquals(stringAttributeValue("github-api-client"), attributes.get("component")); + assertEquals(stringAttributeValue("github"), attributes.get("peer.service")); + assertEquals(stringAttributeValue("path"), attributes.get("http.url")); + assertEquals(stringAttributeValue("GET"), attributes.get("method")); + assertEquals(Status.OK, inner.getStatus()); + } + + @Test + public void testTrace_CompletionStage_Fails() throws Exception { + Span rootSpan = startRootSpan(); + final CompletableFuture future = new CompletableFuture<>(); + OpenCensusTracer tracer = new OpenCensusTracer(); + + tracer.span("path", "POST", future); + future.completeExceptionally(new Exception("GitHub failed!")); + rootSpan.end(); + + List exportedSpans = spanExporterHandler.waitForSpansToBeExported(2); + assertEquals(2, exportedSpans.size()); + + SpanData root = findSpan(exportedSpans, rootSpanName); + SpanData inner = findSpan(exportedSpans, "GitHub Request"); + + assertEquals(root.getContext().getTraceId(), inner.getContext().getTraceId()); + assertEquals(root.getContext().getSpanId(), inner.getParentSpanId()); + final Map attributes = inner.getAttributes().getAttributeMap(); + assertEquals(stringAttributeValue("github-api-client"), attributes.get("component")); + assertEquals(stringAttributeValue("github"), attributes.get("peer.service")); + assertEquals(stringAttributeValue("path"), attributes.get("http.url")); + assertEquals(stringAttributeValue("POST"), attributes.get("method")); + assertEquals(Status.UNKNOWN, inner.getStatus()); + } + + private Span startRootSpan() { + Span rootSpan = Tracing.getTracer().spanBuilder(rootSpanName).startSpan(); + Context context = ContextUtils.withValue(Context.current(), rootSpan); + context.attach(); + return rootSpan; + } + + private SpanData findSpan(final List spans, final String name) { + return spans.stream().filter(s -> s.getName().equals(name)).findFirst().get(); + } + + @Before + public void setUpExporter() { + spanExporterHandler = new TestExportHandler(); + Tracing.getExportComponent().getSpanExporter().registerHandler("test", spanExporterHandler); + } + + @BeforeClass + public static void setupTracing() { + final TraceConfig traceConfig = Tracing.getTraceConfig(); + final Sampler sampler = Samplers.alwaysSample(); + final TraceParams newParams = + traceConfig.getActiveTraceParams().toBuilder().setSampler(sampler).build(); + traceConfig.updateActiveTraceParams(newParams); + } +} \ No newline at end of file diff --git a/src/test/java/com/spotify/github/opencensus/TestExportHandler.java b/src/test/java/com/spotify/github/opencensus/TestExportHandler.java new file mode 100644 index 00000000..c1519572 --- /dev/null +++ b/src/test/java/com/spotify/github/opencensus/TestExportHandler.java @@ -0,0 +1,82 @@ +/*- + * -\-\- + * github-client + * -- + * Copyright (C) 2016 - 2021 Spotify AB + * -- + * 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.spotify.github.opencensus; + +import io.opencensus.trace.export.SpanData; +import io.opencensus.trace.export.SpanExporter; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A dummy SpanExporter.Handler which keeps any exported Spans in memory, so we can query against + * them in tests. + * + *

The opencensus-testing library has a TestHandler that can be used in tests like this, but the + * only method it exposes to gain access to the received spans is waitForExport(int) which blocks + * forever until the given number of spans is exported, which could be never. So instead we define + * our own very simple implementation. + */ +class TestExportHandler extends SpanExporter.Handler { + private static final Logger LOG = LoggerFactory.getLogger(TestExportHandler.class); + + private final List receivedSpans = new ArrayList<>(); + private final Object lock = new Object(); + + @Override + public void export(final Collection spanDataList) { + synchronized (lock) { + receivedSpans.addAll(spanDataList); + LOG.info("received {} spans, {} total", spanDataList.size(), receivedSpans.size()); + } + } + + List receivedSpans() { + synchronized (lock) { + return new ArrayList<>(receivedSpans); + } + } + + /** Wait up to waitTime for at least `count` spans to be exported */ + List waitForSpansToBeExported(final int count) throws InterruptedException { + // opencensus is hardcoded to export batches every 5 seconds (see + // io.opencensus.implcore.trace.export.ExportComponentImpl), so wait slightly longer than that + Duration waitTime = Duration.ofSeconds(7); + Instant deadline = Instant.now().plus(waitTime); + + List spanData = receivedSpans(); + while (spanData.size() < count) { + //noinspection BusyWait + Thread.sleep(100); + spanData = receivedSpans(); + + if (!Instant.now().isBefore(deadline)) { + LOG.warn("ending busy wait for spans because deadline passed"); + break; + } + } + return spanData; + } +} diff --git a/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java b/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java index 182a7a96..8e4504d1 100644 --- a/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java @@ -25,10 +25,9 @@ import static org.hamcrest.core.Is.is; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +import com.spotify.github.Tracer; import com.spotify.github.v3.exceptions.ReadOnlyRepositoryException; import com.spotify.github.v3.exceptions.RequestNotOkException; import com.spotify.github.v3.repos.CommitItem; @@ -52,6 +51,7 @@ public class GitHubClientTest { private GitHubClient github; private OkHttpClient client; + private Tracer tracer = mock(Tracer.class); @Before public void setUp() { @@ -88,10 +88,11 @@ public void testSearchIssue() throws Throwable { when(client.newCall(any())).thenReturn(call); IssueClient issueClient = - github.createRepositoryClient("testorg", "testrepo").createIssueClient(); + github.withTracer(tracer).createRepositoryClient("testorg", "testrepo").createIssueClient(); CompletableFuture maybeSucceeded = issueClient.editComment(1, "some comment"); capture.getValue().onResponse(call, response); + verify(tracer,times(1)).span(anyString(), anyString(),any()); try { maybeSucceeded.get(); } catch (Exception e) {