diff --git a/CHANGELOG.md b/CHANGELOG.md index a4775648d4..acd792468c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +- Tracing api ([#1393](https://github.com/scm-manager/scm-manager/pull/#1393)) - Automatic user converter for external users ([#1380](https://github.com/scm-manager/scm-manager/pull/1380)) - Create _authenticated group on setup ([#1396](https://github.com/scm-manager/scm-manager/pull/1396)) - The name of the initial git branch can be configured and is set to `main` by default ([#1399](https://github.com/scm-manager/scm-manager/pull/1399)) diff --git a/scm-core/src/main/java/sonia/scm/net/ahc/BaseHttpRequest.java b/scm-core/src/main/java/sonia/scm/net/ahc/BaseHttpRequest.java index d30ca4ca6b..302f2ac6fe 100644 --- a/scm-core/src/main/java/sonia/scm/net/ahc/BaseHttpRequest.java +++ b/scm-core/src/main/java/sonia/scm/net/ahc/BaseHttpRequest.java @@ -21,30 +21,28 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.net.ahc; //~--- non-JDK imports -------------------------------------------------------- -import com.google.common.base.Charsets; import com.google.common.base.Strings; import com.google.common.collect.LinkedHashMultimap; import com.google.common.collect.Multimap; - import org.apache.shiro.codec.Base64; - import sonia.scm.util.HttpUtil; -//~--- JDK imports ------------------------------------------------------------ - import java.io.IOException; +import java.nio.charset.StandardCharsets; + +//~--- JDK imports ------------------------------------------------------------ /** * Base class for http requests. * * @author Sebastian Sdorra * @param request implementation - * + * * @since 1.46 */ public abstract class BaseHttpRequest @@ -75,7 +73,7 @@ public abstract class BaseHttpRequest * * @throws IOException */ - public AdvancedHttpResponse request() throws IOException + public AdvancedHttpResponse request() throws IOException { return client.request(this); } @@ -102,7 +100,7 @@ public abstract class BaseHttpRequest String auth = Strings.nullToEmpty(username).concat(":").concat( Strings.nullToEmpty(password)); - auth = Base64.encodeToString(auth.getBytes(Charsets.ISO_8859_1)); + auth = Base64.encodeToString(auth.getBytes(StandardCharsets.ISO_8859_1)); headers.put("Authorization", "Basic ".concat(auth)); return self(); @@ -129,7 +127,7 @@ public abstract class BaseHttpRequest * * * @param disableCertificateValidation true to disable certificate validation - * + * * @return request instance */ public T disableCertificateValidation(boolean disableCertificateValidation) @@ -246,6 +244,30 @@ public abstract class BaseHttpRequest return self(); } + /** + * Sets the kind of span for tracing api. + * + * @param spanKind kind of span + * @return request instance + * + * @since 2.9.0 + */ + public T spanKind(String spanKind) { + this.spanKind = spanKind; + return self(); + } + + /** + * Disables tracing for the request. + * This should only be done for internal requests. + * + * @return request instance + */ + public T disableTracing() { + this.spanKind = null; + return self(); + } + //~--- get methods ---------------------------------------------------------- /** @@ -281,6 +303,17 @@ public abstract class BaseHttpRequest return url; } + /** + * Returns the kind of span which is used for the trace api. + * + * @return kind of span + * + * @since 2.9.0 + */ + public String getSpanKind() { + return spanKind; + } + /** * Returns true if the request decodes gzip compression. * @@ -317,7 +350,7 @@ public abstract class BaseHttpRequest /** * Returns true if the proxy settings are ignored. * - * + * * @return true if the proxy settings are ignored */ public boolean isIgnoreProxySettings() @@ -341,7 +374,7 @@ public abstract class BaseHttpRequest } /** - * Returns string representation of the given object or {@code null}, if the + * Returns string representation of the given object or {@code null}, if the * object is {@code null}. * * @@ -398,4 +431,7 @@ public abstract class BaseHttpRequest /** url of request */ private String url; + + /** kind of span for trace api */ + private String spanKind = "HTTP Request"; } diff --git a/scm-core/src/main/java/sonia/scm/trace/Exporter.java b/scm-core/src/main/java/sonia/scm/trace/Exporter.java new file mode 100644 index 0000000000..91349d1cf2 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/trace/Exporter.java @@ -0,0 +1,43 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.trace; + +import sonia.scm.plugin.ExtensionPoint; + +/** + * An exporter could be used to collect and process spans. + * + * @since 2.9.0 + */ +@ExtensionPoint +public interface Exporter { + + /** + * Process the collected span. + * + * @param span collected span + */ + void export(SpanContext span); +} diff --git a/scm-core/src/main/java/sonia/scm/trace/Span.java b/scm-core/src/main/java/sonia/scm/trace/Span.java new file mode 100644 index 0000000000..6c1ff8ed6e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/trace/Span.java @@ -0,0 +1,141 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.trace; + +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A span represents a single unit of work e.g. a request to an external system. + * + * @since 2.9.0 + */ +public final class Span implements AutoCloseable { + + private final Tracer tracer; + private final String kind; + private final Map labels = new LinkedHashMap<>(); + private final Instant opened; + private boolean failed; + + Span(Tracer tracer, String kind) { + this.tracer = tracer; + this.kind = kind; + this.opened = Instant.now(); + } + + /** + * Adds a label to the span. + * + * @param key key of label + * @param value label value + * @return {@code this} + */ + public Span label(String key, String value) { + labels.put(key, value); + return this; + } + + /** + * Adds a label to the span. + * @param key key of label + * @param value label value + * @return {@code this} + */ + public Span label(String key, int value) { + return label(key, String.valueOf(value)); + } + + /** + * Adds a label to the span. + * @param key key of label + * @param value label value + * @return {@code this} + */ + public Span label(String key, long value) { + return label(key, String.valueOf(value)); + } + + /** + * Adds a label to the span. + * @param key key of label + * @param value label value + * @return {@code this} + */ + public Span label(String key, float value) { + return label(key, String.valueOf(value)); + } + + /** + * Adds a label to the span. + * @param key key of label + * @param value label value + * @return {@code this} + */ + public Span label(String key, double value) { + return label(key, String.valueOf(value)); + } + + /** + * Adds a label to the span. + * @param key key of label + * @param value label value + * @return {@code this} + */ + public Span label(String key, boolean value) { + return label(key, String.valueOf(value)); + } + + /** + * Adds a label to the span. + * @param key key of label + * @param value label value + * @return {@code this} + */ + public Span label(String key, Object value) { + return label(key, String.valueOf(value)); + } + + /** + * Marks the span as failed. + * + * @return {@code this} + */ + public Span failed() { + failed = true; + return this; + } + + /** + * Closes the span a reports the context to the {@link Tracer}. + */ + @Override + public void close() { + tracer.export(new SpanContext(kind, Collections.unmodifiableMap(labels), opened, Instant.now(), failed)); + } + +} diff --git a/scm-core/src/main/java/sonia/scm/trace/SpanContext.java b/scm-core/src/main/java/sonia/scm/trace/SpanContext.java new file mode 100644 index 0000000000..d158933d7a --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/trace/SpanContext.java @@ -0,0 +1,80 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.trace; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sonia.scm.xml.XmlInstantAdapter; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +/** + * The {@link SpanContext} represents a finished span which could be processed by an {@link Exporter}. + * + * @since 2.9.0 + */ +@Getter +@XmlRootElement +@EqualsAndHashCode +@AllArgsConstructor +@XmlAccessorType(XmlAccessType.FIELD) +@NoArgsConstructor(access = AccessLevel.PACKAGE) +public class SpanContext { + + private String kind; + private Map labels; + @XmlJavaTypeAdapter(XmlInstantAdapter.class) + private Instant opened; + @XmlJavaTypeAdapter(XmlInstantAdapter.class) + private Instant closed; + private boolean failed; + + /** + * Returns the label with the given key or {@code null}. + * @param key key of label + * @return label or {@code null} + */ + public String label(String key) { + return labels.get(key); + } + + /** + * Calculates the duration of the span. + * + * @return duration of the span + */ + public Duration duration() { + return Duration.between(opened, closed); + } +} diff --git a/scm-core/src/main/java/sonia/scm/trace/Tracer.java b/scm-core/src/main/java/sonia/scm/trace/Tracer.java new file mode 100644 index 0000000000..7f35c16dff --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/trace/Tracer.java @@ -0,0 +1,83 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.trace; + +import javax.inject.Inject; +import java.util.Set; + +/** + * The tracer api allows the tracing of long running tasks, such as calling external systems. + * The api is able to collect tracing points called spans. + * + * To use the tracer api inject the {@link Tracer} and open a span in a try with resources block e.g.: + *
+ *   try (Span span = tracer.span("jenkins").label("repository", "builds/core")) {
+ *     Response response = jenkins.call("http://...");
+ *     if (!response.isSuccess()) {
+ *       span.label("reason", response.getFailedReason());
+ *       span.failed();
+ *     }
+ *   }
+ * 
+ * + * As seen in the example we can mark span as failed and add more context to the span with labels. + * After a span is closed it is delegated to an {@link Exporter}, which + * + * @since 2.9.0 + */ +public final class Tracer { + + private final Set exporters; + + /** + * Constructs a new tracer with the given set of exporters. + * + * @param exporters set of exporters + */ + @Inject + public Tracer(Set exporters) { + this.exporters = exporters; + } + + /** + * Creates a new span. + * @param kind kind of span + * @return new span + */ + public Span span(String kind) { + return new Span(this, kind); + } + + /** + * Pass the finished span to the exporters. + * + * @param span finished span + */ + void export(SpanContext span) { + for (Exporter exporter : exporters) { + exporter.export(span); + } + } +} diff --git a/scm-core/src/test/java/sonia/scm/trace/SpanContextTest.java b/scm-core/src/test/java/sonia/scm/trace/SpanContextTest.java new file mode 100644 index 0000000000..87bba53753 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/trace/SpanContextTest.java @@ -0,0 +1,57 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.trace; + +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Test; + +import javax.xml.bind.JAXB; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class SpanContextTest { + + @Test + void shouldMarshalAndUnmarshal() { + Instant now = Instant.now(); + SpanContext span = new SpanContext( + "jenkins", ImmutableMap.of("one", "1"), now, now, true + ); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + JAXB.marshal(span, baos); + span = JAXB.unmarshal(new ByteArrayInputStream(baos.toByteArray()), SpanContext.class); + + assertThat(span.getKind()).isEqualTo("jenkins"); + assertThat(span.label("one")).isEqualTo("1"); + assertThat(span.getOpened()).isEqualTo(now); + assertThat(span.getClosed()).isEqualTo(now); + assertThat(span.isFailed()).isTrue(); + } + +} diff --git a/scm-core/src/test/java/sonia/scm/trace/TracerTest.java b/scm-core/src/test/java/sonia/scm/trace/TracerTest.java new file mode 100644 index 0000000000..12985038f8 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/trace/TracerTest.java @@ -0,0 +1,127 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.trace; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class TracerTest { + + private Tracer tracer; + private CollectingExporter exporter; + + @BeforeEach + void setUpTracer() { + exporter = new CollectingExporter(); + tracer = new Tracer(Collections.singleton(exporter)); + } + + @Test + void shouldReturnSpan() { + tracer.span("sample").close(); + + SpanContext span = exporter.spans.get(0); + assertThat(span.getKind()).isEqualTo("sample"); + assertThat(span.getOpened()).isNotNull(); + assertThat(span.getClosed()).isNotNull(); + assertThat(span.isFailed()).isFalse(); + } + + @Test + @SuppressWarnings("java:S2925") // it is ok, to use sleep here + void shouldReturnPositiveDuration() throws InterruptedException { + try (Span span = tracer.span("sample")) { + span.label("l1", "one"); + Thread.sleep(1L); + } + + SpanContext span = exporter.spans.get(0); + assertThat(span.duration()).isPositive(); + } + + @Test + void shouldConvertLabels() { + try (Span span = tracer.span("sample")) { + span.label("int", 21); + span.label("long", 42L); + span.label("float", 21.0f); + span.label("double", 42.0d); + span.label("boolean", true); + span.label("object", new StringWrapper("value")); + } + + Map labels = exporter.spans.get(0).getLabels(); + assertThat(labels) + .containsEntry("int", "21") + .containsEntry("long", "42") + .containsEntry("float", "21.0") + .containsEntry("double", "42.0") + .containsEntry("boolean", "true") + .containsEntry("object", "value"); + } + + @Test + void shouldReturnFailedSpan() { + try (Span span = tracer.span("failing")) { + span.failed(); + } + + SpanContext span = exporter.spans.get(0); + assertThat(span.getKind()).isEqualTo("failing"); + assertThat(span.isFailed()).isTrue(); + } + + public static class CollectingExporter implements Exporter { + + private final List spans = new ArrayList<>(); + + @Override + public void export(SpanContext spanContext) { + spans.add(spanContext); + } + } + + private static class StringWrapper { + + private final String value; + + public StringWrapper(String value) { + this.value = value; + } + + @Override + public String toString() { + return value; + } + } + +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgHookManager.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgHookManager.java index 5e93c1d859..3d333015ee 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgHookManager.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgHookManager.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- @@ -54,11 +54,10 @@ import java.util.UUID; * @author Sebastian Sdorra */ @Singleton -public class HgHookManager -{ +public class HgHookManager { - /** Field description */ - public static final String URL_HOOKPATH = "/hook/hg/"; + @SuppressWarnings("java:S1075") // this url is fixed + private static final String URL_HOOKPATH = "/hook/hg/"; /** * the logger for HgHookManager @@ -191,64 +190,27 @@ public class HgHookManager return accessTokenBuilderFactory.create().build(); } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param request - */ - private void buildHookUrl(HttpServletRequest request) - { - if (configuration.isForceBaseUrl()) - { - if (logger.isDebugEnabled()) - { - logger.debug( - "create hook url from configured base url because force base url is enabled"); - } + private void buildHookUrl(HttpServletRequest request) { + if (configuration.isForceBaseUrl()) { + logger.debug("create hook url from configured base url because force base url is enabled"); hookUrl = createConfiguredUrl(); - - if (!isUrlWorking(hookUrl)) - { + if (!isUrlWorking(hookUrl)) { disableHooks(); } - } - else - { - if (logger.isDebugEnabled()) - { - logger.debug("create hook url from request"); - } + } else { + logger.debug("create hook url from request"); hookUrl = HttpUtil.getCompleteUrl(request, URL_HOOKPATH); - - if (!isUrlWorking(hookUrl)) - { - if (logger.isWarnEnabled()) - { - logger.warn( - "hook url {} from request does not work, try now localhost", - hookUrl); - } + if (!isUrlWorking(hookUrl)) { + logger.warn("hook url {} from request does not work, try now localhost", hookUrl); hookUrl = createLocalUrl(request); - - if (!isUrlWorking(hookUrl)) - { - if (logger.isWarnEnabled()) - { - logger.warn( - "localhost hook url {} does not work, try now from configured base url", - hookUrl); - } + if (!isUrlWorking(hookUrl)) { + logger.warn("localhost hook url {} does not work, try now from configured base url", hookUrl); hookUrl = createConfiguredUrl(); - - if (!isUrlWorking(hookUrl)) - { + if (!isUrlWorking(hookUrl)) { disableHooks(); } } @@ -270,7 +232,7 @@ public class HgHookManager configuration.getBaseUrl(), "http://localhost:8080/scm" ) - ).concat("/hook/hg/"); + ).concat(URL_HOOKPATH); //J+ } @@ -324,11 +286,7 @@ public class HgHookManager { request = httpServletRequestProvider.get(); } - catch (ProvisionException ex) - { - logger.debug("http servlet request is not available"); - } - catch (OutOfScopeException ex) + catch (ProvisionException | OutOfScopeException ex) { logger.debug("http servlet request is not available"); } @@ -358,6 +316,7 @@ public class HgHookManager .disableHostnameValidation(true) .disableCertificateValidation(true) .ignoreProxySettings(true) + .disableTracing() .request() .getStatus(); //J+ diff --git a/scm-ui/ui-components/src/forms/FilterInput.tsx b/scm-ui/ui-components/src/forms/FilterInput.tsx index fad1611c00..15dcabb9e1 100644 --- a/scm-ui/ui-components/src/forms/FilterInput.tsx +++ b/scm-ui/ui-components/src/forms/FilterInput.tsx @@ -21,73 +21,60 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { ChangeEvent, FormEvent } from "react"; -import { WithTranslation, withTranslation } from "react-i18next"; +import React, { FC, FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { createAttributesForTesting } from "../devBuild"; -type Props = WithTranslation & { +type Props = { filter: (p: string) => void; value?: string; testId?: string; -}; - -type State = { - value: string; + placeholder?: string; }; const FixedHeightInput = styled.input` height: 2.5rem; `; -class FilterInput extends React.Component { - constructor(props: Props) { - super(props); - this.state = { - value: this.props.value ? this.props.value : "" - }; - } +const FilterInput: FC = ({ filter, value, testId, placeholder }) => { + const [stateValue, setStateValue] = useState(value || ""); + const [timeoutId, setTimeoutId] = useState(0); + const [t] = useTranslation("commons"); - handleChange = (event: ChangeEvent) => { - this.setState({ - value: event.target.value - }); - }; + useEffect(() => { + clearTimeout(timeoutId); + if (!stateValue) { + // no delay if filter input was deleted + filter(stateValue); + } else { + // with delay while typing + const id = setTimeout(() => filter(stateValue), 1000); + setTimeoutId(id); + } + }, [stateValue]); - handleSubmit = (event: FormEvent) => { - this.props.filter(this.state.value); + const handleSubmit = (event: FormEvent) => { + filter(stateValue); event.preventDefault(); }; - componentDidUpdate = ({ value: oldValue }: Props) => { - const { value: newValue } = this.props; - const { value: stateValue } = this.state; - if (oldValue !== newValue && newValue !== stateValue) { - this.setState({ - value: newValue || "" - }); - } - }; + return ( +
+
+ setStateValue(event.target.value)} + /> + + + +
+ + ); +}; - render() { - const { t, testId } = this.props; - return ( -
-
- - - - -
- - ); - } -} - -export default withTranslation("commons")(FilterInput); +export default FilterInput; diff --git a/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java b/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java index 26c4c63b94..6576cdbceb 100644 --- a/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java +++ b/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java @@ -24,6 +24,7 @@ package sonia.scm.admin; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -46,6 +47,9 @@ public class ReleaseFeedParser { public static final int DEFAULT_TIMEOUT_IN_MILLIS = 1000; private static final Logger LOG = LoggerFactory.getLogger(ReleaseFeedParser.class); + + @VisibleForTesting + static final String SPAN_KIND = "Release Feed"; private final AdvancedHttpClient client; private final ExecutorService executorService; @@ -103,7 +107,10 @@ public class ReleaseFeedParser { if (Strings.isNullOrEmpty(url)) { return Optional.empty(); } - ReleaseFeedDto releaseFeed = client.get(url).request().contentFromXml(ReleaseFeedDto.class); + ReleaseFeedDto releaseFeed = client.get(url) + .spanKind(SPAN_KIND) + .request() + .contentFromXml(ReleaseFeedDto.class); return filterForLatestRelease(releaseFeed); } catch (Exception e) { LOG.error("Could not parse release feed from {}", url, e); diff --git a/scm-webapp/src/main/java/sonia/scm/net/ahc/DefaultAdvancedHttpClient.java b/scm-webapp/src/main/java/sonia/scm/net/ahc/DefaultAdvancedHttpClient.java index 610ec39533..ee3bdbb1e5 100644 --- a/scm-webapp/src/main/java/sonia/scm/net/ahc/DefaultAdvancedHttpClient.java +++ b/scm-webapp/src/main/java/sonia/scm/net/ahc/DefaultAdvancedHttpClient.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.net.ahc; //~--- non-JDK imports -------------------------------------------------------- @@ -38,8 +38,11 @@ import sonia.scm.config.ScmConfiguration; import sonia.scm.net.Proxies; import sonia.scm.net.TrustAllHostnameVerifier; import sonia.scm.net.TrustAllTrustManager; +import sonia.scm.trace.Span; +import sonia.scm.trace.Tracer; import sonia.scm.util.HttpUtil; +import javax.annotation.Nonnull; import javax.inject.Provider; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; @@ -99,9 +102,10 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient */ @Inject public DefaultAdvancedHttpClient(ScmConfiguration configuration, - Set contentTransformers, Provider sslContextProvider) + Tracer tracer, Set contentTransformers, Provider sslContextProvider) { this.configuration = configuration; + this.tracer = tracer; this.contentTransformers = contentTransformers; this.sslContextProvider = sslContextProvider; } @@ -185,45 +189,65 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient * @throws IOException */ @Override - protected AdvancedHttpResponse request(BaseHttpRequest request) - throws IOException - { - HttpURLConnection connection = openConnection(request, - new URL(request.getUrl())); + protected AdvancedHttpResponse request(BaseHttpRequest request) throws IOException { + String spanKind = request.getSpanKind(); + if (Strings.isNullOrEmpty(spanKind)) { + logger.debug("execute request {} without tracing", request.getUrl()); + return doRequest(request); + } + return doRequestWithTracing(request); + } + + @Nonnull + private DefaultAdvancedHttpResponse doRequestWithTracing(BaseHttpRequest request) throws IOException { + try (Span span = tracer.span(request.getSpanKind())) { + span.label("url", request.getUrl()); + span.label("method", request.getMethod()); + try { + DefaultAdvancedHttpResponse response = doRequest(request); + span.label("status", response.getStatus()); + if (!response.isSuccessful()) { + span.failed(); + } + return response; + } catch (IOException ex) { + span.label("exception", ex.getClass().getName()); + span.label("message", ex.getMessage()); + span.failed(); + throw ex; + } + } + } + + @Nonnull + private DefaultAdvancedHttpResponse doRequest(BaseHttpRequest request) throws IOException { + HttpURLConnection connection = openConnection(request, new URL(request.getUrl())); applyBaseSettings(request, connection); - if (connection instanceof HttpsURLConnection) - { + if (connection instanceof HttpsURLConnection) { applySSLSettings(request, (HttpsURLConnection) connection); } Content content = null; - if (request instanceof AdvancedHttpRequestWithBody) - { + if (request instanceof AdvancedHttpRequestWithBody) { AdvancedHttpRequestWithBody ahrwb = (AdvancedHttpRequestWithBody) request; content = ahrwb.getContent(); - if (content != null) - { + if (content != null) { content.prepare(ahrwb); - } - else - { + } else { request.header(HttpUtil.HEADER_CONTENT_LENGTH, "0"); } - } - else - { + } else { request.header(HttpUtil.HEADER_CONTENT_LENGTH, "0"); } applyHeaders(request, connection); - if (content != null) - { + if (content != null) { applyContent(connection, content); } @@ -300,7 +324,7 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient { TrustManager[] trustAllCerts = new TrustManager[] { new TrustAllTrustManager() }; - SSLContext sc = SSLContext.getInstance("SSL"); + SSLContext sc = SSLContext.getInstance("TLS"); sc.init(null, trustAllCerts, new java.security.SecureRandom()); connection.setSSLSocketFactory(sc.getSocketFactory()); @@ -309,10 +333,10 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient { logger.error("could not disable certificate validation", ex); } - } - else + } + else { - logger.trace("set ssl socker factory from provider"); + logger.trace("set ssl socket factory from provider"); connection.setSSLSocketFactory(sslContextProvider.get().getSocketFactory()); } @@ -330,7 +354,7 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient if (isProxyEnabled(request)) { - connection = openProxyConnection(request, url); + connection = openProxyConnection(url); appendProxyAuthentication(connection); } else @@ -340,7 +364,9 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient logger.trace("ignore proxy settings"); } - logger.debug("fetch {}", url.toExternalForm()); + if (logger.isDebugEnabled()) { + logger.debug("fetch {}", url.toExternalForm()); + } connection = createConnection(url); } @@ -348,8 +374,7 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient return connection; } - private HttpURLConnection openProxyConnection(BaseHttpRequest request, - URL url) + private HttpURLConnection openProxyConnection(URL url) throws IOException { if (logger.isDebugEnabled()) @@ -380,7 +405,10 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient /** set of content transformers */ private final Set contentTransformers; - + /** ssl context provider */ private final Provider sslContextProvider; + + /** tracer used for request tracing */ + private final Tracer tracer; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java index efc967df49..0ab33dd6b1 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.plugin; import com.google.common.annotations.VisibleForTesting; @@ -34,6 +34,8 @@ import javax.inject.Inject; import java.util.Collections; import java.util.Set; +import static sonia.scm.plugin.Tracing.SPAN_KIND; + class PluginCenterLoader { private static final Logger LOG = LoggerFactory.getLogger(PluginCenterLoader.class); @@ -57,7 +59,8 @@ class PluginCenterLoader { Set load(String url) { try { LOG.info("fetch plugins from {}", url); - PluginCenterDto pluginCenterDto = client.get(url).request().contentFromJson(PluginCenterDto.class); + PluginCenterDto pluginCenterDto = client.get(url).spanKind(SPAN_KIND).request() + .contentFromJson(PluginCenterDto.class); return mapper.map(pluginCenterDto); } catch (Exception ex) { LOG.error("failed to load plugins from plugin center, returning empty list", ex); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java index 5c371aa323..bea6451028 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -38,6 +38,8 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Optional; +import static sonia.scm.plugin.Tracing.SPAN_KIND; + @SuppressWarnings("UnstableApiUsage") // guava hash is marked as unstable class PluginInstaller { @@ -126,7 +128,7 @@ class PluginInstaller { } private InputStream download(AvailablePlugin plugin) throws IOException { - return client.get(plugin.getDescriptor().getUrl()).request().contentAsStream(); + return client.get(plugin.getDescriptor().getUrl()).spanKind(SPAN_KIND).request().contentAsStream(); } private Path createFile(AvailablePlugin plugin) throws IOException { diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/Tracing.java b/scm-webapp/src/main/java/sonia/scm/plugin/Tracing.java new file mode 100644 index 0000000000..41903830f2 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/Tracing.java @@ -0,0 +1,33 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.plugin; + +final class Tracing { + + public static final String SPAN_KIND = "Plugin Center"; + + private Tracing() { + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/trace/LoggingExporter.java b/scm-webapp/src/main/java/sonia/scm/trace/LoggingExporter.java new file mode 100644 index 0000000000..1e46a81605 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/trace/LoggingExporter.java @@ -0,0 +1,78 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.trace; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.plugin.Extension; + +import javax.inject.Inject; +import java.util.Map; +import java.util.function.Consumer; + +/** + * An {@link Exporter} which logs every collected span. + * + * @since 2.9.0 + */ +@Extension +public final class LoggingExporter implements Exporter { + + private static final Logger LOG = LoggerFactory.getLogger(LoggingExporter.class); + + private final Consumer logger; + + @Inject + LoggingExporter() { + this(LOG::info); + } + + LoggingExporter(Consumer logger) { + this.logger = logger; + } + + @Override + public void export(SpanContext span) { + logger.accept(format(span)); + } + + private String format(SpanContext span) { + StringBuilder message = new StringBuilder("received "); + if (span.isFailed()) { + message.append("failed "); + } + message.append(span.getKind()).append(" span, which took "); + message.append(span.duration().toMillis()).append("ms"); + Map labels = span.getLabels(); + if (!labels.isEmpty()) { + message.append(":"); + for (Map.Entry e : labels.entrySet()) { + message.append("\n - ").append(e.getKey()).append(": ").append(e.getValue()); + } + } + return message.toString(); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java b/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java index f2e9516f1e..1e67b74df9 100644 --- a/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java +++ b/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java @@ -32,6 +32,7 @@ import org.mockito.Answers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.net.ahc.AdvancedHttpClient; +import sonia.scm.net.ahc.AdvancedHttpResponse; import java.io.IOException; import java.util.Date; @@ -44,6 +45,7 @@ import java.util.concurrent.Semaphore; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; +import static sonia.scm.admin.ReleaseFeedParser.SPAN_KIND; @ExtendWith(MockitoExtension.class) class ReleaseFeedParserTest { @@ -62,7 +64,7 @@ class ReleaseFeedParserTest { void shouldFindLatestRelease() throws IOException { String url = "https://www.scm-manager.org/download/rss.xml"; - when(client.get(url).request().contentFromXml(ReleaseFeedDto.class)).thenReturn(createReleaseFeedDto()); + when(request(url).contentFromXml(ReleaseFeedDto.class)).thenReturn(createReleaseFeedDto()); Optional update = releaseFeedParser.findLatestRelease(url); @@ -71,13 +73,17 @@ class ReleaseFeedParserTest { assertThat(update.get().getLink()).isEqualTo("download-3"); } + private AdvancedHttpResponse request(String url) throws IOException { + return client.get(url).spanKind(SPAN_KIND).request(); + } + @Test void shouldHandleTimeout() throws IOException { String url = "https://www.scm-manager.org/download/rss.xml"; Semaphore waitWithResultUntilTimeout = new Semaphore(0); - when(client.get(url).request().contentFromXml(ReleaseFeedDto.class)).thenAnswer(invocation -> { + when(request(url).contentFromXml(ReleaseFeedDto.class)).thenAnswer(invocation -> { waitWithResultUntilTimeout.acquire(); return createReleaseFeedDto(); }); @@ -95,7 +101,7 @@ class ReleaseFeedParserTest { Semaphore waitWithResultUntilBothTriggered = new Semaphore(0); - when(client.get(url).request().contentFromXml(ReleaseFeedDto.class)).thenAnswer(invocation -> { + when(request(url).contentFromXml(ReleaseFeedDto.class)).thenAnswer(invocation -> { waitWithResultUntilBothTriggered.acquire(); return createReleaseFeedDto(); }); diff --git a/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpClientTest.java b/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpClientTest.java index 7da5ddfb8a..89004ece27 100644 --- a/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpClientTest.java +++ b/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpClientTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.net.ahc; //~--- non-JDK imports -------------------------------------------------------- @@ -29,33 +29,29 @@ package sonia.scm.net.ahc; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; - import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; - import sonia.scm.config.ScmConfiguration; +import sonia.scm.net.SSLContextProvider; import sonia.scm.net.TrustAllHostnameVerifier; +import sonia.scm.trace.Span; +import sonia.scm.trace.Tracer; import sonia.scm.util.HttpUtil; -import static org.junit.Assert.*; - -import static org.mockito.Mockito.*; - -//~--- JDK imports ------------------------------------------------------------ - -import java.io.ByteArrayOutputStream; -import java.io.IOException; - -import java.net.HttpURLConnection; -import java.net.SocketAddress; -import java.net.URL; - -import java.util.HashSet; -import java.util.Set; - import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; -import sonia.scm.net.SSLContextProvider; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketAddress; +import java.net.URL; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +//~--- JDK imports ------------------------------------------------------------ /** * @@ -82,12 +78,12 @@ public class DefaultAdvancedHttpClientTest DefaultAdvancedHttpClient.TIMEOUT_CONNECTION); verify(connection).addRequestProperty(HttpUtil.HEADER_CONTENT_LENGTH, "0"); } - + @Test(expected = ContentTransformerNotFoundException.class) public void testContentTransformerNotFound(){ client.createTransformer(String.class, "text/plain"); } - + @Test public void testContentTransformer(){ ContentTransformer transformer = mock(ContentTransformer.class); @@ -265,6 +261,63 @@ public class DefaultAdvancedHttpClientTest "Basic dHJpY2lhOnRyaWNpYXMgc2VjcmV0"); } + @Test + public void shouldCreateTracingSpan() throws IOException { + when(connection.getResponseCode()).thenReturn(200); + + new AdvancedHttpRequest(client, HttpMethod.GET, "https://www.scm-manager.org").spanKind("spaceships").request(); + verify(tracer).span("spaceships"); + verify(span).label("url", "https://www.scm-manager.org"); + verify(span).label("method", "GET"); + verify(span).label("status", 200); + verify(span, never()).failed(); + verify(span).close(); + } + + @Test + public void shouldCreateFailedTracingSpan() throws IOException { + when(connection.getResponseCode()).thenReturn(500); + + new AdvancedHttpRequest(client, HttpMethod.GET, "https://www.scm-manager.org").request(); + verify(tracer).span("HTTP Request"); + verify(span).label("url", "https://www.scm-manager.org"); + verify(span).label("method", "GET"); + verify(span).label("status", 500); + verify(span).failed(); + verify(span).close(); + } + + @Test + public void shouldCreateFailedTracingSpanOnIOException() throws IOException { + when(connection.getResponseCode()).thenThrow(new IOException("failed")); + + boolean thrown = false; + try { + new AdvancedHttpRequest(client, HttpMethod.DELETE, "http://failing.host").spanKind("failures").request(); + } catch (IOException ex) { + thrown = true; + } + assertTrue(thrown); + + verify(tracer).span("failures"); + verify(span).label("url", "http://failing.host"); + verify(span).label("method", "DELETE"); + verify(span).label("exception", IOException.class.getName()); + verify(span).label("message", "failed"); + verify(span).failed(); + verify(span).close(); + } + + @Test + public void shouldNotCreateSpan() throws IOException { + when(connection.getResponseCode()).thenReturn(200); + + new AdvancedHttpRequest(client, HttpMethod.GET, "https://www.scm-manager.org") + .disableTracing().request(); + verify(tracer, never()).span(anyString()); + } + + //~--- set methods ---------------------------------------------------------- /** @@ -277,6 +330,7 @@ public class DefaultAdvancedHttpClientTest configuration = new ScmConfiguration(); transformers = new HashSet(); client = new TestingAdvacedHttpClient(configuration, transformers); + when(tracer.span(anyString())).thenReturn(span); } //~--- inner classes -------------------------------------------------------- @@ -298,10 +352,9 @@ public class DefaultAdvancedHttpClientTest * @param configuration * @param transformers */ - public TestingAdvacedHttpClient(ScmConfiguration configuration, - Set transformers) + public TestingAdvacedHttpClient(ScmConfiguration configuration, Set transformers) { - super(configuration, transformers, new SSLContextProvider()); + super(configuration, tracer, transformers, new SSLContextProvider()); } //~--- methods ------------------------------------------------------------ @@ -364,4 +417,10 @@ public class DefaultAdvancedHttpClientTest /** Field description */ private Set transformers; + + @Mock + private Tracer tracer; + + @Mock + private Span span; } diff --git a/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpResponseTest.java b/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpResponseTest.java index 0496b318e6..e358820a2e 100644 --- a/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpResponseTest.java +++ b/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpResponseTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.net.ahc; //~--- non-JDK imports -------------------------------------------------------- @@ -33,9 +33,11 @@ import com.google.common.collect.Multimap; import com.google.common.io.ByteSource; import org.hamcrest.Matchers; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Answers; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; @@ -56,6 +58,7 @@ import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import sonia.scm.net.SSLContextProvider; +import sonia.scm.trace.Tracer; /** * @@ -65,6 +68,13 @@ import sonia.scm.net.SSLContextProvider; public class DefaultAdvancedHttpResponseTest { + private DefaultAdvancedHttpClient client; + + @Before + public void setUpClient() { + client = new DefaultAdvancedHttpClient(new ScmConfiguration(), tracer, new HashSet<>(), new SSLContextProvider()); + } + /** * Method description * @@ -130,13 +140,10 @@ public class DefaultAdvancedHttpResponseTest assertTrue(headers.get("Test-2").isEmpty()); } - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final DefaultAdvancedHttpClient client = - new DefaultAdvancedHttpClient(new ScmConfiguration(), new HashSet<>(), new SSLContextProvider()); - /** Field description */ @Mock private HttpURLConnection connection; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private Tracer tracer; } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java index cc59e42f62..ebd48a3b24 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java @@ -32,6 +32,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.event.ScmEventBus; import sonia.scm.net.ahc.AdvancedHttpClient; +import sonia.scm.net.ahc.AdvancedHttpResponse; import java.io.IOException; import java.util.Collections; @@ -41,6 +42,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static sonia.scm.plugin.Tracing.SPAN_KIND; @ExtendWith(MockitoExtension.class) class PluginCenterLoaderTest { @@ -63,16 +65,20 @@ class PluginCenterLoaderTest { void shouldFetch() throws IOException { Set plugins = Collections.emptySet(); PluginCenterDto dto = new PluginCenterDto(); - when(client.get(PLUGIN_URL).request().contentFromJson(PluginCenterDto.class)).thenReturn(dto); + when(request().contentFromJson(PluginCenterDto.class)).thenReturn(dto); when(mapper.map(dto)).thenReturn(plugins); Set fetched = loader.load(PLUGIN_URL); assertThat(fetched).isSameAs(plugins); } + private AdvancedHttpResponse request() throws IOException { + return client.get(PLUGIN_URL).spanKind(SPAN_KIND).request(); + } + @Test void shouldReturnEmptySetIfPluginCenterNotBeReached() throws IOException { - when(client.get(PLUGIN_URL).request()).thenThrow(new IOException("failed to fetch")); + when(request()).thenThrow(new IOException("failed to fetch")); Set fetch = loader.load(PLUGIN_URL); assertThat(fetch).isEmpty(); @@ -80,7 +86,7 @@ class PluginCenterLoaderTest { @Test void shouldFirePluginCenterErrorEvent() throws IOException { - when(client.get(PLUGIN_URL).request()).thenThrow(new IOException("failed to fetch")); + when(request()).thenThrow(new IOException("failed to fetch")); loader.load(PLUGIN_URL); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java index a619289094..7d18082833 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -34,6 +34,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.SCMContextProvider; import sonia.scm.net.ahc.AdvancedHttpClient; +import sonia.scm.net.ahc.AdvancedHttpResponse; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -50,6 +51,7 @@ import static org.mockito.Mockito.anyInt; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static sonia.scm.plugin.Tracing.SPAN_KIND; @ExtendWith({MockitoExtension.class}) class PluginInstallerTest { @@ -101,10 +103,14 @@ class PluginInstallerTest { } private void mockContent(String content) throws IOException { - when(client.get("https://download.hitchhiker.com").request().contentAsStream()) + when(request("https://download.hitchhiker.com").contentAsStream()) .thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); } + private AdvancedHttpResponse request(String url) throws IOException { + return client.get(url).spanKind(SPAN_KIND).request(); + } + private AvailablePlugin createGitPlugin() { return createPlugin( "scm-git-plugin", @@ -115,7 +121,7 @@ class PluginInstallerTest { @Test void shouldThrowPluginDownloadException() throws IOException { - when(client.get("https://download.hitchhiker.com").request()).thenThrow(new IOException("failed to download")); + when(request("https://download.hitchhiker.com")).thenThrow(new IOException("failed to download")); PluginInstallationContext context = PluginInstallationContext.empty(); AvailablePlugin gitPlugin = createGitPlugin(); @@ -136,7 +142,7 @@ class PluginInstallerTest { void shouldThrowPluginDownloadExceptionAndCleanup() throws IOException { InputStream stream = mock(InputStream.class); when(stream.read(any(), anyInt(), anyInt())).thenThrow(new IOException("failed to read")); - when(client.get("https://download.hitchhiker.com").request().contentAsStream()).thenReturn(stream); + when(request("https://download.hitchhiker.com").contentAsStream()).thenReturn(stream); PluginInstallationContext context = PluginInstallationContext.empty(); AvailablePlugin gitPlugin = createGitPlugin(); diff --git a/scm-webapp/src/test/java/sonia/scm/trace/LoggingExporterTest.java b/scm-webapp/src/test/java/sonia/scm/trace/LoggingExporterTest.java new file mode 100644 index 0000000000..b1e285a5e2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/trace/LoggingExporterTest.java @@ -0,0 +1,88 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.trace; + +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoggingExporterTest { + + private String message; + + private LoggingExporter exporter; + + @BeforeEach + void setUpLogger() { + exporter = new LoggingExporter((message) -> this.message = message); + } + + @Test + void shouldLogTheSpanKind() { + exporter.export(new SpanContext( + "AwesomeSpanKind", Collections.emptyMap(), Instant.now(), Instant.now(), false + )); + + assertThat(message).contains("AwesomeSpanKind"); + } + + @Test + void shouldLogFailed() { + exporter.export(new SpanContext( + "sample", Collections.emptyMap(), Instant.now(), Instant.now(), true + )); + + assertThat(message).contains("failed"); + } + + @Test + void shouldLogDuration() { + Instant opened = Instant.now(); + exporter.export(new SpanContext( + "sample", ImmutableMap.of(), opened, opened.plusMillis(42L), false + )); + + assertThat(message).contains("42ms"); + } + + @Test + void shouldLogLabels() { + exporter.export(new SpanContext( + "sample", ImmutableMap.of("l1", "v1", "l2", "v2"), Instant.now(), Instant.now(), false + )); + + assertThat(message) + .contains("l1") + .contains("v1") + .contains("l2") + .contains("v2"); + } + +} diff --git a/yarn.lock b/yarn.lock index 92d3df06cc..90a978e77d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12435,10 +12435,10 @@ mini-create-react-context@^0.4.0: "@babel/runtime" "^7.5.5" tiny-warning "^1.0.3" -mini-css-extract-plugin@^0.11.0: - version "0.11.2" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.2.tgz#e3af4d5e04fbcaaf11838ab230510073060b37bf" - integrity sha512-h2LknfX4U1kScXxH8xE9LCOqT5B+068EAj36qicMb8l4dqdJoyHcmWmpd+ueyZfgu/POvIn+teoUnTtei2ikug== +mini-css-extract-plugin@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.12.0.tgz#ddeb74fd6304ca9f99c1db74acc7d5b507705454" + integrity sha512-z6PQCe9rd1XUwZ8gMaEVwwRyZlrYy8Ba1gRjFP5HcV51HkXX+XlwZ+a1iAYTjSYwgNBXoNR7mhx79mDpOn5fdw== dependencies: loader-utils "^1.1.0" normalize-url "1.9.1"