From bef51b7c61d214f6a4ff3d52ca16c9aad17dc135 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Mon, 26 Oct 2020 15:41:32 +0100 Subject: [PATCH 1/7] Add trace api to trace long running tasks --- .../main/java/sonia/scm/trace/Exporter.java | 43 +++++ .../src/main/java/sonia/scm/trace/Span.java | 148 ++++++++++++++++++ .../java/sonia/scm/trace/SpanContext.java | 58 +++++++ .../src/main/java/sonia/scm/trace/Tracer.java | 83 ++++++++++ .../test/java/sonia/scm/trace/TracerTest.java | 130 +++++++++++++++ 5 files changed, 462 insertions(+) create mode 100644 scm-core/src/main/java/sonia/scm/trace/Exporter.java create mode 100644 scm-core/src/main/java/sonia/scm/trace/Span.java create mode 100644 scm-core/src/main/java/sonia/scm/trace/SpanContext.java create mode 100644 scm-core/src/main/java/sonia/scm/trace/Tracer.java create mode 100644 scm-core/src/test/java/sonia/scm/trace/TracerTest.java 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..a386c72b19 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/trace/Span.java @@ -0,0 +1,148 @@ +/* + * 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.Clock; +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 Clock clock; + 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, kind, Clock.systemUTC()); + } + + Span(Tracer tracer, String kind, Clock clock) { + this.clock = clock; + this.tracer = tracer; + this.kind = kind; + this.opened = clock.instant(); + } + + /** + * 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, clock.instant(), 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..964068d71e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/trace/SpanContext.java @@ -0,0 +1,58 @@ +/* + * 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.Value; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +/** + * The {@link SpanContext} represents a finsished span which could be processed by an {@link Exporter}. + * @since 2.9.0 + */ +@Value +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public final class SpanContext { + + String kind; + Map labels; + Instant opened; + Instant closed; + boolean failed; + + /** + * 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/TracerTest.java b/scm-core/src/test/java/sonia/scm/trace/TracerTest.java new file mode 100644 index 0000000000..db9d36001e --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/trace/TracerTest.java @@ -0,0 +1,130 @@ +/* + * 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); + } catch (Exception ex) { + span. + } + + 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; + } + } + +} From c5bf122132ff6251604320b6e498ca816b1e028c Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Mon, 26 Oct 2020 16:51:57 +0100 Subject: [PATCH 2/7] Implemented default LoggingExporter for trace api --- .../src/main/java/sonia/scm/trace/Span.java | 11 +-- .../java/sonia/scm/trace/SpanContext.java | 8 +- .../test/java/sonia/scm/trace/TracerTest.java | 3 - .../java/sonia/scm/trace/LoggingExporter.java | 78 ++++++++++++++++ .../sonia/scm/trace/LoggingExporterTest.java | 88 +++++++++++++++++++ 5 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/trace/LoggingExporter.java create mode 100644 scm-webapp/src/test/java/sonia/scm/trace/LoggingExporterTest.java diff --git a/scm-core/src/main/java/sonia/scm/trace/Span.java b/scm-core/src/main/java/sonia/scm/trace/Span.java index a386c72b19..6c1ff8ed6e 100644 --- a/scm-core/src/main/java/sonia/scm/trace/Span.java +++ b/scm-core/src/main/java/sonia/scm/trace/Span.java @@ -24,7 +24,6 @@ package sonia.scm.trace; -import java.time.Clock; import java.time.Instant; import java.util.Collections; import java.util.LinkedHashMap; @@ -37,7 +36,6 @@ import java.util.Map; */ public final class Span implements AutoCloseable { - private final Clock clock; private final Tracer tracer; private final String kind; private final Map labels = new LinkedHashMap<>(); @@ -45,14 +43,9 @@ public final class Span implements AutoCloseable { private boolean failed; Span(Tracer tracer, String kind) { - this(tracer, kind, Clock.systemUTC()); - } - - Span(Tracer tracer, String kind, Clock clock) { - this.clock = clock; this.tracer = tracer; this.kind = kind; - this.opened = clock.instant(); + this.opened = Instant.now(); } /** @@ -142,7 +135,7 @@ public final class Span implements AutoCloseable { */ @Override public void close() { - tracer.export(new SpanContext(kind, Collections.unmodifiableMap(labels), opened, clock.instant(), failed)); + 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 index 964068d71e..82d3d92db2 100644 --- a/scm-core/src/main/java/sonia/scm/trace/SpanContext.java +++ b/scm-core/src/main/java/sonia/scm/trace/SpanContext.java @@ -24,8 +24,6 @@ package sonia.scm.trace; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Value; import java.time.Duration; @@ -33,12 +31,12 @@ import java.time.Instant; import java.util.Map; /** - * The {@link SpanContext} represents a finsished span which could be processed by an {@link Exporter}. + * The {@link SpanContext} represents a finished span which could be processed by an {@link Exporter}. + * * @since 2.9.0 */ @Value -@AllArgsConstructor(access = AccessLevel.PACKAGE) -public final class SpanContext { +public class SpanContext { String kind; Map labels; diff --git a/scm-core/src/test/java/sonia/scm/trace/TracerTest.java b/scm-core/src/test/java/sonia/scm/trace/TracerTest.java index db9d36001e..12985038f8 100644 --- a/scm-core/src/test/java/sonia/scm/trace/TracerTest.java +++ b/scm-core/src/test/java/sonia/scm/trace/TracerTest.java @@ -34,7 +34,6 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; - class TracerTest { private Tracer tracer; @@ -63,8 +62,6 @@ class TracerTest { try (Span span = tracer.span("sample")) { span.label("l1", "one"); Thread.sleep(1L); - } catch (Exception ex) { - span. } SpanContext span = exporter.spans.get(0); 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/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"); + } + +} From 4edb4064209fc2321bbb908b3051b81a5d8c40fa Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Mon, 26 Oct 2020 16:54:05 +0100 Subject: [PATCH 3/7] Integrate trace api with AdvancedHttpClient --- .../sonia/scm/net/ahc/BaseHttpRequest.java | 49 ++++++++++++---- .../net/ahc/DefaultAdvancedHttpClient.java | 58 +++++++++++-------- .../ahc/DefaultAdvancedHttpClientTest.java | 47 +++++++++++++-- .../ahc/DefaultAdvancedHttpResponseTest.java | 21 ++++--- 4 files changed, 126 insertions(+), 49 deletions(-) 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..bab0b4e0a8 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,19 @@ 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(); + } + //~--- get methods ---------------------------------------------------------- /** @@ -281,6 +292,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 +339,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 +363,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 +420,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-webapp/src/main/java/sonia/scm/net/ahc/DefaultAdvancedHttpClient.java b/scm-webapp/src/main/java/sonia/scm/net/ahc/DefaultAdvancedHttpClient.java index 610ec39533..62db41ad38 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,48 @@ 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 { + try (Span span = tracer.span(request.getSpanKind())) { + span.label("url", request.getUrl()); + span.label("method", request.getMethod()); + DefaultAdvancedHttpResponse response = doRequest(request); + span.label("status", response.getStatus()); + if (!response.isSuccessful()) { + span.failed(); + } + return response; + } + } + + @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); } @@ -309,8 +316,8 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient { logger.error("could not disable certificate validation", ex); } - } - else + } + else { logger.trace("set ssl socker factory from provider"); connection.setSSLSocketFactory(sslContextProvider.get().getSocketFactory()); @@ -380,7 +387,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 Tracer tracer; } 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..4ca513b4e5 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 -------------------------------------------------------- @@ -30,11 +30,14 @@ 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; import sonia.scm.config.ScmConfiguration; 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.*; @@ -82,12 +85,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 +268,32 @@ 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(); + } + //~--- set methods ---------------------------------------------------------- /** @@ -277,6 +306,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 +328,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 +393,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; } From 10d4f7930293f2c3f0b4dfd1a38d66bd2bd6aa3c Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Mon, 26 Oct 2020 16:55:22 +0100 Subject: [PATCH 4/7] Fix SonarQube issues --- .../scm/net/ahc/DefaultAdvancedHttpClient.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 62db41ad38..be3c8bee64 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 @@ -307,7 +307,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()); @@ -337,7 +337,7 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient if (isProxyEnabled(request)) { - connection = openProxyConnection(request, url); + connection = openProxyConnection(url); appendProxyAuthentication(connection); } else @@ -347,7 +347,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); } @@ -355,8 +357,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()) @@ -392,5 +393,5 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient private final Provider sslContextProvider; /** tracer used for request tracing */ - private Tracer tracer; + private final Tracer tracer; } From 066ccdf4f2a29235faa0be1725c14b49eb5d45f4 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 27 Oct 2020 08:15:10 +0100 Subject: [PATCH 5/7] Mark span as failed if an IOException occurs --- .../net/ahc/DefaultAdvancedHttpClient.java | 14 +++++-- .../ahc/DefaultAdvancedHttpClientTest.java | 41 ++++++++++++------- 2 files changed, 37 insertions(+), 18 deletions(-) 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 be3c8bee64..9ea181dd46 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 @@ -193,12 +193,18 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient try (Span span = tracer.span(request.getSpanKind())) { span.label("url", request.getUrl()); span.label("method", request.getMethod()); - DefaultAdvancedHttpResponse response = doRequest(request); - span.label("status", response.getStatus()); - if (!response.isSuccessful()) { + try { + DefaultAdvancedHttpResponse response = doRequest(request); + span.label("status", response.getStatus()); + if (!response.isSuccessful()) { + span.failed(); + } + return response; + } catch (IOException ex) { + span.label("exception", ex.getMessage()); span.failed(); + throw ex; } - return response; } } 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 4ca513b4e5..24b857ea9e 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 @@ -29,36 +29,29 @@ package sonia.scm.net.ahc; 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; - 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 javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; 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 static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +//~--- JDK imports ------------------------------------------------------------ /** * @@ -294,6 +287,26 @@ public class DefaultAdvancedHttpClientTest 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", "failed"); + verify(span).failed(); + verify(span).close(); + } + //~--- set methods ---------------------------------------------------------- /** From 37514eb4684784e2fe58be7ee57c3483c7831771 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 27 Oct 2020 08:28:23 +0100 Subject: [PATCH 6/7] Fix typo in log message --- .../main/java/sonia/scm/net/ahc/DefaultAdvancedHttpClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9ea181dd46..8ef0a22862 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 @@ -325,7 +325,7 @@ public class DefaultAdvancedHttpClient extends AdvancedHttpClient } else { - logger.trace("set ssl socker factory from provider"); + logger.trace("set ssl socket factory from provider"); connection.setSSLSocketFactory(sslContextProvider.get().getSocketFactory()); } From 35c07a92df95bf9795d445db94d6eaf58d79e170 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 27 Oct 2020 14:24:01 +0100 Subject: [PATCH 7/7] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd515bb632..39ae6ebf28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased +### Added +- Tracing api ([#1393](https://github.com/scm-manager/scm-manager/pull/#1393)) + ## [2.8.0] - 2020-10-27 ### Added - Generation of email addresses for users, where none is configured ([#1370](https://github.com/scm-manager/scm-manager/pull/1370))