From ce4b75c941daac3f4abedd6538c3a210bf27e3e3 Mon Sep 17 00:00:00 2001 From: Stephan Schnabel Date: Mon, 13 Dec 2021 14:12:39 +0100 Subject: [PATCH] Add path mdc filter. --- README.md | 1 + docs/features/http_mdc_path.md | 25 +++++ .../logging/http/mdc/PathMdcFilter.java | 88 +++++++++++++++ .../logging/http/AbstractFilterTest.java | 12 +- .../http/level/LogLevelServerFilterTest.java | 2 +- .../http/mdc/AuthenticationMdcFilterTest.java | 2 +- .../logging/http/mdc/HeaderMdcFilterTest.java | 2 +- .../logging/http/mdc/PathMdcFilterTest.java | 104 ++++++++++++++++++ 8 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 docs/features/http_mdc_path.md create mode 100644 src/main/java/io/kokuwa/micronaut/logging/http/mdc/PathMdcFilter.java create mode 100644 src/test/java/io/kokuwa/micronaut/logging/http/mdc/PathMdcFilterTest.java diff --git a/README.md b/README.md index 290de0d..11f4f85 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ * [add default xml](docs/features/logback_default.md) * [preconfigured appender for different environments](docs/features/logback_appender.md) * [set log level based on HTTP request header](docs/features/http_log_level.md) +* [add HTTP path parts to MDC](docs/features/http_mdc_path.md) * [add HTTP header to MDC](docs/features/http_mdc_header.md) * [add authentication information from HTTP request to MDC](docs/features/http_mdc_authentication.md) diff --git a/docs/features/http_mdc_path.md b/docs/features/http_mdc_path.md new file mode 100644 index 0000000..b5f9f73 --- /dev/null +++ b/docs/features/http_mdc_path.md @@ -0,0 +1,25 @@ +# Add HTTP path parts to MDC + +## Properties + +Property | Description | Default +-------- | ----------- | ------- +`logger.http.path.enabled` | filter enabled? | `true` +`logger.http.path.path` | filter path | `/**` +`logger.http.path.order` | order for [Ordered](https://github.com/micronaut-projects/micronaut-core/blob/v3.2.0/core/src/main/java/io/micronaut/core/order/Ordered.java) | [ServerFilterPhase.FIRST.before()](https://github.com/micronaut-projects/micronaut-core/blob/v3.2.0/http/src/main/java/io/micronaut/http/filter/ServerFilterPhase.java#L34) +`logger.http.path.prefix` | prefix to MDC key | `` +`logger.http.path.patterns` | patterns with groups to add to MDC | `[]` + +## Examples + +Configuration for adding ids: + +```yaml +logger: + http: + path: + prefix: path. + patterns: + - \/gateway\/(?[a-f0-9\-]{36}) + - \/gateway\/(?[a-f0-9\-]{36})\/configuration\/(?[a-z]+) +``` diff --git a/src/main/java/io/kokuwa/micronaut/logging/http/mdc/PathMdcFilter.java b/src/main/java/io/kokuwa/micronaut/logging/http/mdc/PathMdcFilter.java new file mode 100644 index 0000000..c8c490a --- /dev/null +++ b/src/main/java/io/kokuwa/micronaut/logging/http/mdc/PathMdcFilter.java @@ -0,0 +1,88 @@ +package io.kokuwa.micronaut.logging.http.mdc; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.reactivestreams.Publisher; + +import io.kokuwa.micronaut.logging.http.AbstractMdcFilter; +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.annotation.Value; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Filter; +import io.micronaut.http.filter.ServerFilterChain; +import io.micronaut.http.filter.ServerFilterPhase; +import io.micronaut.runtime.context.scope.Refreshable; +import lombok.extern.slf4j.Slf4j; + +/** + * Filter to add request path parts to MDC. + * + * @author Stephan Schnabel + */ +@Refreshable +@Requires(property = PathMdcFilter.PREFIX + ".enabled", notEquals = StringUtils.FALSE) +@Requires(property = PathMdcFilter.PREFIX + ".patterns") +@Filter("${" + PathMdcFilter.PREFIX + ".path:/**}") +@Slf4j +public class PathMdcFilter extends AbstractMdcFilter { + + public static final String PREFIX = "logger.http.path"; + public static final int DEFAULT_ORDER = ServerFilterPhase.FIRST.before(); + public static final Pattern PATTERN_GROUPS = Pattern.compile("\\(\\?<([a-zA-Z][a-zA-Z0-9]+)>"); + + private final Map> patternsWithGroups; + + public PathMdcFilter( + @Value("${" + PREFIX + ".patterns}") List patterns, + @Value("${" + PREFIX + ".prefix}") Optional prefix, + @Value("${" + PREFIX + ".order}") Optional order) { + super(order.orElse(DEFAULT_ORDER), prefix.orElse(null)); + this.patternsWithGroups = new HashMap<>(); + for (var patternString : patterns) { + try { + var pattern = Pattern.compile(patternString); + var groupMatcher = PATTERN_GROUPS.matcher(pattern.toString()); + var groups = new HashSet(); + while (groupMatcher.find()) { + groups.add(groupMatcher.group(1)); + } + + if (groups.isEmpty()) { + log.warn("Path {} is missing groups.", patternString); + } else { + log.info("Added path {} with groups {}.", patternString, groups); + patternsWithGroups.put(pattern, groups); + } + } catch (PatternSyntaxException e) { + log.warn("Path {} is invalid.", patternString); + } + } + } + + @Override + public Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + + var mdc = new HashMap(); + var path = request.getPath(); + + for (var patternWithGroup : patternsWithGroups.entrySet()) { + var matcher = patternWithGroup.getKey().matcher(path); + if (matcher.matches()) { + for (var group : patternWithGroup.getValue()) { + mdc.put(group, matcher.group(group)); + } + } + } + + return doFilter(request, chain, mdc); + } +} diff --git a/src/test/java/io/kokuwa/micronaut/logging/http/AbstractFilterTest.java b/src/test/java/io/kokuwa/micronaut/logging/http/AbstractFilterTest.java index 11d6676..46fd6cd 100644 --- a/src/test/java/io/kokuwa/micronaut/logging/http/AbstractFilterTest.java +++ b/src/test/java/io/kokuwa/micronaut/logging/http/AbstractFilterTest.java @@ -21,6 +21,7 @@ import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpStatus; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.PathVariable; import io.micronaut.http.client.DefaultHttpClientConfiguration; import io.micronaut.http.client.HttpClient; import io.micronaut.http.filter.HttpServerFilter; @@ -79,9 +80,9 @@ public abstract class AbstractFilterTest extends AbstractTest { // request @SneakyThrows - public TestResponse get(Map headers) { + public TestResponse get(String path, Map headers) { - var request = HttpRequest.GET("/"); + var request = HttpRequest.GET(path); headers.forEach((name, value) -> request.header(name, value)); var configuration = new DefaultHttpClientConfiguration(); configuration.setLoggerName("io.kokuwa.TestClient"); @@ -100,8 +101,8 @@ public abstract class AbstractFilterTest extends AbstractTest { @Slf4j public static class TestController { - @Get("/") - TestResponse run() { + @Get("/{+path}") + TestResponse run(@PathVariable String path) { var level = Level.OFF; if (log.isTraceEnabled()) { @@ -119,7 +120,7 @@ public abstract class AbstractFilterTest extends AbstractTest { var mdc = MDC.getCopyOfContextMap(); log.info("Found MDC: {}", mdc); - return new TestResponse(level.toString(), mdc == null ? Map.of() : mdc); + return new TestResponse(path, level.toString(), mdc == null ? Map.of() : mdc); } } @@ -127,6 +128,7 @@ public abstract class AbstractFilterTest extends AbstractTest { @NoArgsConstructor @AllArgsConstructor public static class TestResponse { + private String path; private String level; private Map context = Map.of(); } diff --git a/src/test/java/io/kokuwa/micronaut/logging/http/level/LogLevelServerFilterTest.java b/src/test/java/io/kokuwa/micronaut/logging/http/level/LogLevelServerFilterTest.java index b25a6af..c4db1ef 100644 --- a/src/test/java/io/kokuwa/micronaut/logging/http/level/LogLevelServerFilterTest.java +++ b/src/test/java/io/kokuwa/micronaut/logging/http/level/LogLevelServerFilterTest.java @@ -75,6 +75,6 @@ public class LogLevelServerFilterTest extends AbstractFilterTest { private void assertLevel(Level expectedLevel, String name, String value) { var headers = value == null ? Map.of() : Map.of(name, value); - assertEquals(expectedLevel.toString(), get(headers).getLevel()); + assertEquals(expectedLevel.toString(), get("/level", headers).getLevel()); } } diff --git a/src/test/java/io/kokuwa/micronaut/logging/http/mdc/AuthenticationMdcFilterTest.java b/src/test/java/io/kokuwa/micronaut/logging/http/mdc/AuthenticationMdcFilterTest.java index 0492139..d10b673 100644 --- a/src/test/java/io/kokuwa/micronaut/logging/http/mdc/AuthenticationMdcFilterTest.java +++ b/src/test/java/io/kokuwa/micronaut/logging/http/mdc/AuthenticationMdcFilterTest.java @@ -63,7 +63,7 @@ public class AuthenticationMdcFilterTest extends AbstractFilterTest { } private Map getContext(boolean token) { - return get(token + return get("/security", token ? Map.of(HttpHeaders.AUTHORIZATION, token("mySubject", claims -> claims .issuer("nope") .claim("azp", "myAzp") diff --git a/src/test/java/io/kokuwa/micronaut/logging/http/mdc/HeaderMdcFilterTest.java b/src/test/java/io/kokuwa/micronaut/logging/http/mdc/HeaderMdcFilterTest.java index 8ef7673..489870f 100644 --- a/src/test/java/io/kokuwa/micronaut/logging/http/mdc/HeaderMdcFilterTest.java +++ b/src/test/java/io/kokuwa/micronaut/logging/http/mdc/HeaderMdcFilterTest.java @@ -55,6 +55,6 @@ public class HeaderMdcFilterTest extends AbstractFilterTest { } private void assertContext(Map expectedMdcs, Map headers) { - assertEquals(expectedMdcs, get(headers).getContext()); + assertEquals(expectedMdcs, get("/header", headers).getContext()); } } diff --git a/src/test/java/io/kokuwa/micronaut/logging/http/mdc/PathMdcFilterTest.java b/src/test/java/io/kokuwa/micronaut/logging/http/mdc/PathMdcFilterTest.java new file mode 100644 index 0000000..3654d4d --- /dev/null +++ b/src/test/java/io/kokuwa/micronaut/logging/http/mdc/PathMdcFilterTest.java @@ -0,0 +1,104 @@ +package io.kokuwa.micronaut.logging.http.mdc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.kokuwa.micronaut.logging.http.AbstractFilterTest; +import io.micronaut.context.annotation.Property; + +/** + * Test for {@link PathMdcFilter}. + * + * @author Stephan Schnabel + */ +@DisplayName("http: mdc from path") +public class PathMdcFilterTest extends AbstractFilterTest { + + @DisplayName("noop: empty configuration") + @Test + void noopEmptyConfiguration() { + assertContext(Map.of(), "/foo/bar"); + } + + @DisplayName("noop: disabled") + @Test + @Property(name = "logger.http.path.enabled", value = "false") + @Property(name = "logger.http.path.patterns", value = "\\/foo\\/(?[0-9]+)") + void noopDisabled() { + assertContext(Map.of(), "/foo/123"); + } + + @DisplayName("noop: misconfigured") + @Test + @Property(name = "logger.http.path.patterns", value = "\\A{") + void noopMisconfigured() { + assertContext(Map.of(), "/foo/123"); + } + + @DisplayName("noop: no group") + @Test + @Property(name = "logger.http.path.patterns", value = "\\/foo/[0-9]+") + void noopGroups() { + assertContext(Map.of(), "/foo/123"); + } + + @DisplayName("mdc: mismatch") + @Test + @Property(name = "logger.http.path.patterns", value = "\\/foo\\/(?[0-9]+)") + void mdcMismatch() { + assertContext(Map.of(), "/nope"); + assertContext(Map.of(), "/foo/abc"); + } + + @DisplayName("mdc: match with single group") + @Test + @Property(name = "logger.http.path.patterns", value = "\\/foo\\/(?[0-9]+)") + void mdcMatchWithSingleGroup() { + assertContext(Map.of("foo", "123"), "/foo/123"); + } + + @DisplayName("mdc: match with single group and prefix") + @Test + @Property(name = "logger.http.path.names", value = "foo") + @Property(name = "logger.http.path.patterns", value = "\\/foo\\/(?[0-9]+)") + @Property(name = "logger.http.path.prefix", value = "path.") + void mdcMatchWithSingleGroupAndPrefix() { + assertContext(Map.of("path.foo", "123"), "/foo/123"); + } + + @DisplayName("mdc: match with single group and misconfigured") + @Test + @Property(name = "logger.http.path.names", value = "foo") + @Property(name = "logger.http.path.patterns", value = "\\/foo\\/(?[0-9]+),\\A{") + @Property(name = "logger.http.path.prefix", value = "path.") + void mdcMatchWithSingleGroupAndMisconfigured() { + assertContext(Map.of("path.foo", "123"), "/foo/123"); + } + + @DisplayName("mdc: match with multiple group") + @Test + @Property(name = "logger.http.path.patterns", value = "/foo/(?[0-9]+)/bar/(?[0-9]+)") + void mdcMatchWithmultipleGroup() { + assertContext(Map.of("foo", "123", "bar", "456"), "/foo/123/bar/456"); + } + + @DisplayName("mdc: test for documentation example") + @Test + @Property(name = "logger.http.path.patterns", value = "" + + "\\/gateway\\/(?[a-f0-9\\-]{36})," + + "\\/gateway\\/(?[a-f0-9\\-]{36})\\/configuration\\/(?[a-z]+)") + void mdcMatchExample() { + var uuid = UUID.randomUUID().toString(); + assertContext(Map.of("gatewayId", uuid), "/gateway/" + uuid); + assertContext(Map.of("gatewayId", uuid, "config", "abc"), "/gateway/" + uuid + "/configuration/abc"); + } + + private void assertContext(Map expectedMdcs, String path) { + assertEquals(expectedMdcs, get(path, Map.of()).getContext()); + } +}