From 90e76839dc49a3b9469c861640f855b96c06ff8f Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Tue, 25 Jul 2023 12:27:13 +0700 Subject: [PATCH 01/25] chore: Tidyup build issues --- .gitignore | 103 ++++++++++++++++++ pom.xml | 4 +- .../config/SpringLoggerAutoConfiguration.java | 24 ++-- .../logging/filter/SpringLoggerFilter.java | 24 ++-- 4 files changed, 126 insertions(+), 29 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3163428 --- /dev/null +++ b/.gitignore @@ -0,0 +1,103 @@ +# General +*.log +*.keystore +.history/ +*.tgz + +# git-secret rules +.secrets/*.* +!.secrets/*.secret +!.secrets/public-keys/ +.gitsecret/keys/random_seed +.gitsecret/keys/pubring.kbx~ + + +.ci/gradle* + +# You can put a script which call `yarn prettier-eslint --write` that prettify the file in-place. +# Keep in mind the hook files are executed by alphabet sequence: so `in_place_prettier_eslint` will be executed before `prettier_eslint` +buildtools/hooks/pre-commit.d/in_place_prettier_eslint + +# Archives / Binaries +*.jar +*.zip +*.tar.gz +*.so +*.xcarchive + +# MacOS +.DS_Store + +# Build Output +build/ +out/ +bin/ +codemr/ + +# JetBrains +.idea/ +*.iml +*.ipr +*.iws +.mvn/ +.project + +# VSCode +.vscode/ +*.code-workspace + +# Eclipse +.settings/ +.classpath + +# Java +*.hprof +.gradle/ +!gradle-wrapper.jar +!gradle-wrapper.properties + + +# Node or NPM or Yarn +npm-debug.log* +yarn-debug.log* +yarn-error.log* +node_modules/ +.eslintcache + +# Jest/Bamboo test results output +jest.json +coverage/ + +# Vagrant +.vagrant/ + +# Xcode +# +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace +**/*.framework/ + + +# VIM +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-v][a-z] +[._]sw[a-p] +version.yml + +#Andorid +local.properties \ No newline at end of file diff --git a/pom.xml b/pom.xml index 2ac8a70..723967a 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ 0.0.1.SNAPSPOT firetail-java-lib Java Library for Firetail - https://github.com/muhammadn/firetail-java-lib + https://github.com/muhammadn/firetail-java-lib @@ -30,7 +30,7 @@ 2.6 4.0.1 5.3 - 5.2.3.RELEASE + 5.2.21.RELEASE 2.1.4.RELEASE 1.7.26 diff --git a/src/main/java/io/firetail/logging/config/SpringLoggerAutoConfiguration.java b/src/main/java/io/firetail/logging/config/SpringLoggerAutoConfiguration.java index 09d50d8..8b06bb6 100644 --- a/src/main/java/io/firetail/logging/config/SpringLoggerAutoConfiguration.java +++ b/src/main/java/io/firetail/logging/config/SpringLoggerAutoConfiguration.java @@ -1,24 +1,18 @@ package io.firetail.logging.config; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.core.net.ssl.KeyStoreFactoryBean; -import ch.qos.logback.core.net.ssl.SSLConfiguration; //import net.logstash.logback.appender.LogstashTcpSocketAppender; //import net.logstash.logback.encoder.LogstashEncoder; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.ConfigurationProperties; + import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.web.client.RestTemplate; import io.firetail.logging.client.RestTemplateSetHeaderInterceptor; -import io.firetail.logging.filter.SpringLoggingFilter; +import io.firetail.logging.filter.SpringLoggerFilter; import io.firetail.logging.util.UniqueIDGenerator; import javax.annotation.PostConstruct; @@ -28,9 +22,9 @@ @Configuration @ConfigurationProperties(prefix = "logging.logstash") -public class SpringLoggingAutoConfiguration { +public class SpringLoggerAutoConfiguration { - private static final String FIRETAIL_APPENDER_NAME = "FIRETAIL"; + // private static final String FIRETAIL_APPENDER_NAME = "FIRETAIL"; private String url = "localhost:8500"; private String ignorePatterns; @@ -48,15 +42,15 @@ public UniqueIDGenerator generator() { } @Bean - public SpringLoggingFilter loggingFilter() { - return new SpringLoggingFilter(generator(), ignorePatterns, logHeaders); + public SpringLoggerFilter loggingFilter() { + return new SpringLoggerFilter(generator(), ignorePatterns, logHeaders); } @Bean @ConditionalOnMissingBean(RestTemplate.class) public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate(); - List interceptorList = new ArrayList(); + List interceptorList = new ArrayList<>(); interceptorList.add(new RestTemplateSetHeaderInterceptor()); restTemplate.setInterceptors(interceptorList); return restTemplate; @@ -94,11 +88,11 @@ public FiretailTcpSocketAppender firetailAppender() { @PostConstruct public void init() { template.ifPresent(restTemplate -> { - List interceptorList = new ArrayList(); + List interceptorList = new ArrayList<>(); interceptorList.add(new RestTemplateSetHeaderInterceptor()); restTemplate.setInterceptors(interceptorList); }); - } + } public String getUrl() { return url; diff --git a/src/main/java/io/firetail/logging/filter/SpringLoggerFilter.java b/src/main/java/io/firetail/logging/filter/SpringLoggerFilter.java index ce11374..61e2088 100644 --- a/src/main/java/io/firetail/logging/filter/SpringLoggerFilter.java +++ b/src/main/java/io/firetail/logging/filter/SpringLoggerFilter.java @@ -1,5 +1,8 @@ package io.firetail.logging.filter; +import io.firetail.logging.util.UniqueIDGenerator; +import io.firetail.logging.wrapper.SpringRequestWrapper; +import io.firetail.logging.wrapper.SpringResponseWrapper; import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -9,31 +12,28 @@ import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerExecutionChain; -import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; -import io.firetail.logging.util.UniqueIDGenerator; -import io.firetail.logging.wrapper.SpringRequestWrapper; -import io.firetail.logging.wrapper.SpringResponseWrapper; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.Map; import java.util.Objects; -public class SpringLoggingFilter extends OncePerRequestFilter { +import static net.logstash.logback.argument.StructuredArguments.value; + +public class SpringLoggerFilter extends OncePerRequestFilter { - private static final Logger LOGGER = LoggerFactory.getLogger(SpringLoggingFilter.class); - private UniqueIDGenerator generator; - private String ignorePatterns; - private boolean logHeaders; + private static final Logger LOGGER = LoggerFactory.getLogger(SpringLoggerFilter.class); + private final UniqueIDGenerator generator; + private final String ignorePatterns; + private final boolean logHeaders; @Autowired ApplicationContext context; - public SpringLoggingFilter(UniqueIDGenerator generator, String ignorePatterns, boolean logHeaders) { + public SpringLoggerFilter(UniqueIDGenerator generator, String ignorePatterns, boolean logHeaders) { this.generator = generator; this.ignorePatterns = ignorePatterns; this.logHeaders = logHeaders; @@ -88,7 +88,7 @@ private void logResponse(long startTime, SpringResponseWrapper wrappedResponse, private void getHandlerMethod(HttpServletRequest request) throws Exception { RequestMappingHandlerMapping mappings1 = (RequestMappingHandlerMapping) context.getBean("requestMappingHandlerMapping"); - Map handlerMethods = mappings1.getHandlerMethods(); + // Map handlerMethods = mappings1.getHandlerMethods(); HandlerExecutionChain handler = mappings1.getHandler(request); if (Objects.nonNull(handler)) { HandlerMethod handler1 = (HandlerMethod) handler.getHandler(); From cd5044f03ad881f5d2027a760c4e7ccdadb5b122 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Thu, 3 Aug 2023 15:17:35 +0800 Subject: [PATCH 02/25] refactor: Kotlin/Gradle --- build.gradle.kts | 59 +++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63375 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++++++++++++++ gradlew.bat | 92 +++++++ pom.xml | 2 +- settings.gradle.kts | 10 + .../RestTemplateSetHeaderInterceptor.java | 19 -- .../config/SpringLoggerAutoConfiguration.java | 136 ---------- .../logging/filter/SpringLoggerFilter.java | 99 ------- .../logging/util/UniqueIDGenerator.java | 26 -- .../wrapper/ServletOutputStreamWrapper.java | 39 --- .../logging/wrapper/SpringRequestWrapper.java | 57 ---- .../wrapper/SpringResponseWrapper.java | 77 ------ .../kotlin/io/firetail/logging/Constants.kt | 12 + .../RestTemplateSetHeaderInterceptor.kt | 21 ++ .../config/SpringLoggerAutoConfiguration.kt | 91 +++++++ .../logging/filter/SpringLoggerFilter.kt | 120 +++++++++ .../io/firetail/logging/util/Generator.kt | 9 + .../logging/util/UniqueIDGenerator.kt | 18 ++ .../wrapper/ServletOutputStreamWrapper.kt | 25 ++ .../logging/wrapper/SpringRequestWrapper.kt | 51 ++++ .../logging/wrapper/SpringResponseWrapper.kt | 51 ++++ .../io/firetail/logging/IdGeneratorTests.kt | 45 ++++ 24 files changed, 860 insertions(+), 454 deletions(-) create mode 100644 build.gradle.kts create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts delete mode 100644 src/main/java/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.java delete mode 100644 src/main/java/io/firetail/logging/config/SpringLoggerAutoConfiguration.java delete mode 100644 src/main/java/io/firetail/logging/filter/SpringLoggerFilter.java delete mode 100644 src/main/java/io/firetail/logging/util/UniqueIDGenerator.java delete mode 100644 src/main/java/io/firetail/logging/wrapper/ServletOutputStreamWrapper.java delete mode 100644 src/main/java/io/firetail/logging/wrapper/SpringRequestWrapper.java delete mode 100644 src/main/java/io/firetail/logging/wrapper/SpringResponseWrapper.java create mode 100644 src/main/kotlin/io/firetail/logging/Constants.kt create mode 100644 src/main/kotlin/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.kt create mode 100644 src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt create mode 100644 src/main/kotlin/io/firetail/logging/filter/SpringLoggerFilter.kt create mode 100644 src/main/kotlin/io/firetail/logging/util/Generator.kt create mode 100644 src/main/kotlin/io/firetail/logging/util/UniqueIDGenerator.kt create mode 100644 src/main/kotlin/io/firetail/logging/wrapper/ServletOutputStreamWrapper.kt create mode 100644 src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt create mode 100644 src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt create mode 100644 src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ad3aded --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,59 @@ + +plugins { + `java-library` + `maven-publish` + // Not possible to set the version for a plugin from a variable. + kotlin("plugin.spring") version "1.8.21" + kotlin("jvm") version "1.8.21" + id("io.spring.dependency-management") version "1.1.2" +} + +repositories { + mavenLocal() + maven { + url = uri("https://repo.maven.apache.org/maven2/") + } + mavenCentral() +} + +group = "com.github.firetail-io" +version = "0.0.1.SNAPSHOT" +description = "firetail-java-lib" +java.sourceCompatibility = JavaVersion.VERSION_1_8 + +dependencies { + implementation( + platform("org.springframework.boot:spring-boot-dependencies:2.7.14"), + ) + api("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + api("commons-io:commons-io:2.7") + api("net.logstash.logback:logstash-logback-encoder:7.4") + api("javax.annotation:javax.annotation-api:1.3.2") + // Dependencies are transitively imported from spring-boot-dependencies + api("org.slf4j:slf4j-api") + api("ch.qos.logback:logback-classic") + compileOnly("javax.servlet:javax.servlet-api") + compileOnly("org.springframework.boot:spring-boot-autoconfigure") + compileOnly("org.springframework:spring-context") + compileOnly("org.springframework:spring-web") + compileOnly("org.springframework:spring-web") + compileOnly("org.springframework:spring-webmvc") + testImplementation(kotlin("test")) + testImplementation("javax.servlet:javax.servlet-api") + testImplementation("org.springframework:spring-test") + testImplementation("org.assertj:assertj-core") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0") +} + +publishing { + publications.create("maven") { + from(components["java"]) + } +} +kotlin { + jvmToolchain(8) +} + +tasks.test { // See 5️⃣ + useJUnitPlatform() // JUnitPlatform for tests. See 6️⃣ +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..033e24c4cdf41af1ab109bc7f253b2b887023340 GIT binary patch literal 63375 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≫L+crb^Bz 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/pom.xml b/pom.xml index 723967a..252c401 100644 --- a/pom.xml +++ b/pom.xml @@ -74,7 +74,7 @@ org.springframework.boot spring-boot-autoconfigure - ${spring.boot.version} + ${spring.boot.version} provided diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..2c8deb4 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,10 @@ +pluginManagement { + plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") +} + +rootProject.name = "firetail-java-lib" diff --git a/src/main/java/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.java b/src/main/java/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.java deleted file mode 100644 index 5965953..0000000 --- a/src/main/java/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.firetail.logging.client; - -import org.slf4j.MDC; -import org.springframework.http.HttpRequest; -import org.springframework.http.client.ClientHttpRequestExecution; -import org.springframework.http.client.ClientHttpRequestInterceptor; -import org.springframework.http.client.ClientHttpResponse; - -import java.io.IOException; - -public class RestTemplateSetHeaderInterceptor implements ClientHttpRequestInterceptor { - - public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { - request.getHeaders().add("X-Correlation-ID", MDC.get("X-Correlation-ID")); - request.getHeaders().add("X-Request-ID", MDC.get("X-Request-ID")); - return execution.execute(request, body); - } - -} diff --git a/src/main/java/io/firetail/logging/config/SpringLoggerAutoConfiguration.java b/src/main/java/io/firetail/logging/config/SpringLoggerAutoConfiguration.java deleted file mode 100644 index 8b06bb6..0000000 --- a/src/main/java/io/firetail/logging/config/SpringLoggerAutoConfiguration.java +++ /dev/null @@ -1,136 +0,0 @@ -package io.firetail.logging.config; - -//import net.logstash.logback.appender.LogstashTcpSocketAppender; -//import net.logstash.logback.encoder.LogstashEncoder; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; - import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.ClientHttpRequestInterceptor; -import org.springframework.web.client.RestTemplate; - -import io.firetail.logging.client.RestTemplateSetHeaderInterceptor; -import io.firetail.logging.filter.SpringLoggerFilter; -import io.firetail.logging.util.UniqueIDGenerator; - -import javax.annotation.PostConstruct; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -@Configuration -@ConfigurationProperties(prefix = "logging.logstash") -public class SpringLoggerAutoConfiguration { - - // private static final String FIRETAIL_APPENDER_NAME = "FIRETAIL"; - - private String url = "localhost:8500"; - private String ignorePatterns; - private boolean logHeaders; - private String trustStoreLocation; - private String trustStorePassword; - @Value("${spring.application.name:-}") - String name; - @Autowired(required = false) - Optional template; - - @Bean - public UniqueIDGenerator generator() { - return new UniqueIDGenerator(); - } - - @Bean - public SpringLoggerFilter loggingFilter() { - return new SpringLoggerFilter(generator(), ignorePatterns, logHeaders); - } - - @Bean - @ConditionalOnMissingBean(RestTemplate.class) - public RestTemplate restTemplate() { - RestTemplate restTemplate = new RestTemplate(); - List interceptorList = new ArrayList<>(); - interceptorList.add(new RestTemplateSetHeaderInterceptor()); - restTemplate.setInterceptors(interceptorList); - return restTemplate; - } - - /* rewrite this method to send data to firetail backend - @Bean - @ConditionalOnProperty("logging.firetail.enabled") - public FiretailTcpSocketAppender firetailAppender() { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - LogstashTcpSocketAppender logstashTcpSocketAppender = new LogstashTcpSocketAppender(); - logstashTcpSocketAppender.setName(FIRETAIL_APPENDER_NAME); - logstashTcpSocketAppender.setContext(loggerContext); - logstashTcpSocketAppender.addDestination(url); - if (trustStoreLocation != null) { - SSLConfiguration sslConfiguration = new SSLConfiguration(); - KeyStoreFactoryBean factory = new KeyStoreFactoryBean(); - factory.setLocation(trustStoreLocation); - if (trustStorePassword != null) - factory.setPassword(trustStorePassword); - sslConfiguration.setTrustStore(factory); - logstashTcpSocketAppender.setSsl(sslConfiguration); - } - LogstashEncoder encoder = new LogstashEncoder(); - encoder.setContext(loggerContext); - encoder.setIncludeContext(true); - encoder.setCustomFields("{\"appname\":\"" + name + "\"}"); - encoder.start(); - logstashTcpSocketAppender.setEncoder(encoder); - logstashTcpSocketAppender.start(); - loggerContext.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(logstashTcpSocketAppender); - return logstashTcpSocketAppender; - } */ - - @PostConstruct - public void init() { - template.ifPresent(restTemplate -> { - List interceptorList = new ArrayList<>(); - interceptorList.add(new RestTemplateSetHeaderInterceptor()); - restTemplate.setInterceptors(interceptorList); - }); - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getTrustStoreLocation() { - return trustStoreLocation; - } - - public void setTrustStoreLocation(String trustStoreLocation) { - this.trustStoreLocation = trustStoreLocation; - } - - public String getTrustStorePassword() { - return trustStorePassword; - } - - public void setTrustStorePassword(String trustStorePassword) { - this.trustStorePassword = trustStorePassword; - } - - public String getIgnorePatterns() { - return ignorePatterns; - } - - public void setIgnorePatterns(String ignorePatterns) { - this.ignorePatterns = ignorePatterns; - } - - public boolean isLogHeaders() { - return logHeaders; - } - - public void setLogHeaders(boolean logHeaders) { - this.logHeaders = logHeaders; - } -} diff --git a/src/main/java/io/firetail/logging/filter/SpringLoggerFilter.java b/src/main/java/io/firetail/logging/filter/SpringLoggerFilter.java deleted file mode 100644 index 61e2088..0000000 --- a/src/main/java/io/firetail/logging/filter/SpringLoggerFilter.java +++ /dev/null @@ -1,99 +0,0 @@ -package io.firetail.logging.filter; - -import io.firetail.logging.util.UniqueIDGenerator; -import io.firetail.logging.wrapper.SpringRequestWrapper; -import io.firetail.logging.wrapper.SpringResponseWrapper; -import org.apache.commons.io.IOUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.slf4j.MDC; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationContext; -import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.HandlerExecutionChain; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; - -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Objects; - -import static net.logstash.logback.argument.StructuredArguments.value; - -public class SpringLoggerFilter extends OncePerRequestFilter { - - private static final Logger LOGGER = LoggerFactory.getLogger(SpringLoggerFilter.class); - private final UniqueIDGenerator generator; - private final String ignorePatterns; - private final boolean logHeaders; - - @Autowired - ApplicationContext context; - - public SpringLoggerFilter(UniqueIDGenerator generator, String ignorePatterns, boolean logHeaders) { - this.generator = generator; - this.ignorePatterns = ignorePatterns; - this.logHeaders = logHeaders; - } - - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { - if (ignorePatterns != null && request.getRequestURI().matches(ignorePatterns)) { - chain.doFilter(request, response); - } else { - generator.generateAndSetMDC(request); - try { - getHandlerMethod(request); - } catch (Exception e) { - LOGGER.trace("Cannot get handler method"); - } - final long startTime = System.currentTimeMillis(); - final SpringRequestWrapper wrappedRequest = new SpringRequestWrapper(request); - if (logHeaders) - LOGGER.info("Request: method={}, uri={}, payload={}, headers={}, audit={}", wrappedRequest.getMethod(), - wrappedRequest.getRequestURI(), IOUtils.toString(wrappedRequest.getInputStream(), - wrappedRequest.getCharacterEncoding()), wrappedRequest.getAllHeaders(), value("audit", true)); - else - LOGGER.info("Request: method={}, uri={}, payload={}, audit={}", wrappedRequest.getMethod(), - wrappedRequest.getRequestURI(), IOUtils.toString(wrappedRequest.getInputStream(), - wrappedRequest.getCharacterEncoding()), value("audit", true)); - final SpringResponseWrapper wrappedResponse = new SpringResponseWrapper(response); - wrappedResponse.setHeader("X-Request-ID", MDC.get("X-Request-ID")); - wrappedResponse.setHeader("X-Correlation-ID", MDC.get("X-Correlation-ID")); - - try { - chain.doFilter(wrappedRequest, wrappedResponse); - } catch (Exception e) { - logResponse(startTime, wrappedResponse, 500); - throw e; - } - logResponse(startTime, wrappedResponse, wrappedResponse.getStatus()); - } - } - - private void logResponse(long startTime, SpringResponseWrapper wrappedResponse, int overriddenStatus) throws IOException { - final long duration = System.currentTimeMillis() - startTime; - wrappedResponse.setCharacterEncoding("UTF-8"); - if (logHeaders) - LOGGER.info("Response({} ms): status={}, payload={}, headers={}, audit={}", value("X-Response-Time", duration), - value("X-Response-Status", overriddenStatus), IOUtils.toString(wrappedResponse.getContentAsByteArray(), - wrappedResponse.getCharacterEncoding()), wrappedResponse.getAllHeaders(), value("audit", true)); - else - LOGGER.info("Response({} ms): status={}, payload={}, audit={}", value("X-Response-Time", duration), - value("X-Response-Status", overriddenStatus), - IOUtils.toString(wrappedResponse.getContentAsByteArray(), wrappedResponse.getCharacterEncoding()), value("audit", true)); - } - - private void getHandlerMethod(HttpServletRequest request) throws Exception { - RequestMappingHandlerMapping mappings1 = (RequestMappingHandlerMapping) context.getBean("requestMappingHandlerMapping"); - // Map handlerMethods = mappings1.getHandlerMethods(); - HandlerExecutionChain handler = mappings1.getHandler(request); - if (Objects.nonNull(handler)) { - HandlerMethod handler1 = (HandlerMethod) handler.getHandler(); - MDC.put("X-Operation-Name", handler1.getBeanType().getSimpleName() + "." + handler1.getMethod().getName()); - } - } - -} diff --git a/src/main/java/io/firetail/logging/util/UniqueIDGenerator.java b/src/main/java/io/firetail/logging/util/UniqueIDGenerator.java deleted file mode 100644 index 7d95a69..0000000 --- a/src/main/java/io/firetail/logging/util/UniqueIDGenerator.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.firetail.logging.util; - -import org.slf4j.MDC; - -import javax.servlet.http.HttpServletRequest; -import java.util.UUID; - -public class UniqueIDGenerator { - - private static final String REQUEST_ID_HEADER_NAME = "X-Request-ID"; - private static final String CORRELATION_ID_HEADER_NAME = "X-Correlation-ID"; - - public void generateAndSetMDC(HttpServletRequest request) { - MDC.clear(); - String requestId = request.getHeader(REQUEST_ID_HEADER_NAME); - if (requestId == null) - requestId = UUID.randomUUID().toString(); - MDC.put(REQUEST_ID_HEADER_NAME, requestId); - - String correlationId = request.getHeader(CORRELATION_ID_HEADER_NAME); - if (correlationId == null) - correlationId = UUID.randomUUID().toString(); - MDC.put(CORRELATION_ID_HEADER_NAME, correlationId); - } - -} diff --git a/src/main/java/io/firetail/logging/wrapper/ServletOutputStreamWrapper.java b/src/main/java/io/firetail/logging/wrapper/ServletOutputStreamWrapper.java deleted file mode 100644 index c9964dd..0000000 --- a/src/main/java/io/firetail/logging/wrapper/ServletOutputStreamWrapper.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.firetail.logging.wrapper; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; - -import javax.servlet.ServletOutputStream; -import javax.servlet.WriteListener; - -public class ServletOutputStreamWrapper extends ServletOutputStream { - - private OutputStream outputStream; - private ByteArrayOutputStream copy; - - public ServletOutputStreamWrapper(OutputStream outputStream) { - this.outputStream = outputStream; - this.copy = new ByteArrayOutputStream(); - } - - @Override - public void write(int b) throws IOException { - outputStream.write(b); - copy.write(b); - } - - public byte[] getCopy() { - return copy.toByteArray(); - } - - @Override - public boolean isReady() { - return true; - } - - @Override - public void setWriteListener(WriteListener writeListener) { - - } -} diff --git a/src/main/java/io/firetail/logging/wrapper/SpringRequestWrapper.java b/src/main/java/io/firetail/logging/wrapper/SpringRequestWrapper.java deleted file mode 100644 index 79be944..0000000 --- a/src/main/java/io/firetail/logging/wrapper/SpringRequestWrapper.java +++ /dev/null @@ -1,57 +0,0 @@ -package io.firetail.logging.wrapper; - -import org.apache.commons.io.IOUtils; - -import javax.servlet.ReadListener; -import javax.servlet.ServletInputStream; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletRequestWrapper; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -public class SpringRequestWrapper extends HttpServletRequestWrapper { - - private byte[] body; - - public SpringRequestWrapper(HttpServletRequest request) { - super(request); - try { - body = IOUtils.toByteArray(request.getInputStream()); - } catch (IOException ex) { - body = new byte[0]; - } - } - - @Override - public ServletInputStream getInputStream() throws IOException { - return new ServletInputStream() { - public boolean isFinished() { - return false; - } - - public boolean isReady() { - return true; - } - - public void setReadListener(ReadListener readListener) { - - } - - ByteArrayInputStream byteArray = new ByteArrayInputStream(body); - - @Override - public int read() throws IOException { - return byteArray.read(); - } - }; - } - - public Map getAllHeaders() { - final Map headers = new HashMap<>(); - Collections.list(getHeaderNames()).forEach(it -> headers.put(it, getHeader(it))); - return headers; - } -} diff --git a/src/main/java/io/firetail/logging/wrapper/SpringResponseWrapper.java b/src/main/java/io/firetail/logging/wrapper/SpringResponseWrapper.java deleted file mode 100644 index b8c191c..0000000 --- a/src/main/java/io/firetail/logging/wrapper/SpringResponseWrapper.java +++ /dev/null @@ -1,77 +0,0 @@ -package io.firetail.logging.wrapper; - -import java.io.IOException; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; -import java.util.HashMap; -import java.util.Map; - -import javax.servlet.ServletOutputStream; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpServletResponseWrapper; - -public class SpringResponseWrapper extends HttpServletResponseWrapper { - - private ServletOutputStream outputStream; - private PrintWriter writer; - private ServletOutputStreamWrapper copier; - - public SpringResponseWrapper(HttpServletResponse response) throws IOException { - super(response); - } - - @Override - public ServletOutputStream getOutputStream() throws IOException { - if (writer != null) { - throw new IllegalStateException("getWriter() has already been called on this response."); - } - - if (outputStream == null) { - outputStream = getResponse().getOutputStream(); - copier = new ServletOutputStreamWrapper(outputStream); - } - - return copier; - } - - @Override - public PrintWriter getWriter() throws IOException { - if (outputStream != null) { - throw new IllegalStateException("getOutputStream() has already been called on this response."); - } - - if (writer == null) { - copier = new ServletOutputStreamWrapper(getResponse().getOutputStream()); - writer = new PrintWriter(new OutputStreamWriter(copier, getResponse().getCharacterEncoding()), true); - } - - return writer; - } - - @Override - public void flushBuffer() throws IOException { - if (writer != null) { - writer.flush(); - } - else if (outputStream != null) { - copier.flush(); - } - } - - public byte[] getContentAsByteArray() { - if (copier != null) { - return copier.getCopy(); - } - else { - return new byte[0]; - } - } - - public Map getAllHeaders() { - final Map headers = new HashMap<>(); - getHeaderNames().forEach(it -> headers.put(it, getHeader(it))); - return headers; - } - -} - diff --git a/src/main/kotlin/io/firetail/logging/Constants.kt b/src/main/kotlin/io/firetail/logging/Constants.kt new file mode 100644 index 0000000..3af7ab8 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/Constants.kt @@ -0,0 +1,12 @@ +package io.firetail.logging + +class Constants { + companion object { + const val REQUEST_ID = "X-Request-ID" + const val CORRELATION_ID = "X-Correlation-ID" + const val OP_NAME = "X-Operation-Name" + const val RESPONSE_TIME = "X-Response-Time" + const val RESPONSE_STATUS = "X-Response-Status" + val empty = ByteArray(0) + } +} diff --git a/src/main/kotlin/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.kt b/src/main/kotlin/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.kt new file mode 100644 index 0000000..4cf96fd --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.kt @@ -0,0 +1,21 @@ +package io.firetail.logging.client + +import io.firetail.logging.Constants.Companion.CORRELATION_ID +import io.firetail.logging.Constants.Companion.REQUEST_ID +import org.slf4j.MDC +import org.springframework.http.HttpRequest +import org.springframework.http.client.ClientHttpRequestExecution +import org.springframework.http.client.ClientHttpRequestInterceptor +import org.springframework.http.client.ClientHttpResponse + +class RestTemplateSetHeaderInterceptor : ClientHttpRequestInterceptor { + override fun intercept( + request: HttpRequest, + body: ByteArray, + execution: ClientHttpRequestExecution, + ): ClientHttpResponse { + request.headers.add(CORRELATION_ID, MDC.get(CORRELATION_ID)) + request.headers.add(REQUEST_ID, MDC.get(REQUEST_ID)) + return execution.execute(request, body) + } +} diff --git a/src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt b/src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt new file mode 100644 index 0000000..f92d7d5 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt @@ -0,0 +1,91 @@ +package io.firetail.logging.config + +import io.firetail.logging.client.RestTemplateSetHeaderInterceptor +import io.firetail.logging.filter.SpringLoggerFilter +import io.firetail.logging.util.UniqueIDGenerator +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.ClientHttpRequestInterceptor +import org.springframework.web.client.RestTemplate +import java.util.* +import javax.annotation.PostConstruct + +// import net.logstash.logback.appender.LogstashTcpSocketAppender; +// import net.logstash.logback.encoder.LogstashEncoder; +@Configuration +@ConfigurationProperties(prefix = "logging.logstash") +class SpringLoggerAutoConfiguration { + // private static final String FIRETAIL_APPENDER_NAME = "FIRETAIL"; + var url = "localhost:8500" + var ignorePatterns: String? = null + var isLogHeaders = false + var trustStoreLocation: String? = null + var trustStorePassword: String? = null + + @Value("\${spring.application.name:-}") + lateinit var name: String + + @Autowired(required = false) + var template: Optional? = null + + @Bean + fun generator(): UniqueIDGenerator { + return UniqueIDGenerator() + } + + @Bean + fun loggingFilter(): SpringLoggerFilter { + return SpringLoggerFilter(generator(), ignorePatterns, isLogHeaders) + } + + @Bean + @ConditionalOnMissingBean(RestTemplate::class) + fun restTemplate(): RestTemplate { + val restTemplate = RestTemplate() + val interceptorList: MutableList = ArrayList() + interceptorList.add(RestTemplateSetHeaderInterceptor()) + restTemplate.interceptors = interceptorList + return restTemplate + } + + /* rewrite this method to send data to firetail backend + @Bean + @ConditionalOnProperty("logging.firetail.enabled") + public FiretailTcpSocketAppender firetailAppender() { + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + LogstashTcpSocketAppender logstashTcpSocketAppender = new LogstashTcpSocketAppender(); + logstashTcpSocketAppender.setName(FIRETAIL_APPENDER_NAME); + logstashTcpSocketAppender.setContext(loggerContext); + logstashTcpSocketAppender.addDestination(url); + if (trustStoreLocation != null) { + SSLConfiguration sslConfiguration = new SSLConfiguration(); + KeyStoreFactoryBean factory = new KeyStoreFactoryBean(); + factory.setLocation(trustStoreLocation); + if (trustStorePassword != null) + factory.setPassword(trustStorePassword); + sslConfiguration.setTrustStore(factory); + logstashTcpSocketAppender.setSsl(sslConfiguration); + } + LogstashEncoder encoder = new LogstashEncoder(); + encoder.setContext(loggerContext); + encoder.setIncludeContext(true); + encoder.setCustomFields("{\"appname\":\"" + name + "\"}"); + encoder.start(); + logstashTcpSocketAppender.setEncoder(encoder); + logstashTcpSocketAppender.start(); + loggerContext.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(logstashTcpSocketAppender); + return logstashTcpSocketAppender; + } */ + @PostConstruct + fun init() { + template!!.ifPresent { restTemplate: RestTemplate -> + val interceptorList: MutableList = ArrayList() + interceptorList.add(RestTemplateSetHeaderInterceptor()) + restTemplate.interceptors = interceptorList + } + } +} diff --git a/src/main/kotlin/io/firetail/logging/filter/SpringLoggerFilter.kt b/src/main/kotlin/io/firetail/logging/filter/SpringLoggerFilter.kt new file mode 100644 index 0000000..b56a10d --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/filter/SpringLoggerFilter.kt @@ -0,0 +1,120 @@ +package io.firetail.logging.filter + +import io.firetail.logging.Constants.Companion.CORRELATION_ID +import io.firetail.logging.Constants.Companion.OP_NAME +import io.firetail.logging.Constants.Companion.REQUEST_ID +import io.firetail.logging.Constants.Companion.RESPONSE_STATUS +import io.firetail.logging.Constants.Companion.RESPONSE_TIME +import io.firetail.logging.util.UniqueIDGenerator +import io.firetail.logging.wrapper.SpringRequestWrapper +import io.firetail.logging.wrapper.SpringResponseWrapper +import net.logstash.logback.argument.StructuredArguments +import org.apache.commons.io.IOUtils +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.method.HandlerMethod +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping +import java.util.* +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class SpringLoggerFilter( + private val generator: UniqueIDGenerator, + private val ignorePatterns: String?, + private val logHeaders: Boolean, +) : OncePerRequestFilter() { + @Autowired + lateinit var context: ApplicationContext + + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { + if (ignorePatterns != null && request.requestURI.matches(ignorePatterns.toRegex())) { + chain.doFilter(request, response) + } else { + generator.generateAndSetMDC(request) + try { + getHandlerMethod(request) + } catch (e: Exception) { + LOGGER.trace("Cannot get handler method") + } + val startTime = System.currentTimeMillis() + val wrappedRequest = SpringRequestWrapper(request) + if (logHeaders) { + LOGGER.info( + "Request: method={}, uri={}, payload={}, headers={}, audit={}", + wrappedRequest.method, + wrappedRequest.requestURI, + IOUtils.toString( + wrappedRequest.inputStream, + wrappedRequest.characterEncoding, + ), + wrappedRequest.allHeaders, + StructuredArguments.value("audit", true), + ) + } else { + LOGGER.info( + "Request: method={}, uri={}, payload={}, audit={}", + wrappedRequest.method, + wrappedRequest.requestURI, + IOUtils.toString( + wrappedRequest.inputStream, + wrappedRequest.characterEncoding, + ), + StructuredArguments.value("audit", true), + ) + } + val wrappedResponse = SpringResponseWrapper(response) + wrappedResponse.setHeader(REQUEST_ID, MDC.get(REQUEST_ID)) + wrappedResponse.setHeader(CORRELATION_ID, MDC.get(CORRELATION_ID)) + try { + chain.doFilter(wrappedRequest, wrappedResponse) + } catch (e: Exception) { + logResponse(startTime, wrappedResponse, 500) + throw e + } + logResponse(startTime, wrappedResponse, wrappedResponse.status) + } + } + + private fun logResponse(startTime: Long, wrappedResponse: SpringResponseWrapper, overriddenStatus: Int) { + val duration = System.currentTimeMillis() - startTime + wrappedResponse.characterEncoding = "UTF-8" + if (logHeaders) { + LOGGER.info( + "Response({} ms): status={}, payload={}, headers={}, audit={}", + StructuredArguments.value(RESPONSE_TIME, duration), + StructuredArguments.value(RESPONSE_STATUS, overriddenStatus), + IOUtils.toString( + wrappedResponse.contentAsByteArray, + wrappedResponse.characterEncoding, + ), + wrappedResponse.allHeaders, + StructuredArguments.value("audit", true), + ) + } else { + LOGGER.info( + "Response({} ms): status={}, payload={}, audit={}", + StructuredArguments.value(RESPONSE_TIME, duration), + StructuredArguments.value(RESPONSE_STATUS, overriddenStatus), + IOUtils.toString(wrappedResponse.contentAsByteArray, wrappedResponse.characterEncoding), + StructuredArguments.value("audit", true), + ) + } + } + + private fun getHandlerMethod(request: HttpServletRequest) { + val mappings = context.getBean("requestMappingHandlerMapping") as RequestMappingHandlerMapping + val nullableHandler = mappings.getHandler(request) + if (Objects.nonNull(nullableHandler)) { + val handler = nullableHandler?.handler as HandlerMethod + MDC.put(OP_NAME, handler.beanType.simpleName + "." + handler.method.name) + } + } + + companion object { + private val LOGGER = LoggerFactory.getLogger(SpringLoggerFilter::class.java) + } +} diff --git a/src/main/kotlin/io/firetail/logging/util/Generator.kt b/src/main/kotlin/io/firetail/logging/util/Generator.kt new file mode 100644 index 0000000..1eaabd0 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/util/Generator.kt @@ -0,0 +1,9 @@ +package io.firetail.logging.util + +import java.util.UUID + +class Generator { + fun generate(): String { + return UUID.randomUUID().toString() + } +} diff --git a/src/main/kotlin/io/firetail/logging/util/UniqueIDGenerator.kt b/src/main/kotlin/io/firetail/logging/util/UniqueIDGenerator.kt new file mode 100644 index 0000000..6788727 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/util/UniqueIDGenerator.kt @@ -0,0 +1,18 @@ +package io.firetail.logging.util + +import io.firetail.logging.Constants.Companion.CORRELATION_ID +import io.firetail.logging.Constants.Companion.REQUEST_ID +import org.slf4j.MDC +import javax.servlet.http.HttpServletRequest + +class UniqueIDGenerator(private val generator: Generator = Generator()) { + fun generateAndSetMDC(request: HttpServletRequest) { + MDC.clear() + var requestId = request.getHeader(REQUEST_ID) + if (requestId == null) requestId = generator.generate() + MDC.put(REQUEST_ID, requestId) + var correlationId = request.getHeader(CORRELATION_ID) + if (correlationId == null) correlationId = generator.generate() + MDC.put(CORRELATION_ID, correlationId) + } +} diff --git a/src/main/kotlin/io/firetail/logging/wrapper/ServletOutputStreamWrapper.kt b/src/main/kotlin/io/firetail/logging/wrapper/ServletOutputStreamWrapper.kt new file mode 100644 index 0000000..32d7e86 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/wrapper/ServletOutputStreamWrapper.kt @@ -0,0 +1,25 @@ +package io.firetail.logging.wrapper + +import java.io.ByteArrayOutputStream +import java.io.OutputStream +import javax.servlet.ServletOutputStream +import javax.servlet.WriteListener + +class ServletOutputStreamWrapper(private val outputStream: OutputStream) : ServletOutputStream() { + private val copy: ByteArrayOutputStream = ByteArrayOutputStream() + + override fun write(b: Int) { + outputStream.write(b) + copy.write(b) + } + + fun getCopy(): ByteArray { + return copy.toByteArray() + } + + override fun isReady(): Boolean { + return true + } + + override fun setWriteListener(writeListener: WriteListener) {} +} diff --git a/src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt b/src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt new file mode 100644 index 0000000..4f95787 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt @@ -0,0 +1,51 @@ +package io.firetail.logging.wrapper + +import io.firetail.logging.Constants.Companion.empty +import org.apache.commons.io.IOUtils +import java.io.ByteArrayInputStream +import java.io.IOException +import java.util.* +import java.util.function.Consumer +import javax.servlet.ReadListener +import javax.servlet.ServletInputStream +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletRequestWrapper + +class SpringRequestWrapper(request: HttpServletRequest) : HttpServletRequestWrapper(request) { + private var body: ByteArray + + init { + body = try { + IOUtils.toByteArray(request.inputStream) + } catch (ex: IOException) { + empty + } + } + + override fun getInputStream(): ServletInputStream { + return object : ServletInputStream() { + override fun isFinished(): Boolean { + return false + } + + override fun isReady(): Boolean { + return true + } + + override fun setReadListener(readListener: ReadListener) {} + var byteArray = ByteArrayInputStream(body) + + override fun read(): Int { + return byteArray.read() + } + } + } + + val allHeaders: Map + get() { + val headers: MutableMap = HashMap() + Collections.list(headerNames) + .forEach(Consumer { it: String -> headers[it] = getHeader(it) }) + return headers + } +} diff --git a/src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt b/src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt new file mode 100644 index 0000000..9532a5b --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt @@ -0,0 +1,51 @@ +package io.firetail.logging.wrapper + +import io.firetail.logging.Constants.Companion.empty +import java.io.OutputStreamWriter +import java.io.PrintWriter +import java.util.function.Consumer +import javax.servlet.ServletOutputStream +import javax.servlet.http.HttpServletResponse +import javax.servlet.http.HttpServletResponseWrapper + +class SpringResponseWrapper(response: HttpServletResponse?) : HttpServletResponseWrapper(response) { + private var outputStream: ServletOutputStream? = null + private var writer: PrintWriter? = null + private var copier: ServletOutputStreamWrapper? = null + + override fun getOutputStream(): ServletOutputStream { + check(writer == null) { "getWriter() has already been called on this response." } + copier = ServletOutputStreamWrapper(response.outputStream) + return copier!! + } + + override fun getWriter(): PrintWriter { + check(outputStream == null) { "getOutputStream() has already been called on this response." } + if (writer == null) { + copier = ServletOutputStreamWrapper(response.outputStream) + writer = PrintWriter(OutputStreamWriter(copier!!, response.characterEncoding), true) + } + return writer!! + } + + override fun flushBuffer() { + if (writer != null) { + writer!!.flush() + } else if (outputStream != null) { + copier!!.flush() + } + } + + val contentAsByteArray: ByteArray + get() = if (copier != null) { + copier!!.getCopy() + } else { + empty + } + val allHeaders: Map + get() { + val headers: MutableMap = HashMap() + headerNames.forEach(Consumer { it: String -> headers[it] = getHeader(it) }) + return headers + } +} diff --git a/src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt b/src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt new file mode 100644 index 0000000..1aac882 --- /dev/null +++ b/src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt @@ -0,0 +1,45 @@ +package io.firetail.logging + +import io.firetail.logging.Constants.Companion.CORRELATION_ID +import io.firetail.logging.Constants.Companion.REQUEST_ID +import io.firetail.logging.util.Generator +import io.firetail.logging.util.UniqueIDGenerator +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.slf4j.MDC +import org.springframework.mock.web.MockHttpServletRequest + +class IdGeneratorTests { + @Test + fun mdcIsSetFromHeaderValues() { + val idGenerator = UniqueIDGenerator() // test with default generator + val httpRequest = MockHttpServletRequest() + val requestId = "requestId" + val correlationId = "correlationId" + httpRequest.addHeader(REQUEST_ID, requestId) + httpRequest.addHeader(CORRELATION_ID, correlationId) + idGenerator.generateAndSetMDC(httpRequest) + + assertThat(MDC.get(REQUEST_ID)).isEqualTo(requestId) + assertThat(MDC.get(CORRELATION_ID)).isEqualTo(correlationId) + assertThat(httpRequest.getHeader(REQUEST_ID)).isEqualTo(requestId) + assertThat(httpRequest.getHeader(CORRELATION_ID)).isEqualTo(correlationId) + } + + @Test + fun mdcIsSetWhenNoHeaderValues() { + val generator = Mockito.mock(Generator::class.java) + MDC.clear() + assertThat(MDC.get(CORRELATION_ID)).isNull() + assertThat(MDC.get(REQUEST_ID)).isNull() + val httpRequest = MockHttpServletRequest() + val id = "someValue" + Mockito.`when`(generator.generate()).thenReturn(id) + val idGenerator = UniqueIDGenerator(generator) + idGenerator.generateAndSetMDC(httpRequest) + assertThat(httpRequest.headerNames.toList()).isEmpty() + assertThat(MDC.get(REQUEST_ID)).isEqualTo(id) + assertThat(MDC.get(CORRELATION_ID)).isEqualTo(id) + } +} From c158fe5947d99a206f8483745988f9146ef9ee1e Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Fri, 8 Sep 2023 11:31:08 +0700 Subject: [PATCH 03/25] refactor: Simplify classes and introduce clearer defaults feat: Add FiretailConfig fix: Add in HTTP filters chore: Upgrade spring --- build.gradle.kts | 18 ++- .../logging/{ => config}/Constants.kt | 3 +- .../firetail/logging/config/FiretailConfig.kt | 28 ++++ .../FiretailHeaderInterceptor.kt} | 14 +- .../config/SpringLoggerAutoConfiguration.kt | 20 +-- .../firetail/logging/filter/FiretailFilter.kt | 82 ++++++++++++ .../firetail/logging/filter/FiretailLogger.kt | 86 +++++++++++++ .../logging/filter/SpringLoggerFilter.kt | 120 ------------------ .../io/firetail/logging/util/LogContext.kt | 18 +++ .../io/firetail/logging/util/StringUtils.kt | 16 +++ .../logging/util/UniqueIDGenerator.kt | 18 --- .../logging/wrapper/SpringRequestWrapper.kt | 2 +- .../logging/wrapper/SpringResponseWrapper.kt | 2 +- .../io/firetail/logging/IdGeneratorTests.kt | 18 ++- .../logging/RequestInterceptorTests.kt | 68 ++++++++++ .../io/firetail/logging/StringUtilsTest.kt | 31 +++++ 16 files changed, 371 insertions(+), 173 deletions(-) rename src/main/kotlin/io/firetail/logging/{ => config}/Constants.kt (83%) create mode 100644 src/main/kotlin/io/firetail/logging/config/FiretailConfig.kt rename src/main/kotlin/io/firetail/logging/{client/RestTemplateSetHeaderInterceptor.kt => config/FiretailHeaderInterceptor.kt} (54%) create mode 100644 src/main/kotlin/io/firetail/logging/filter/FiretailFilter.kt create mode 100644 src/main/kotlin/io/firetail/logging/filter/FiretailLogger.kt delete mode 100644 src/main/kotlin/io/firetail/logging/filter/SpringLoggerFilter.kt create mode 100644 src/main/kotlin/io/firetail/logging/util/LogContext.kt create mode 100644 src/main/kotlin/io/firetail/logging/util/StringUtils.kt delete mode 100644 src/main/kotlin/io/firetail/logging/util/UniqueIDGenerator.kt create mode 100644 src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt create mode 100644 src/test/kotlin/io/firetail/logging/StringUtilsTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index ad3aded..7d13048 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ - plugins { `java-library` `maven-publish` @@ -8,6 +7,14 @@ plugins { id("io.spring.dependency-management") version "1.1.2" } +buildscript { + val kotlinVersion = "1.8.21" + dependencies { + classpath("org.jetbrains.kotlin:kotlin-noarg:$kotlinVersion") + classpath("org.jmailen.gradle:kotlinter-gradle:3.14.0") + } +} + repositories { mavenLocal() maven { @@ -19,11 +26,11 @@ repositories { group = "com.github.firetail-io" version = "0.0.1.SNAPSHOT" description = "firetail-java-lib" -java.sourceCompatibility = JavaVersion.VERSION_1_8 +// java.sourceCompatibility = JavaVersion.VERSION_1_8 dependencies { implementation( - platform("org.springframework.boot:spring-boot-dependencies:2.7.14"), + platform("org.springframework.boot:spring-boot-dependencies:2.7.15"), ) api("org.jetbrains.kotlin:kotlin-stdlib-jdk8") api("commons-io:commons-io:2.7") @@ -40,7 +47,8 @@ dependencies { compileOnly("org.springframework:spring-webmvc") testImplementation(kotlin("test")) testImplementation("javax.servlet:javax.servlet-api") - testImplementation("org.springframework:spring-test") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework:spring-webmvc") testImplementation("org.assertj:assertj-core") testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0") } @@ -51,7 +59,7 @@ publishing { } } kotlin { - jvmToolchain(8) + jvmToolchain(17) } tasks.test { // See 5️⃣ diff --git a/src/main/kotlin/io/firetail/logging/Constants.kt b/src/main/kotlin/io/firetail/logging/config/Constants.kt similarity index 83% rename from src/main/kotlin/io/firetail/logging/Constants.kt rename to src/main/kotlin/io/firetail/logging/config/Constants.kt index 3af7ab8..536c1c1 100644 --- a/src/main/kotlin/io/firetail/logging/Constants.kt +++ b/src/main/kotlin/io/firetail/logging/config/Constants.kt @@ -1,4 +1,4 @@ -package io.firetail.logging +package io.firetail.logging.config class Constants { companion object { @@ -7,6 +7,7 @@ class Constants { const val OP_NAME = "X-Operation-Name" const val RESPONSE_TIME = "X-Response-Time" const val RESPONSE_STATUS = "X-Response-Status" + const val AUDIT = "audit" val empty = ByteArray(0) } } diff --git a/src/main/kotlin/io/firetail/logging/config/FiretailConfig.kt b/src/main/kotlin/io/firetail/logging/config/FiretailConfig.kt new file mode 100644 index 0000000..bf72550 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/config/FiretailConfig.kt @@ -0,0 +1,28 @@ +package io.firetail.logging.config + +import io.firetail.logging.filter.FiretailFilter +import io.firetail.logging.filter.FiretailLogger +import io.firetail.logging.util.LogContext +import io.firetail.logging.util.StringUtils +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import + +@Configuration +@Import( + FiretailLogger::class, + StringUtils::class, + LogContext::class, + FiretailFilter::class, +) +class FiretailConfig @Autowired constructor( + @Value("\${firetail.ignorePatterns:#null}") + val ignorePatterns: String?, + @Value("\${firetail.logHeaders:false}") + val logHeaders: Boolean = false, +) { + @Autowired + lateinit var context: ApplicationContext +} diff --git a/src/main/kotlin/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.kt b/src/main/kotlin/io/firetail/logging/config/FiretailHeaderInterceptor.kt similarity index 54% rename from src/main/kotlin/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.kt rename to src/main/kotlin/io/firetail/logging/config/FiretailHeaderInterceptor.kt index 4cf96fd..03cc225 100644 --- a/src/main/kotlin/io/firetail/logging/client/RestTemplateSetHeaderInterceptor.kt +++ b/src/main/kotlin/io/firetail/logging/config/FiretailHeaderInterceptor.kt @@ -1,21 +1,23 @@ -package io.firetail.logging.client +package io.firetail.logging.config -import io.firetail.logging.Constants.Companion.CORRELATION_ID -import io.firetail.logging.Constants.Companion.REQUEST_ID +import io.firetail.logging.config.Constants.Companion.CORRELATION_ID +import io.firetail.logging.config.Constants.Companion.REQUEST_ID import org.slf4j.MDC import org.springframework.http.HttpRequest import org.springframework.http.client.ClientHttpRequestExecution import org.springframework.http.client.ClientHttpRequestInterceptor import org.springframework.http.client.ClientHttpResponse -class RestTemplateSetHeaderInterceptor : ClientHttpRequestInterceptor { +class FiretailHeaderInterceptor : ClientHttpRequestInterceptor { override fun intercept( request: HttpRequest, body: ByteArray, execution: ClientHttpRequestExecution, ): ClientHttpResponse { - request.headers.add(CORRELATION_ID, MDC.get(CORRELATION_ID)) - request.headers.add(REQUEST_ID, MDC.get(REQUEST_ID)) + with(request) { + headers.add(CORRELATION_ID, MDC.get(CORRELATION_ID)) + headers.add(REQUEST_ID, MDC.get(REQUEST_ID)) + } return execution.execute(request, body) } } diff --git a/src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt b/src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt index f92d7d5..f786602 100644 --- a/src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt +++ b/src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt @@ -1,23 +1,23 @@ package io.firetail.logging.config -import io.firetail.logging.client.RestTemplateSetHeaderInterceptor -import io.firetail.logging.filter.SpringLoggerFilter -import io.firetail.logging.util.UniqueIDGenerator import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import import org.springframework.http.client.ClientHttpRequestInterceptor import org.springframework.web.client.RestTemplate import java.util.* import javax.annotation.PostConstruct + // import net.logstash.logback.appender.LogstashTcpSocketAppender; // import net.logstash.logback.encoder.LogstashEncoder; @Configuration @ConfigurationProperties(prefix = "logging.logstash") +@Import(FiretailConfig::class) class SpringLoggerAutoConfiguration { // private static final String FIRETAIL_APPENDER_NAME = "FIRETAIL"; var url = "localhost:8500" @@ -32,22 +32,12 @@ class SpringLoggerAutoConfiguration { @Autowired(required = false) var template: Optional? = null - @Bean - fun generator(): UniqueIDGenerator { - return UniqueIDGenerator() - } - - @Bean - fun loggingFilter(): SpringLoggerFilter { - return SpringLoggerFilter(generator(), ignorePatterns, isLogHeaders) - } - @Bean @ConditionalOnMissingBean(RestTemplate::class) fun restTemplate(): RestTemplate { val restTemplate = RestTemplate() val interceptorList: MutableList = ArrayList() - interceptorList.add(RestTemplateSetHeaderInterceptor()) + interceptorList.add(FiretailHeaderInterceptor()) restTemplate.interceptors = interceptorList return restTemplate } @@ -84,7 +74,7 @@ class SpringLoggerAutoConfiguration { fun init() { template!!.ifPresent { restTemplate: RestTemplate -> val interceptorList: MutableList = ArrayList() - interceptorList.add(RestTemplateSetHeaderInterceptor()) + interceptorList.add(FiretailHeaderInterceptor()) restTemplate.interceptors = interceptorList } } diff --git a/src/main/kotlin/io/firetail/logging/filter/FiretailFilter.kt b/src/main/kotlin/io/firetail/logging/filter/FiretailFilter.kt new file mode 100644 index 0000000..e73b985 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/filter/FiretailFilter.kt @@ -0,0 +1,82 @@ +package io.firetail.logging.filter + +import io.firetail.logging.config.Constants.Companion.CORRELATION_ID +import io.firetail.logging.config.Constants.Companion.OP_NAME +import io.firetail.logging.config.Constants.Companion.REQUEST_ID +import io.firetail.logging.config.FiretailConfig +import io.firetail.logging.util.LogContext +import io.firetail.logging.util.StringUtils +import io.firetail.logging.wrapper.SpringRequestWrapper +import io.firetail.logging.wrapper.SpringResponseWrapper +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.context.annotation.Bean +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.method.HandlerMethod +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping +import java.io.IOException +import java.util.* +import javax.servlet.FilterChain +import javax.servlet.ServletException +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class FiretailFilter( + private val logContext: LogContext, + private val firetailConfig: FiretailConfig, +) { + private val stringUtils = StringUtils() // UTF-8 + private val firetailLogger = FiretailLogger(stringUtils, firetailConfig.logHeaders) + + @Bean + fun firetailRequestFilter(): OncePerRequestFilter { + return object : OncePerRequestFilter() { + @Throws(ServletException::class, IOException::class) + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + chain: FilterChain, + ) { + if (firetailConfig.ignorePatterns != null && request.requestURI.matches(firetailConfig.ignorePatterns!!.toRegex())) { + chain.doFilter(request, response) + } else { + logContext.generateAndSetMDC(request) + try { + getHandlerMethod(request) + } catch (e: Exception) { + LOGGER.trace("Cannot get handler method") + } + val startTime = System.currentTimeMillis() + val wrappedRequest = SpringRequestWrapper(request) + firetailLogger.logRequest(wrappedRequest) + val wrappedResponse = SpringResponseWrapper(response) + try { + with(wrappedResponse) { + setHeader(REQUEST_ID, MDC.get(REQUEST_ID)) + setHeader(CORRELATION_ID, MDC.get(CORRELATION_ID)) + } + chain.doFilter(wrappedRequest, wrappedResponse) + firetailLogger.logResponse(startTime, wrappedResponse) + } catch (e: Exception) { + firetailLogger.logResponse(startTime, wrappedResponse, 500) + throw e + } + } + } + } + } + + private fun getHandlerMethod(request: HttpServletRequest) { + val mappings = firetailConfig.context.getBean("requestMappingHandlerMapping") + as RequestMappingHandlerMapping + val nullableHandler = mappings.getHandler(request) + if (Objects.nonNull(nullableHandler)) { + val handler = nullableHandler?.handler as HandlerMethod + MDC.put(OP_NAME, handler.beanType.simpleName + "." + handler.method.name) + } + } + + companion object { + private val LOGGER = LoggerFactory.getLogger(FiretailLogger::class.java) + } +} diff --git a/src/main/kotlin/io/firetail/logging/filter/FiretailLogger.kt b/src/main/kotlin/io/firetail/logging/filter/FiretailLogger.kt new file mode 100644 index 0000000..0b8174c --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/filter/FiretailLogger.kt @@ -0,0 +1,86 @@ +package io.firetail.logging.filter + +import io.firetail.logging.config.Constants +import io.firetail.logging.util.StringUtils +import io.firetail.logging.wrapper.SpringRequestWrapper +import io.firetail.logging.wrapper.SpringResponseWrapper +import net.logstash.logback.argument.StructuredArguments +import org.slf4j.LoggerFactory + +class FiretailLogger(private val stringUtils: StringUtils = StringUtils(), private val logHeaders: Boolean = false) { + fun logRequest(wrappedRequest: SpringRequestWrapper) { + if (logHeaders) { + logWithHeaders(wrappedRequest) + } else { + logNoHeaders(wrappedRequest) + } + } + + private fun logNoHeaders(wrappedRequest: SpringRequestWrapper) { + LOGGER.info( + "Request: method={}, uri={}, payload={}, audit={}", + wrappedRequest.method, + wrappedRequest.requestURI, + stringUtils.toString(wrappedRequest.inputStream.readAllBytes(), wrappedRequest.characterEncoding), + StructuredArguments.value(Constants.AUDIT, true), + ) + } + + private fun logWithHeaders(wrappedRequest: SpringRequestWrapper) { + LOGGER.info( + "Request: method={}, uri={}, payload={}, headers={}, audit={}", + wrappedRequest.method, + wrappedRequest.requestURI, + stringUtils.toString(wrappedRequest.inputStream.readAllBytes(), wrappedRequest.characterEncoding), + wrappedRequest.allHeaders, + StructuredArguments.value(Constants.AUDIT, true), + ) + } + + fun logResponse( + startTime: Long, + wrappedResponse: SpringResponseWrapper, + status: Int = wrappedResponse.status, + ) { + val duration = System.currentTimeMillis() - startTime + wrappedResponse.characterEncoding = stringUtils.charSet() + if (logHeaders) { + logWithHeaders(duration, status, wrappedResponse) + } else { + logNoHeaders(duration, status, wrappedResponse) + } + } + + private fun logNoHeaders( + duration: Long, + status: Int, + wrappedResponse: SpringResponseWrapper, + ) { + LOGGER.info( + "Response({} ms): status={}, payload={}, audit={}", + StructuredArguments.value(Constants.RESPONSE_TIME, duration), + StructuredArguments.value(Constants.RESPONSE_STATUS, status), + stringUtils.toString(wrappedResponse.contentAsByteArray), + StructuredArguments.value(Constants.AUDIT, true), + ) + } + + private fun logWithHeaders( + duration: Long, + status: Int, + wrappedResponse: SpringResponseWrapper, + ) { + LOGGER.info( + "Response({} ms): status={}, payload={}, headers={}, audit={}", + StructuredArguments.value(Constants.RESPONSE_TIME, duration), + StructuredArguments.value(Constants.RESPONSE_STATUS, status), + stringUtils.toString(wrappedResponse.contentAsByteArray), + wrappedResponse.allHeaders, + StructuredArguments.value(Constants.AUDIT, true), + ) + } + + companion object { + private val LOGGER = LoggerFactory.getLogger(this::class.java) + } +} diff --git a/src/main/kotlin/io/firetail/logging/filter/SpringLoggerFilter.kt b/src/main/kotlin/io/firetail/logging/filter/SpringLoggerFilter.kt deleted file mode 100644 index b56a10d..0000000 --- a/src/main/kotlin/io/firetail/logging/filter/SpringLoggerFilter.kt +++ /dev/null @@ -1,120 +0,0 @@ -package io.firetail.logging.filter - -import io.firetail.logging.Constants.Companion.CORRELATION_ID -import io.firetail.logging.Constants.Companion.OP_NAME -import io.firetail.logging.Constants.Companion.REQUEST_ID -import io.firetail.logging.Constants.Companion.RESPONSE_STATUS -import io.firetail.logging.Constants.Companion.RESPONSE_TIME -import io.firetail.logging.util.UniqueIDGenerator -import io.firetail.logging.wrapper.SpringRequestWrapper -import io.firetail.logging.wrapper.SpringResponseWrapper -import net.logstash.logback.argument.StructuredArguments -import org.apache.commons.io.IOUtils -import org.slf4j.LoggerFactory -import org.slf4j.MDC -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.ApplicationContext -import org.springframework.web.filter.OncePerRequestFilter -import org.springframework.web.method.HandlerMethod -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping -import java.util.* -import javax.servlet.FilterChain -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse - -class SpringLoggerFilter( - private val generator: UniqueIDGenerator, - private val ignorePatterns: String?, - private val logHeaders: Boolean, -) : OncePerRequestFilter() { - @Autowired - lateinit var context: ApplicationContext - - override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { - if (ignorePatterns != null && request.requestURI.matches(ignorePatterns.toRegex())) { - chain.doFilter(request, response) - } else { - generator.generateAndSetMDC(request) - try { - getHandlerMethod(request) - } catch (e: Exception) { - LOGGER.trace("Cannot get handler method") - } - val startTime = System.currentTimeMillis() - val wrappedRequest = SpringRequestWrapper(request) - if (logHeaders) { - LOGGER.info( - "Request: method={}, uri={}, payload={}, headers={}, audit={}", - wrappedRequest.method, - wrappedRequest.requestURI, - IOUtils.toString( - wrappedRequest.inputStream, - wrappedRequest.characterEncoding, - ), - wrappedRequest.allHeaders, - StructuredArguments.value("audit", true), - ) - } else { - LOGGER.info( - "Request: method={}, uri={}, payload={}, audit={}", - wrappedRequest.method, - wrappedRequest.requestURI, - IOUtils.toString( - wrappedRequest.inputStream, - wrappedRequest.characterEncoding, - ), - StructuredArguments.value("audit", true), - ) - } - val wrappedResponse = SpringResponseWrapper(response) - wrappedResponse.setHeader(REQUEST_ID, MDC.get(REQUEST_ID)) - wrappedResponse.setHeader(CORRELATION_ID, MDC.get(CORRELATION_ID)) - try { - chain.doFilter(wrappedRequest, wrappedResponse) - } catch (e: Exception) { - logResponse(startTime, wrappedResponse, 500) - throw e - } - logResponse(startTime, wrappedResponse, wrappedResponse.status) - } - } - - private fun logResponse(startTime: Long, wrappedResponse: SpringResponseWrapper, overriddenStatus: Int) { - val duration = System.currentTimeMillis() - startTime - wrappedResponse.characterEncoding = "UTF-8" - if (logHeaders) { - LOGGER.info( - "Response({} ms): status={}, payload={}, headers={}, audit={}", - StructuredArguments.value(RESPONSE_TIME, duration), - StructuredArguments.value(RESPONSE_STATUS, overriddenStatus), - IOUtils.toString( - wrappedResponse.contentAsByteArray, - wrappedResponse.characterEncoding, - ), - wrappedResponse.allHeaders, - StructuredArguments.value("audit", true), - ) - } else { - LOGGER.info( - "Response({} ms): status={}, payload={}, audit={}", - StructuredArguments.value(RESPONSE_TIME, duration), - StructuredArguments.value(RESPONSE_STATUS, overriddenStatus), - IOUtils.toString(wrappedResponse.contentAsByteArray, wrappedResponse.characterEncoding), - StructuredArguments.value("audit", true), - ) - } - } - - private fun getHandlerMethod(request: HttpServletRequest) { - val mappings = context.getBean("requestMappingHandlerMapping") as RequestMappingHandlerMapping - val nullableHandler = mappings.getHandler(request) - if (Objects.nonNull(nullableHandler)) { - val handler = nullableHandler?.handler as HandlerMethod - MDC.put(OP_NAME, handler.beanType.simpleName + "." + handler.method.name) - } - } - - companion object { - private val LOGGER = LoggerFactory.getLogger(SpringLoggerFilter::class.java) - } -} diff --git a/src/main/kotlin/io/firetail/logging/util/LogContext.kt b/src/main/kotlin/io/firetail/logging/util/LogContext.kt new file mode 100644 index 0000000..fc775b6 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/util/LogContext.kt @@ -0,0 +1,18 @@ +package io.firetail.logging.util + +import io.firetail.logging.config.Constants.Companion.CORRELATION_ID +import io.firetail.logging.config.Constants.Companion.REQUEST_ID +import org.slf4j.MDC +import javax.servlet.http.HttpServletRequest + +class LogContext(private val generator: Generator = Generator()) { + fun generateAndSetMDC(request: HttpServletRequest) { + MDC.clear() + MDC.put(REQUEST_ID, getValue(request, REQUEST_ID)) + MDC.put(CORRELATION_ID, getValue(request, CORRELATION_ID)) + } + + private fun getValue(request: HttpServletRequest, key: String): String { + return request.getHeader(key) ?: generator.generate() + } +} diff --git a/src/main/kotlin/io/firetail/logging/util/StringUtils.kt b/src/main/kotlin/io/firetail/logging/util/StringUtils.kt new file mode 100644 index 0000000..022c381 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/util/StringUtils.kt @@ -0,0 +1,16 @@ +package io.firetail.logging.util + +import org.apache.commons.io.IOUtils +import java.nio.charset.Charset +import kotlin.text.Charsets.UTF_8 + +class StringUtils(private val defaultCharset: Charset = UTF_8) { + fun toString(inputStream: ByteArray, characterEncoding: String = charSet()): String { + return IOUtils.toString( + inputStream, + characterEncoding, + ) + } + + fun charSet() = defaultCharset.toString() +} diff --git a/src/main/kotlin/io/firetail/logging/util/UniqueIDGenerator.kt b/src/main/kotlin/io/firetail/logging/util/UniqueIDGenerator.kt deleted file mode 100644 index 6788727..0000000 --- a/src/main/kotlin/io/firetail/logging/util/UniqueIDGenerator.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.firetail.logging.util - -import io.firetail.logging.Constants.Companion.CORRELATION_ID -import io.firetail.logging.Constants.Companion.REQUEST_ID -import org.slf4j.MDC -import javax.servlet.http.HttpServletRequest - -class UniqueIDGenerator(private val generator: Generator = Generator()) { - fun generateAndSetMDC(request: HttpServletRequest) { - MDC.clear() - var requestId = request.getHeader(REQUEST_ID) - if (requestId == null) requestId = generator.generate() - MDC.put(REQUEST_ID, requestId) - var correlationId = request.getHeader(CORRELATION_ID) - if (correlationId == null) correlationId = generator.generate() - MDC.put(CORRELATION_ID, correlationId) - } -} diff --git a/src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt b/src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt index 4f95787..a10a8f5 100644 --- a/src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt +++ b/src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt @@ -1,6 +1,6 @@ package io.firetail.logging.wrapper -import io.firetail.logging.Constants.Companion.empty +import io.firetail.logging.config.Constants.Companion.empty import org.apache.commons.io.IOUtils import java.io.ByteArrayInputStream import java.io.IOException diff --git a/src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt b/src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt index 9532a5b..3f9833c 100644 --- a/src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt +++ b/src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt @@ -1,6 +1,6 @@ package io.firetail.logging.wrapper -import io.firetail.logging.Constants.Companion.empty +import io.firetail.logging.config.Constants.Companion.empty import java.io.OutputStreamWriter import java.io.PrintWriter import java.util.function.Consumer diff --git a/src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt b/src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt index 1aac882..a1ac174 100644 --- a/src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt +++ b/src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt @@ -1,9 +1,9 @@ package io.firetail.logging -import io.firetail.logging.Constants.Companion.CORRELATION_ID -import io.firetail.logging.Constants.Companion.REQUEST_ID +import io.firetail.logging.config.Constants.Companion.CORRELATION_ID +import io.firetail.logging.config.Constants.Companion.REQUEST_ID import io.firetail.logging.util.Generator -import io.firetail.logging.util.UniqueIDGenerator +import io.firetail.logging.util.LogContext import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -13,13 +13,13 @@ import org.springframework.mock.web.MockHttpServletRequest class IdGeneratorTests { @Test fun mdcIsSetFromHeaderValues() { - val idGenerator = UniqueIDGenerator() // test with default generator + val logContext = LogContext() // test with default generator val httpRequest = MockHttpServletRequest() val requestId = "requestId" val correlationId = "correlationId" httpRequest.addHeader(REQUEST_ID, requestId) httpRequest.addHeader(CORRELATION_ID, correlationId) - idGenerator.generateAndSetMDC(httpRequest) + logContext.generateAndSetMDC(httpRequest) assertThat(MDC.get(REQUEST_ID)).isEqualTo(requestId) assertThat(MDC.get(CORRELATION_ID)).isEqualTo(correlationId) @@ -36,10 +36,16 @@ class IdGeneratorTests { val httpRequest = MockHttpServletRequest() val id = "someValue" Mockito.`when`(generator.generate()).thenReturn(id) - val idGenerator = UniqueIDGenerator(generator) + val idGenerator = LogContext(generator) idGenerator.generateAndSetMDC(httpRequest) assertThat(httpRequest.headerNames.toList()).isEmpty() assertThat(MDC.get(REQUEST_ID)).isEqualTo(id) assertThat(MDC.get(CORRELATION_ID)).isEqualTo(id) } + + @Test + fun generateId() { + val generator = Generator() + assertThat(generator.generate()).isNotNull().isNotEmpty() + } } diff --git a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt new file mode 100644 index 0000000..8ff170d --- /dev/null +++ b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt @@ -0,0 +1,68 @@ +package io.firetail.logging + +import io.firetail.logging.config.FiretailConfig +import io.firetail.logging.filter.FiretailLogger +import io.firetail.logging.util.LogContext +import io.firetail.logging.util.StringUtils +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.context.ApplicationContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultHandlers +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@WebMvcTest +@ContextConfiguration( + classes = [ + RequestInterceptorTests.SimpleController::class, + FiretailConfig::class, + ApplicationContext::class, + ], +) +@ExtendWith(SpringExtension::class) +class RequestInterceptorTests { + + @Autowired + private lateinit var stringUtils: StringUtils + + @Autowired + private lateinit var firetailLogger: FiretailLogger + + @Autowired + private lateinit var logContext: LogContext + + @Autowired + private lateinit var mockMvc: MockMvc + + @Test + fun testWiring() { + assertThat(stringUtils).isNotNull + assertThat(firetailLogger).isNotNull + assertThat(logContext).isNotNull + } + + @Test + fun something() { + mockMvc.perform(MockMvcRequestBuilders.get("/hello")) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isOk) + .andReturn() + } + + @RestController("/") + internal class SimpleController { + + @GetMapping("/hello") + fun sayHello(): String { + return "hello" + } + } +} diff --git a/src/test/kotlin/io/firetail/logging/StringUtilsTest.kt b/src/test/kotlin/io/firetail/logging/StringUtilsTest.kt new file mode 100644 index 0000000..ac92068 --- /dev/null +++ b/src/test/kotlin/io/firetail/logging/StringUtilsTest.kt @@ -0,0 +1,31 @@ +package io.firetail.logging + +import io.firetail.logging.util.StringUtils +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.text.Charsets.US_ASCII + +class StringUtilsTest { + // Assertions around \r\n and unicode. + private val input = "Now is the time \r\n for all good \n people to. 你好世界." + + @Test + fun bytesToStringUsingDefaultEncoder() { + assertThat(StringUtils().toString(input.encodeToByteArray())) + .isEqualTo(input) + } + + @Test + fun bytesToStringUsingAsciiEncoderIsNotEqual() { + assertThat(StringUtils(US_ASCII).toString(input.encodeToByteArray())) + .isNotEqualTo(input) + .startsWith(input.subSequence(0, 18)) + } + + @Test + fun bytesToStringUsingSpecifiedEncoderIsNotEqual() { + assertThat(StringUtils().toString(input.encodeToByteArray(), US_ASCII.toString())) + .isNotEqualTo(input) + .startsWith(input.subSequence(0, 18)) + } +} From 8fe24f5fe81bd55ca4b51384ffd94ccc51c45764 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Sat, 9 Sep 2023 11:29:48 +0700 Subject: [PATCH 04/25] feat: Beef up Interceptor tests refactor: DI fixes --- .../logging/{config => base}/Constants.kt | 2 +- .../{config => base}/FiretailConfig.kt | 5 +-- .../{filter => base}/FiretailLogger.kt | 21 +++++----- .../SpringLoggerAutoConfiguration.kt | 4 +- .../{filter => servlet}/FiretailFilter.kt | 23 +++++------ .../FiretailHeaderInterceptor.kt | 6 +-- .../ServletOutputStreamWrapper.kt | 2 +- .../SpringRequestWrapper.kt | 4 +- .../SpringResponseWrapper.kt | 4 +- .../util/{Generator.kt => KeyGenerator.kt} | 4 +- .../io/firetail/logging/util/LogContext.kt | 10 +++-- .../io/firetail/logging/util/StringUtils.kt | 2 + ...neratorTests.kt => IdKeyGeneratorTests.kt} | 18 ++++----- .../logging/RequestInterceptorTests.kt | 39 +++++++++++++++---- 14 files changed, 88 insertions(+), 56 deletions(-) rename src/main/kotlin/io/firetail/logging/{config => base}/Constants.kt (91%) rename src/main/kotlin/io/firetail/logging/{config => base}/FiretailConfig.kt (85%) rename src/main/kotlin/io/firetail/logging/{filter => base}/FiretailLogger.kt (85%) rename src/main/kotlin/io/firetail/logging/{config => base}/SpringLoggerAutoConfiguration.kt (97%) rename src/main/kotlin/io/firetail/logging/{filter => servlet}/FiretailFilter.kt (82%) rename src/main/kotlin/io/firetail/logging/{config => servlet}/FiretailHeaderInterceptor.kt (80%) rename src/main/kotlin/io/firetail/logging/{wrapper => servlet}/ServletOutputStreamWrapper.kt (94%) rename src/main/kotlin/io/firetail/logging/{wrapper => servlet}/SpringRequestWrapper.kt (93%) rename src/main/kotlin/io/firetail/logging/{wrapper => servlet}/SpringResponseWrapper.kt (94%) rename src/main/kotlin/io/firetail/logging/util/{Generator.kt => KeyGenerator.kt} (64%) rename src/test/kotlin/io/firetail/logging/{IdGeneratorTests.kt => IdKeyGeneratorTests.kt} (75%) diff --git a/src/main/kotlin/io/firetail/logging/config/Constants.kt b/src/main/kotlin/io/firetail/logging/base/Constants.kt similarity index 91% rename from src/main/kotlin/io/firetail/logging/config/Constants.kt rename to src/main/kotlin/io/firetail/logging/base/Constants.kt index 536c1c1..30a3c33 100644 --- a/src/main/kotlin/io/firetail/logging/config/Constants.kt +++ b/src/main/kotlin/io/firetail/logging/base/Constants.kt @@ -1,4 +1,4 @@ -package io.firetail.logging.config +package io.firetail.logging.base class Constants { companion object { diff --git a/src/main/kotlin/io/firetail/logging/config/FiretailConfig.kt b/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt similarity index 85% rename from src/main/kotlin/io/firetail/logging/config/FiretailConfig.kt rename to src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt index bf72550..9c3f794 100644 --- a/src/main/kotlin/io/firetail/logging/config/FiretailConfig.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt @@ -1,7 +1,6 @@ -package io.firetail.logging.config +package io.firetail.logging.base -import io.firetail.logging.filter.FiretailFilter -import io.firetail.logging.filter.FiretailLogger +import io.firetail.logging.servlet.FiretailFilter import io.firetail.logging.util.LogContext import io.firetail.logging.util.StringUtils import org.springframework.beans.factory.annotation.Autowired diff --git a/src/main/kotlin/io/firetail/logging/filter/FiretailLogger.kt b/src/main/kotlin/io/firetail/logging/base/FiretailLogger.kt similarity index 85% rename from src/main/kotlin/io/firetail/logging/filter/FiretailLogger.kt rename to src/main/kotlin/io/firetail/logging/base/FiretailLogger.kt index 0b8174c..8de9732 100644 --- a/src/main/kotlin/io/firetail/logging/filter/FiretailLogger.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailLogger.kt @@ -1,20 +1,23 @@ -package io.firetail.logging.filter +package io.firetail.logging.base -import io.firetail.logging.config.Constants +import io.firetail.logging.servlet.SpringRequestWrapper +import io.firetail.logging.servlet.SpringResponseWrapper import io.firetail.logging.util.StringUtils -import io.firetail.logging.wrapper.SpringRequestWrapper -import io.firetail.logging.wrapper.SpringResponseWrapper import net.logstash.logback.argument.StructuredArguments import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service -class FiretailLogger(private val stringUtils: StringUtils = StringUtils(), private val logHeaders: Boolean = false) { - fun logRequest(wrappedRequest: SpringRequestWrapper) { - if (logHeaders) { +@Service +class FiretailLogger( + private val stringUtils: StringUtils = StringUtils(), + private val firetailConfig: FiretailConfig, +) { + fun logRequest(wrappedRequest: SpringRequestWrapper) = + if (firetailConfig.logHeaders) { logWithHeaders(wrappedRequest) } else { logNoHeaders(wrappedRequest) } - } private fun logNoHeaders(wrappedRequest: SpringRequestWrapper) { LOGGER.info( @@ -44,7 +47,7 @@ class FiretailLogger(private val stringUtils: StringUtils = StringUtils(), priva ) { val duration = System.currentTimeMillis() - startTime wrappedResponse.characterEncoding = stringUtils.charSet() - if (logHeaders) { + if (firetailConfig.logHeaders) { logWithHeaders(duration, status, wrappedResponse) } else { logNoHeaders(duration, status, wrappedResponse) diff --git a/src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt b/src/main/kotlin/io/firetail/logging/base/SpringLoggerAutoConfiguration.kt similarity index 97% rename from src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt rename to src/main/kotlin/io/firetail/logging/base/SpringLoggerAutoConfiguration.kt index f786602..c119a72 100644 --- a/src/main/kotlin/io/firetail/logging/config/SpringLoggerAutoConfiguration.kt +++ b/src/main/kotlin/io/firetail/logging/base/SpringLoggerAutoConfiguration.kt @@ -1,5 +1,6 @@ -package io.firetail.logging.config +package io.firetail.logging.base +import io.firetail.logging.servlet.FiretailHeaderInterceptor import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean @@ -12,7 +13,6 @@ import org.springframework.web.client.RestTemplate import java.util.* import javax.annotation.PostConstruct - // import net.logstash.logback.appender.LogstashTcpSocketAppender; // import net.logstash.logback.encoder.LogstashEncoder; @Configuration diff --git a/src/main/kotlin/io/firetail/logging/filter/FiretailFilter.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt similarity index 82% rename from src/main/kotlin/io/firetail/logging/filter/FiretailFilter.kt rename to src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt index e73b985..c017d5d 100644 --- a/src/main/kotlin/io/firetail/logging/filter/FiretailFilter.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt @@ -1,15 +1,14 @@ -package io.firetail.logging.filter +package io.firetail.logging.servlet -import io.firetail.logging.config.Constants.Companion.CORRELATION_ID -import io.firetail.logging.config.Constants.Companion.OP_NAME -import io.firetail.logging.config.Constants.Companion.REQUEST_ID -import io.firetail.logging.config.FiretailConfig +import io.firetail.logging.base.Constants.Companion.CORRELATION_ID +import io.firetail.logging.base.Constants.Companion.OP_NAME +import io.firetail.logging.base.Constants.Companion.REQUEST_ID +import io.firetail.logging.base.FiretailConfig +import io.firetail.logging.base.FiretailLogger import io.firetail.logging.util.LogContext -import io.firetail.logging.util.StringUtils -import io.firetail.logging.wrapper.SpringRequestWrapper -import io.firetail.logging.wrapper.SpringResponseWrapper import org.slf4j.LoggerFactory import org.slf4j.MDC +import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Bean import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.method.HandlerMethod @@ -23,10 +22,12 @@ import javax.servlet.http.HttpServletResponse class FiretailFilter( private val logContext: LogContext, - private val firetailConfig: FiretailConfig, ) { - private val stringUtils = StringUtils() // UTF-8 - private val firetailLogger = FiretailLogger(stringUtils, firetailConfig.logHeaders) + @Autowired + lateinit var firetailConfig: FiretailConfig + + @Autowired + private lateinit var firetailLogger: FiretailLogger @Bean fun firetailRequestFilter(): OncePerRequestFilter { diff --git a/src/main/kotlin/io/firetail/logging/config/FiretailHeaderInterceptor.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt similarity index 80% rename from src/main/kotlin/io/firetail/logging/config/FiretailHeaderInterceptor.kt rename to src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt index 03cc225..dac6d02 100644 --- a/src/main/kotlin/io/firetail/logging/config/FiretailHeaderInterceptor.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt @@ -1,7 +1,7 @@ -package io.firetail.logging.config +package io.firetail.logging.servlet -import io.firetail.logging.config.Constants.Companion.CORRELATION_ID -import io.firetail.logging.config.Constants.Companion.REQUEST_ID +import io.firetail.logging.base.Constants.Companion.CORRELATION_ID +import io.firetail.logging.base.Constants.Companion.REQUEST_ID import org.slf4j.MDC import org.springframework.http.HttpRequest import org.springframework.http.client.ClientHttpRequestExecution diff --git a/src/main/kotlin/io/firetail/logging/wrapper/ServletOutputStreamWrapper.kt b/src/main/kotlin/io/firetail/logging/servlet/ServletOutputStreamWrapper.kt similarity index 94% rename from src/main/kotlin/io/firetail/logging/wrapper/ServletOutputStreamWrapper.kt rename to src/main/kotlin/io/firetail/logging/servlet/ServletOutputStreamWrapper.kt index 32d7e86..4a8981e 100644 --- a/src/main/kotlin/io/firetail/logging/wrapper/ServletOutputStreamWrapper.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/ServletOutputStreamWrapper.kt @@ -1,4 +1,4 @@ -package io.firetail.logging.wrapper +package io.firetail.logging.servlet import java.io.ByteArrayOutputStream import java.io.OutputStream diff --git a/src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt b/src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt similarity index 93% rename from src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt rename to src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt index a10a8f5..55a9611 100644 --- a/src/main/kotlin/io/firetail/logging/wrapper/SpringRequestWrapper.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt @@ -1,6 +1,6 @@ -package io.firetail.logging.wrapper +package io.firetail.logging.servlet -import io.firetail.logging.config.Constants.Companion.empty +import io.firetail.logging.base.Constants.Companion.empty import org.apache.commons.io.IOUtils import java.io.ByteArrayInputStream import java.io.IOException diff --git a/src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt b/src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt similarity index 94% rename from src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt rename to src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt index 3f9833c..8ea6d8b 100644 --- a/src/main/kotlin/io/firetail/logging/wrapper/SpringResponseWrapper.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt @@ -1,6 +1,6 @@ -package io.firetail.logging.wrapper +package io.firetail.logging.servlet -import io.firetail.logging.config.Constants.Companion.empty +import io.firetail.logging.base.Constants.Companion.empty import java.io.OutputStreamWriter import java.io.PrintWriter import java.util.function.Consumer diff --git a/src/main/kotlin/io/firetail/logging/util/Generator.kt b/src/main/kotlin/io/firetail/logging/util/KeyGenerator.kt similarity index 64% rename from src/main/kotlin/io/firetail/logging/util/Generator.kt rename to src/main/kotlin/io/firetail/logging/util/KeyGenerator.kt index 1eaabd0..6476bb5 100644 --- a/src/main/kotlin/io/firetail/logging/util/Generator.kt +++ b/src/main/kotlin/io/firetail/logging/util/KeyGenerator.kt @@ -1,8 +1,10 @@ package io.firetail.logging.util +import org.springframework.stereotype.Service import java.util.UUID -class Generator { +@Service +class KeyGenerator { fun generate(): String { return UUID.randomUUID().toString() } diff --git a/src/main/kotlin/io/firetail/logging/util/LogContext.kt b/src/main/kotlin/io/firetail/logging/util/LogContext.kt index fc775b6..f73456a 100644 --- a/src/main/kotlin/io/firetail/logging/util/LogContext.kt +++ b/src/main/kotlin/io/firetail/logging/util/LogContext.kt @@ -1,11 +1,13 @@ package io.firetail.logging.util -import io.firetail.logging.config.Constants.Companion.CORRELATION_ID -import io.firetail.logging.config.Constants.Companion.REQUEST_ID +import io.firetail.logging.base.Constants.Companion.CORRELATION_ID +import io.firetail.logging.base.Constants.Companion.REQUEST_ID import org.slf4j.MDC +import org.springframework.stereotype.Service import javax.servlet.http.HttpServletRequest -class LogContext(private val generator: Generator = Generator()) { +@Service +class LogContext(private val keyGenerator: KeyGenerator = KeyGenerator()) { fun generateAndSetMDC(request: HttpServletRequest) { MDC.clear() MDC.put(REQUEST_ID, getValue(request, REQUEST_ID)) @@ -13,6 +15,6 @@ class LogContext(private val generator: Generator = Generator()) { } private fun getValue(request: HttpServletRequest, key: String): String { - return request.getHeader(key) ?: generator.generate() + return request.getHeader(key) ?: keyGenerator.generate() } } diff --git a/src/main/kotlin/io/firetail/logging/util/StringUtils.kt b/src/main/kotlin/io/firetail/logging/util/StringUtils.kt index 022c381..89ced9c 100644 --- a/src/main/kotlin/io/firetail/logging/util/StringUtils.kt +++ b/src/main/kotlin/io/firetail/logging/util/StringUtils.kt @@ -1,9 +1,11 @@ package io.firetail.logging.util import org.apache.commons.io.IOUtils +import org.springframework.stereotype.Service import java.nio.charset.Charset import kotlin.text.Charsets.UTF_8 +@Service class StringUtils(private val defaultCharset: Charset = UTF_8) { fun toString(inputStream: ByteArray, characterEncoding: String = charSet()): String { return IOUtils.toString( diff --git a/src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt b/src/test/kotlin/io/firetail/logging/IdKeyGeneratorTests.kt similarity index 75% rename from src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt rename to src/test/kotlin/io/firetail/logging/IdKeyGeneratorTests.kt index a1ac174..32fae56 100644 --- a/src/test/kotlin/io/firetail/logging/IdGeneratorTests.kt +++ b/src/test/kotlin/io/firetail/logging/IdKeyGeneratorTests.kt @@ -1,8 +1,8 @@ package io.firetail.logging -import io.firetail.logging.config.Constants.Companion.CORRELATION_ID -import io.firetail.logging.config.Constants.Companion.REQUEST_ID -import io.firetail.logging.util.Generator +import io.firetail.logging.base.Constants.Companion.CORRELATION_ID +import io.firetail.logging.base.Constants.Companion.REQUEST_ID +import io.firetail.logging.util.KeyGenerator import io.firetail.logging.util.LogContext import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -10,7 +10,7 @@ import org.mockito.Mockito import org.slf4j.MDC import org.springframework.mock.web.MockHttpServletRequest -class IdGeneratorTests { +class IdKeyGeneratorTests { @Test fun mdcIsSetFromHeaderValues() { val logContext = LogContext() // test with default generator @@ -29,14 +29,14 @@ class IdGeneratorTests { @Test fun mdcIsSetWhenNoHeaderValues() { - val generator = Mockito.mock(Generator::class.java) + val keyGenerator = Mockito.mock(KeyGenerator::class.java) MDC.clear() assertThat(MDC.get(CORRELATION_ID)).isNull() assertThat(MDC.get(REQUEST_ID)).isNull() val httpRequest = MockHttpServletRequest() val id = "someValue" - Mockito.`when`(generator.generate()).thenReturn(id) - val idGenerator = LogContext(generator) + Mockito.`when`(keyGenerator.generate()).thenReturn(id) + val idGenerator = LogContext(keyGenerator) idGenerator.generateAndSetMDC(httpRequest) assertThat(httpRequest.headerNames.toList()).isEmpty() assertThat(MDC.get(REQUEST_ID)).isEqualTo(id) @@ -45,7 +45,7 @@ class IdGeneratorTests { @Test fun generateId() { - val generator = Generator() - assertThat(generator.generate()).isNotNull().isNotEmpty() + val keyGenerator = KeyGenerator() + assertThat(keyGenerator.generate()).isNotNull().isNotEmpty() } } diff --git a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt index 8ff170d..8dc7463 100644 --- a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt +++ b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt @@ -1,20 +1,24 @@ package io.firetail.logging -import io.firetail.logging.config.FiretailConfig -import io.firetail.logging.filter.FiretailLogger +import io.firetail.logging.base.Constants +import io.firetail.logging.base.FiretailConfig +import io.firetail.logging.base.FiretailLogger import io.firetail.logging.util.LogContext import io.firetail.logging.util.StringUtils import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.any +import org.mockito.kotlin.verify +import org.slf4j.MDC import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.ApplicationContext import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders -import org.springframework.test.web.servlet.result.MockMvcResultHandlers import org.springframework.test.web.servlet.result.MockMvcResultMatchers import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController @@ -24,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController classes = [ RequestInterceptorTests.SimpleController::class, FiretailConfig::class, + FiretailLogger::class, + StringUtils::class, ApplicationContext::class, ], ) @@ -33,7 +39,7 @@ class RequestInterceptorTests { @Autowired private lateinit var stringUtils: StringUtils - @Autowired + @MockBean private lateinit var firetailLogger: FiretailLogger @Autowired @@ -50,14 +56,31 @@ class RequestInterceptorTests { } @Test - fun something() { - mockMvc.perform(MockMvcRequestBuilders.get("/hello")) - .andDo(MockMvcResultHandlers.print()) + fun fireTailRequestLoggingAndResponse() { + MDC.clear() + val result = mockMvc.perform(MockMvcRequestBuilders.get("/hello")) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() + verify(firetailLogger) + .logRequest(any()) // Called once + verify(firetailLogger) + .logResponse(any(), any(), any()) // Called once + + // Headers are set + assertThat(result.response.headerNames) + .contains(Constants.REQUEST_ID, Constants.CORRELATION_ID) + + assertThat(MDC.get(Constants.REQUEST_ID)) + .isNotBlank() + .isEqualTo(result.response.getHeaderValue(Constants.REQUEST_ID)) + assertThat(MDC.get(Constants.CORRELATION_ID)) + .isNotBlank() + .isEqualTo(result.response.getHeaderValue(Constants.CORRELATION_ID)) } - @RestController("/") + // Emulates a general MVC controller for which we want to + // assert Firetail calls have been made. + @RestController internal class SimpleController { @GetMapping("/hello") From 55d1336e5ad3e40d427c6d1ad34367f424384b55 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Tue, 24 Oct 2023 17:32:04 +0800 Subject: [PATCH 05/25] chore: Fix publishToMavenLocal --- .gitignore | 2 +- build.gradle.kts | 25 +++++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 3163428..bb8b2ec 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ !.secrets/public-keys/ .gitsecret/keys/random_seed .gitsecret/keys/pubring.kbx~ - +gradle.properties .ci/gradle* diff --git a/build.gradle.kts b/build.gradle.kts index 7d13048..a79154a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { `java-library` `maven-publish` + signing // Not possible to set the version for a plugin from a variable. kotlin("plugin.spring") version "1.8.21" kotlin("jvm") version "1.8.21" @@ -30,13 +31,13 @@ description = "firetail-java-lib" dependencies { implementation( - platform("org.springframework.boot:spring-boot-dependencies:2.7.15"), + platform("org.springframework.boot:spring-boot-dependencies:2.7.17"), ) + // Dependencies are transitively imported from spring-boot-dependencies api("org.jetbrains.kotlin:kotlin-stdlib-jdk8") api("commons-io:commons-io:2.7") api("net.logstash.logback:logstash-logback-encoder:7.4") api("javax.annotation:javax.annotation-api:1.3.2") - // Dependencies are transitively imported from spring-boot-dependencies api("org.slf4j:slf4j-api") api("ch.qos.logback:logback-classic") compileOnly("javax.servlet:javax.servlet-api") @@ -53,11 +54,27 @@ dependencies { testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0") } +signing { + setRequired { + // signing is only required if the artifacts are to be published + gradle.taskGraph.allTasks.any { it is PublishToMavenRepository } + } + if (project.hasProperty("signJar") && project.property("signJar") == "true") { + val signingKey: String? by project + val signingPassword: String? by project + useInMemoryPgpKeys(signingKey, signingPassword) + sign(configurations["archives"]) + } +} + publishing { - publications.create("maven") { - from(components["java"]) + publications { + create("jar") { + artifact(tasks.named("jar")) + } } } + kotlin { jvmToolchain(17) } From a9ba96f62ebe86d863ab1d5abf29b2a4e8cbaed9 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Wed, 25 Oct 2023 18:01:41 +0800 Subject: [PATCH 06/25] feat: Added Spring Boot Web Demo chore: upgrade gradle --- build.gradle.kts | 2 +- examples/.gitignore | 33 +++ examples/README.md | 16 ++ examples/build.gradle.kts | 30 +++ examples/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63721 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + examples/gradlew | 249 ++++++++++++++++++ examples/gradlew.bat | 92 +++++++ examples/pom.xml | 47 ++++ examples/settings.gradle.kts | 10 + .../com/example/demo/DemoApplication.java | 21 ++ .../src/main/resources/application.properties | 1 + .../example/demo/DemoApplicationTests.java | 13 + gradle/wrapper/gradle-wrapper.jar | Bin 63375 -> 63721 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 17 +- 16 files changed, 530 insertions(+), 10 deletions(-) create mode 100644 examples/.gitignore create mode 100644 examples/README.md create mode 100644 examples/build.gradle.kts create mode 100644 examples/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/gradlew create mode 100644 examples/gradlew.bat create mode 100644 examples/pom.xml create mode 100644 examples/settings.gradle.kts create mode 100644 examples/src/main/java/com/example/demo/DemoApplication.java create mode 100644 examples/src/main/resources/application.properties create mode 100644 examples/src/test/java/com/example/demo/DemoApplicationTests.java diff --git a/build.gradle.kts b/build.gradle.kts index a79154a..d811491 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,7 +25,7 @@ repositories { } group = "com.github.firetail-io" -version = "0.0.1.SNAPSHOT" +version = "0.0.1-SNAPSHOT" description = "firetail-java-lib" // java.sourceCompatibility = JavaVersion.VERSION_1_8 diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..8a6d526 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,16 @@ +Spring Boot Demo + +Requires Java 17 + +Firstly, build the Firetail-Java-Library + +```bash +# Build the firetail library +cd .. +./gradlew build publishToMavenLocal +# Run the example +cd examples +./gradlew bootRun +curl http://localhost:8080/hello + +``` \ No newline at end of file diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts new file mode 100644 index 0000000..b6cb38f --- /dev/null +++ b/examples/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id("org.springframework.boot") version "3.1.3" + id("io.spring.dependency-management") version "1.1.2" + kotlin("jvm") version "1.6.10" +} + +group = "com.github.firetail-io" +version = "0.0.1-SNAPSHOT" + +repositories { + mavenLocal() + maven { + url = uri("https://repo.maven.apache.org/maven2/") + } + mavenCentral() +} + +dependencies { + implementation( + platform("org.springframework.boot:spring-boot-dependencies:3.1.3"), + ) + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.springframework.boot:spring-boot-starter-web") + testImplementation("org.springframework.boot:spring-boot-starter-test") + implementation("com.github.firetail-io:firetail-java-lib:$version") +} + +tasks.named("bootRun") { + args("--spring.profiles.active=dev") +} diff --git a/examples/gradle/wrapper/gradle-wrapper.jar b/examples/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..7f93135c49b765f8051ef9d0a6055ff8e46073d8 GIT binary patch literal 63721 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鯊%1Et zwk~hMkhq;MOw^Q5`7oC{CUUyTw9x>^%*FHx^qJw(LB+E0WBX@{Ghw;)6aA-KyYg8p z7XDveQOpEr;B4je@2~usI5BlFadedX^ma{b{ypd|RNYqo#~d*mj&y`^iojR}s%~vF z(H!u`yx68D1Tj(3(m;Q+Ma}s2n#;O~bcB1`lYk%Irx60&-nWIUBr2x&@}@76+*zJ5 ze&4?q8?m%L9c6h=J$WBzbiTf1Z-0Eb5$IZs>lvm$>1n_Mezp*qw_pr8<8$6f)5f<@ zyV#tzMCs51nTv_5ca`x`yfE5YA^*%O_H?;tWYdM_kHPubA%vy47i=9>Bq) zRQ&0UwLQHeswmB1yP)+BiR;S+Vc-5TX84KUA;8VY9}yEj0eESSO`7HQ4lO z4(CyA8y1G7_C;6kd4U3K-aNOK!sHE}KL_-^EDl(vB42P$2Km7$WGqNy=%fqB+ zSLdrlcbEH=T@W8V4(TgoXZ*G1_aq$K^@ek=TVhoKRjw;HyI&coln|uRr5mMOy2GXP zwr*F^Y|!Sjr2YQXX(Fp^*`Wk905K%$bd03R4(igl0&7IIm*#f`A!DCarW9$h$z`kYk9MjjqN&5-DsH@8xh63!fTNPxWsFQhNv z#|3RjnP$Thdb#Ys7M+v|>AHm0BVTw)EH}>x@_f4zca&3tXJhTZ8pO}aN?(dHo)44Z z_5j+YP=jMlFqwvf3lq!57-SAuRV2_gJ*wsR_!Y4Z(trO}0wmB9%f#jNDHPdQGHFR; zZXzS-$`;7DQ5vF~oSgP3bNV$6Z(rwo6W(U07b1n3UHqml>{=6&-4PALATsH@Bh^W? z)ob%oAPaiw{?9HfMzpGb)@Kys^J$CN{uf*HX?)z=g`J(uK1YO^8~s1(ZIbG%Et(|q z$D@_QqltVZu9Py4R0Ld8!U|#`5~^M=b>fnHthzKBRr=i+w@0Vr^l|W;=zFT#PJ?*a zbC}G#It}rQP^Ait^W&aa6B;+0gNvz4cWUMzpv(1gvfw-X4xJ2Sv;mt;zb2Tsn|kSS zo*U9N?I{=-;a-OybL4r;PolCfiaL=y@o9{%`>+&FI#D^uy#>)R@b^1ue&AKKwuI*` zx%+6r48EIX6nF4o;>)zhV_8(IEX})NGU6Vs(yslrx{5fII}o3SMHW7wGtK9oIO4OM&@@ECtXSICLcPXoS|{;=_yj>hh*%hP27yZwOmj4&Lh z*Nd@OMkd!aKReoqNOkp5cW*lC)&C$P?+H3*%8)6HcpBg&IhGP^77XPZpc%WKYLX$T zsSQ$|ntaVVOoRat$6lvZO(G-QM5s#N4j*|N_;8cc2v_k4n6zx9c1L4JL*83F-C1Cn zaJhd;>rHXB%%ZN=3_o3&Qd2YOxrK~&?1=UuN9QhL$~OY-Qyg&})#ez*8NpQW_*a&kD&ANjedxT0Ar z<6r{eaVz3`d~+N~vkMaV8{F?RBVemN(jD@S8qO~L{rUw#=2a$V(7rLE+kGUZ<%pdr z?$DP|Vg#gZ9S}w((O2NbxzQ^zTot=89!0^~hE{|c9q1hVzv0?YC5s42Yx($;hAp*E zyoGuRyphQY{Q2ee0Xx`1&lv(l-SeC$NEyS~8iil3_aNlnqF_G|;zt#F%1;J)jnPT& z@iU0S;wHJ2$f!juqEzPZeZkjcQ+Pa@eERSLKsWf=`{R@yv7AuRh&ALRTAy z8=g&nxsSJCe!QLchJ=}6|LshnXIK)SNd zRkJNiqHwKK{SO;N5m5wdL&qK`v|d?5<4!(FAsDxR>Ky#0#t$8XCMptvNo?|SY?d8b z`*8dVBlXTUanlh6n)!EHf2&PDG8sXNAt6~u-_1EjPI1|<=33T8 zEnA00E!`4Ave0d&VVh0e>)Dc}=FfAFxpsC1u9ATfQ`-Cu;mhc8Z>2;uyXtqpLb7(P zd2F9<3cXS} znMg?{&8_YFTGRQZEPU-XPq55%51}RJpw@LO_|)CFAt62-_!u_Uq$csc+7|3+TV_!h z+2a7Yh^5AA{q^m|=KSJL+w-EWDBc&I_I1vOr^}P8i?cKMhGy$CP0XKrQzCheG$}G# zuglf8*PAFO8%xop7KSwI8||liTaQ9NCAFarr~psQt)g*pC@9bORZ>m`_GA`_K@~&% zijH0z;T$fd;-Liw8%EKZas>BH8nYTqsK7F;>>@YsE=Rqo?_8}UO-S#|6~CAW0Oz1} z3F(1=+#wrBJh4H)9jTQ_$~@#9|Bc1Pd3rAIA_&vOpvvbgDJOM(yNPhJJq2%PCcMaI zrbe~toYzvkZYQ{ea(Wiyu#4WB#RRN%bMe=SOk!CbJZv^m?Flo5p{W8|0i3`hI3Np# zvCZqY%o258CI=SGb+A3yJe~JH^i{uU`#U#fvSC~rWTq+K`E%J@ zasU07&pB6A4w3b?d?q}2=0rA#SA7D`X+zg@&zm^iA*HVi z009#PUH<%lk4z~p^l0S{lCJk1Uxi=F4e_DwlfHA`X`rv(|JqWKAA5nH+u4Da+E_p+ zVmH@lg^n4ixs~*@gm_dgQ&eDmE1mnw5wBz9Yg?QdZwF|an67Xd*x!He)Gc8&2!urh z4_uXzbYz-aX)X1>&iUjGp;P1u8&7TID0bTH-jCL&Xk8b&;;6p2op_=y^m@Nq*0{#o!!A;wNAFG@0%Z9rHo zcJs?Th>Ny6+hI`+1XoU*ED$Yf@9f91m9Y=#N(HJP^Y@ZEYR6I?oM{>&Wq4|v0IB(p zqX#Z<_3X(&{H+{3Tr|sFy}~=bv+l=P;|sBz$wk-n^R`G3p0(p>p=5ahpaD7>r|>pm zv;V`_IR@tvZreIuv2EM7ZQHhO+qUgw#kOs%*ekY^n|=1#x9&c;Ro&I~{rG-#_3ZB1 z?|9}IFdbP}^DneP*T-JaoYHt~r@EfvnPE5EKUwIxjPbsr$% zfWW83pgWST7*B(o=kmo)74$8UU)v0{@4DI+ci&%=#90}!CZz|rnH+Mz=HN~97G3~@ z;v5(9_2%eca(9iu@J@aqaMS6*$TMw!S>H(b z4(*B!|H|8&EuB%mITr~O?vVEf%(Gr)6E=>H~1VR z&1YOXluJSG1!?TnT)_*YmJ*o_Q@om~(GdrhI{$Fsx_zrkupc#y{DK1WOUR>tk>ZE) ziOLoBkhZZ?0Uf}cm>GsA>Rd6V8@JF)J*EQlQ<=JD@m<)hyElXR0`pTku*3MU`HJn| zIf7$)RlK^pW-$87U;431;Ye4Ie+l~_B3*bH1>*yKzn23cH0u(i5pXV! z4K?{3oF7ZavmmtTq((wtml)m6i)8X6ot_mrE-QJCW}Yn!(3~aUHYG=^fA<^~`e3yc z-NWTb{gR;DOUcK#zPbN^D*e=2eR^_!(!RKkiwMW@@yYtEoOp4XjOGgzi`;=8 zi3`Ccw1%L*y(FDj=C7Ro-V?q)-%p?Ob2ZElu`eZ99n14-ZkEV#y5C+{Pq87Gu3&>g zFy~Wk7^6v*)4pF3@F@rE__k3ikx(hzN3@e*^0=KNA6|jC^B5nf(XaoQaZN?Xi}Rn3 z$8&m*KmWvPaUQ(V<#J+S&zO|8P-#!f%7G+n_%sXp9=J%Z4&9OkWXeuZN}ssgQ#Tcj z8p6ErJQJWZ+fXLCco=RN8D{W%+*kko*2-LEb))xcHwNl~Xmir>kmAxW?eW50Osw3# zki8Fl$#fvw*7rqd?%E?}ZX4`c5-R&w!Y0#EBbelVXSng+kUfeUiqofPehl}$ormli zg%r)}?%=?_pHb9`Cq9Z|B`L8b>(!+8HSX?`5+5mm81AFXfnAt1*R3F z%b2RPIacKAddx%JfQ8l{3U|vK@W7KB$CdLqn@wP^?azRks@x8z59#$Q*7q!KilY-P zHUbs(IFYRGG1{~@RF;Lqyho$~7^hNC`NL3kn^Td%A7dRgr_&`2k=t+}D-o9&C!y^? z6MsQ=tc3g0xkK(O%DzR9nbNB(r@L;1zQrs8mzx&4dz}?3KNYozOW5;=w18U6$G4U2 z#2^qRLT*Mo4bV1Oeo1PKQ2WQS2Y-hv&S|C7`xh6=Pj7MNLC5K-zokZ67S)C;(F0Dd zloDK2_o1$Fmza>EMj3X9je7e%Q`$39Dk~GoOj89-6q9|_WJlSl!!+*{R=tGp z8u|MuSwm^t7K^nUe+^0G3dkGZr3@(X+TL5eah)K^Tn zXEtHmR9UIaEYgD5Nhh(s*fcG_lh-mfy5iUF3xxpRZ0q3nZ=1qAtUa?(LnT9I&~uxX z`pV?+=|-Gl(kz?w!zIieXT}o}7@`QO>;u$Z!QB${a08_bW0_o@&9cjJUXzVyNGCm8 zm=W+$H!;_Kzp6WQqxUI;JlPY&`V}9C$8HZ^m?NvI*JT@~BM=()T()Ii#+*$y@lTZBkmMMda>7s#O(1YZR+zTG@&}!EXFG{ zEWPSDI5bFi;NT>Yj*FjH((=oe%t%xYmE~AGaOc4#9K_XsVpl<4SP@E!TgC0qpe1oi zNpxU2b0(lEMcoibQ-G^cxO?ySVW26HoBNa;n0}CWL*{k)oBu1>F18X061$SP{Gu67 z-v-Fa=Fl^u3lnGY^o5v)Bux}bNZ~ z5pL+7F_Esoun8^5>z8NFoIdb$sNS&xT8_|`GTe8zSXQzs4r^g0kZjg(b0bJvz`g<70u9Z3fQILX1Lj@;@+##bP|FAOl)U^9U>0rx zGi)M1(Hce)LAvQO-pW!MN$;#ZMX?VE(22lTlJrk#pB0FJNqVwC+*%${Gt#r_tH9I_ z;+#)#8cWAl?d@R+O+}@1A^hAR1s3UcW{G+>;X4utD2d9X(jF555}!TVN-hByV6t+A zdFR^aE@GNNgSxxixS2p=on4(+*+f<8xrwAObC)D5)4!z7)}mTpb7&ofF3u&9&wPS< zB62WHLGMhmrmOAgmJ+|c>qEWTD#jd~lHNgT0?t-p{T=~#EMcB| z=AoDKOL+qXCfk~F)-Rv**V}}gWFl>liXOl7Uec_8v)(S#av99PX1sQIVZ9eNLkhq$ zt|qu0b?GW_uo}TbU8!jYn8iJeIP)r@;!Ze_7mj{AUV$GEz6bDSDO=D!&C9!M@*S2! zfGyA|EPlXGMjkH6x7OMF?gKL7{GvGfED=Jte^p=91FpCu)#{whAMw`vSLa`K#atdN zThnL+7!ZNmP{rc=Z>%$meH;Qi1=m1E3Lq2D_O1-X5C;!I0L>zur@tPAC9*7Jeh)`;eec}1`nkRP(%iv-`N zZ@ip-g|7l6Hz%j%gcAM}6-nrC8oA$BkOTz^?dakvX?`^=ZkYh%vUE z9+&)K1UTK=ahYiaNn&G5nHUY5niLGus@p5E2@RwZufRvF{@$hW{;{3QhjvEHMvduO z#Wf-@oYU4ht?#uP{N3utVzV49mEc9>*TV_W2TVC`6+oI)zAjy$KJrr=*q##&kobiQ z1vNbya&OVjK`2pdRrM?LuK6BgrLN7H_3m z!qpNKg~87XgCwb#I=Q&0rI*l$wM!qTkXrx1ko5q-f;=R2fImRMwt5Qs{P*p^z@9ex z`2#v(qE&F%MXlHpdO#QEZyZftn4f05ab^f2vjxuFaat2}jke{j?5GrF=WYBR?gS(^ z9SBiNi}anzBDBRc+QqizTTQuJrzm^bNA~A{j%ugXP7McZqJ}65l10({wk++$=e8O{ zxWjG!Qp#5OmI#XRQQM?n6?1ztl6^D40hDJr?4$Wc&O_{*OfMfxe)V0=e{|N?J#fgE>j9jAajze$iN!*yeF%jJU#G1c@@rm zolGW!j?W6Q8pP=lkctNFdfgUMg92wlM4E$aks1??M$~WQfzzzXtS)wKrr2sJeCN4X zY(X^H_c^PzfcO8Bq(Q*p4c_v@F$Y8cHLrH$`pJ2}=#*8%JYdqsqnGqEdBQMpl!Ot04tUGSXTQdsX&GDtjbWD=prcCT9(+ z&UM%lW%Q3yrl1yiYs;LxzIy>2G}EPY6|sBhL&X&RAQrSAV4Tlh2nITR?{6xO9ujGu zr*)^E`>o!c=gT*_@6S&>0POxcXYNQd&HMw6<|#{eSute2C3{&h?Ah|cw56-AP^f8l zT^kvZY$YiH8j)sk7_=;gx)vx-PW`hbSBXJGCTkpt;ap(}G2GY=2bbjABU5)ty%G#x zAi07{Bjhv}>OD#5zh#$0w;-vvC@^}F! z#X$@)zIs1L^E;2xDAwEjaXhTBw2<{&JkF*`;c3<1U@A4MaLPe{M5DGGkL}#{cHL%* zYMG+-Fm0#qzPL#V)TvQVI|?_M>=zVJr9>(6ib*#z8q@mYKXDP`k&A4A};xMK0h=yrMp~JW{L?mE~ph&1Y1a#4%SO)@{ zK2juwynUOC)U*hVlJU17%llUxAJFuKZh3K0gU`aP)pc~bE~mM!i1mi!~LTf>1Wp< zuG+ahp^gH8g8-M$u{HUWh0m^9Rg@cQ{&DAO{PTMudV6c?ka7+AO& z746QylZ&Oj`1aqfu?l&zGtJnpEQOt;OAFq19MXTcI~`ZcoZmyMrIKDFRIDi`FH)w; z8+*8tdevMDv*VtQi|e}CnB_JWs>fhLOH-+Os2Lh!&)Oh2utl{*AwR)QVLS49iTp{6 z;|172Jl!Ml17unF+pd+Ff@jIE-{Oxv)5|pOm@CkHW?{l}b@1>Pe!l}VccX#xp@xgJ zyE<&ep$=*vT=}7vtvif0B?9xw_3Gej7mN*dOHdQPtW5kA5_zGD zpA4tV2*0E^OUimSsV#?Tg#oiQ>%4D@1F5@AHwT8Kgen$bSMHD3sXCkq8^(uo7CWk`mT zuslYq`6Yz;L%wJh$3l1%SZv#QnG3=NZ=BK4yzk#HAPbqXa92;3K5?0kn4TQ`%E%X} z&>Lbt!!QclYKd6+J7Nl@xv!uD%)*bY-;p`y^ZCC<%LEHUi$l5biu!sT3TGGSTPA21 zT8@B&a0lJHVn1I$I3I1I{W9fJAYc+8 zVj8>HvD}&O`TqU2AAb={?eT;0hyL(R{|h23=4fDSZKC32;wWxsVj`P z3J3{M$PwdH!ro*Cn!D&=jnFR>BNGR<<|I8CI@+@658Dy(lhqbhXfPTVecY@L8%`3Q z1Fux2w?2C3th60jI~%OC9BtpNF$QPqcG+Pz96qZJ71_`0o0w_q7|h&O>`6U+^BA&5 zXd5Zp1Xkw~>M%RixTm&OqpNl8Q+ue=92Op_>T~_9UON?ZM2c0aGm=^A4ejrXj3dV9 zhh_bCt-b9`uOX#cFLj!vhZ#lS8Tc47OH>*)y#{O9?AT~KR9LntM|#l#Dlm^8{nZdk zjMl#>ZM%#^nK2TPzLcKxqx24P7R1FPlBy7LSBrRvx>fE$9AJ;7{PQm~^LBX^k#6Zq zw*Z(zJC|`!6_)EFR}8|n8&&Rbj8y028~P~sFXBFRt+tmqH-S3<%N;C&WGH!f3{7cm zy_fCAb9@HqaXa1Y5vFbxWf%#zg6SI$C+Uz5=CTO}e|2fjWkZ;Dx|84Ow~bkI=LW+U zuq;KSv9VMboRvs9)}2PAO|b(JCEC_A0wq{uEj|3x@}*=bOd zwr{TgeCGG>HT<@Zeq8y}vTpwDg#UBvD)BEs@1KP$^3$sh&_joQPn{hjBXmLPJ{tC) z*HS`*2+VtJO{|e$mM^|qv1R*8i(m1`%)}g=SU#T#0KlTM2RSvYUc1fP+va|4;5}Bfz98UvDCpq7}+SMV&;nX zQw~N6qOX{P55{#LQkrZk(e5YGzr|(B;Q;ju;2a`q+S9bsEH@i1{_Y0;hWYn1-79jl z5c&bytD*k)GqrVcHn6t-7kinadiD>B{Tl`ZY@`g|b~pvHh5!gKP4({rp?D0aFd_cN zhHRo4dd5^S6ViN(>(28qZT6E>??aRhc($kP`>@<+lIKS5HdhjVU;>f7<4))E*5|g{ z&d1}D|vpuV^eRj5j|xx9nwaCxXFG?Qbjn~_WSy=N}P0W>MP zG-F%70lX5Xr$a)2i6?i|iMyM|;Jtf*hO?=Jxj12oz&>P=1#h~lf%#fc73M2_(SUM- zf&qnjS80|_Y0lDgl&I?*eMumUklLe_=Td!9G@eR*tcPOgIShJipp3{A10u(4eT~DY zHezEj8V+7m!knn7)W!-5QI3=IvC^as5+TW1@Ern@yX| z7Nn~xVx&fGSr+L%4iohtS3w^{-H1A_5=r&x8}R!YZvp<2T^YFvj8G_vm}5q;^UOJf ztl=X3iL;;^^a#`t{Ae-%5Oq{?M#s6Npj+L(n-*LMI-yMR{)qki!~{5z{&`-iL}lgW zxo+tnvICK=lImjV$Z|O_cYj_PlEYCzu-XBz&XC-JVxUh9;6*z4fuBG+H{voCC;`~GYV|hj%j_&I zDZCj>Q_0RCwFauYoVMiUSB+*Mx`tg)bWmM^SwMA+?lBg12QUF_x2b)b?qb88K-YUd z0dO}3k#QirBV<5%jL$#wlf!60dizu;tsp(7XLdI=eQs?P`tOZYMjVq&jE)qK*6B^$ zBe>VvH5TO>s>izhwJJ$<`a8fakTL!yM^Zfr2hV9`f}}VVUXK39p@G|xYRz{fTI+Yq z20d=)iwjuG9RB$%$^&8#(c0_j0t_C~^|n+c`Apu|x7~;#cS-s=X1|C*YxX3ailhg_|0`g!E&GZJEr?bh#Tpb8siR=JxWKc{#w7g zWznLwi;zLFmM1g8V5-P#RsM@iX>TK$xsWuujcsVR^7TQ@!+vCD<>Bk9tdCo7Mzgq5 zv8d>dK9x8C@Qoh01u@3h0X_`SZluTb@5o;{4{{eF!-4405x8X7hewZWpz z2qEi4UTiXTvsa(0X7kQH{3VMF>W|6;6iTrrYD2fMggFA&-CBEfSqPlQDxqsa>{e2M z(R5PJ7uOooFc|9GU0ELA%m4&4Ja#cQpNw8i8ACAoK6?-px+oBl_yKmenZut#Xumjz zk8p^OV2KY&?5MUwGrBOo?ki`Sxo#?-Q4gw*Sh0k`@ zFTaYK2;}%Zk-68`#5DXU$2#=%YL#S&MTN8bF+!J2VT6x^XBci6O)Q#JfW{YMz) zOBM>t2rSj)n#0a3cjvu}r|k3od6W(SN}V-cL?bi*Iz-8uOcCcsX0L>ZXjLqk zZu2uHq5B|Kt>e+=pPKu=1P@1r9WLgYFq_TNV1p9pu0erHGd!+bBp!qGi+~4A(RsYN@CyXNrC&hxGmW)u5m35OmWwX`I+0yByglO`}HC4nGE^_HUs^&A(uaM zKPj^=qI{&ayOq#z=p&pnx@@k&I1JI>cttJcu@Ihljt?6p^6{|ds`0MoQwp+I{3l6` zB<9S((RpLG^>=Kic`1LnhpW2=Gu!x`m~=y;A`Qk!-w`IN;S8S930#vBVMv2vCKi}u z6<-VPrU0AnE&vzwV(CFC0gnZYcpa-l5T0ZS$P6(?9AM;`Aj~XDvt;Jua=jIgF=Fm? zdp=M$>`phx%+Gu};;-&7T|B1AcC#L4@mW5SV_^1BRbo6;2PWe$r+npRV`yc;T1mo& z+~_?7rA+(Um&o@Tddl zL_hxvWk~a)yY}%j`Y+200D%9$bWHy&;(yj{jpi?Rtz{J66ANw)UyPOm;t6FzY3$hx zcn)Ir79nhFvNa7^a{SHN7XH*|Vlsx`CddPnA&Qvh8aNhEA;mPVv;Ah=k<*u!Zq^7 z<=xs*iQTQOMMcg|(NA_auh@x`3#_LFt=)}%SQppP{E>mu_LgquAWvh<>L7tf9+~rO znwUDS52u)OtY<~!d$;m9+87aO+&`#2ICl@Y>&F{jI=H(K+@3M1$rr=*H^dye#~TyD z!){#Pyfn+|ugUu}G;a~!&&0aqQ59U@UT3|_JuBlYUpT$2+11;}JBJ`{+lQN9T@QFY z5+`t;6(TS0F?OlBTE!@7D`8#URDNqx2t6`GZ{ZgXeS@v%-eJzZOHz18aS|svxII$a zZeFjrJ*$IwX$f-Rzr_G>xbu@euGl)B7pC&S+CmDJBg$BoV~jxSO#>y z33`bupN#LDoW0feZe0%q8un0rYN|eRAnwDHQ6e_)xBTbtoZtTA=Fvk){q}9Os~6mQ zKB80VI_&6iSq`LnK7*kfHZoeX6?WE}8yjuDn=2#JG$+;-TOA1%^=DnXx%w{b=w}tS zQbU3XxtOI8E(!%`64r2`zog;5<0b4i)xBmGP^jiDZ2%HNSxIf3@wKs~uk4%3Mxz;~ zts_S~E4>W+YwI<-*-$U8*^HKDEa8oLbmqGg?3vewnaNg%Mm)W=)lcC_J+1ov^u*N3 zXJ?!BrH-+wGYziJq2Y#vyry6Z>NPgkEk+Ke`^DvNRdb>Q2Nlr#v%O@<5hbflI6EKE z9dWc0-ORk^T}jP!nkJ1imyjdVX@GrjOs%cpgA8-c&FH&$(4od#x6Y&=LiJZPINVyW z0snY$8JW@>tc2}DlrD3StQmA0Twck~@>8dSix9CyQOALcREdxoM$Sw*l!}bXKq9&r zysMWR@%OY24@e`?+#xV2bk{T^C_xSo8v2ZI=lBI*l{RciPwuE>L5@uhz@{!l)rtVlWC>)6(G)1~n=Q|S!{E9~6*fdpa*n z!()-8EpTdj=zr_Lswi;#{TxbtH$8*G=UM`I+icz7sr_SdnHXrv=?iEOF1UL+*6O;% zPw>t^kbW9X@oEXx<97%lBm-9?O_7L!DeD)Me#rwE54t~UBu9VZ zl_I1tBB~>jm@bw0Aljz8! zXBB6ATG6iByKIxs!qr%pz%wgqbg(l{65DP4#v(vqhhL{0b#0C8mq`bnqZ1OwFV z7mlZZJFMACm>h9v^2J9+^_zc1=JjL#qM5ZHaThH&n zXPTsR8(+)cj&>Un{6v*z?@VTLr{TmZ@-fY%*o2G}*G}#!bmqpoo*Ay@U!JI^Q@7gj;Kg-HIrLj4}#ec4~D2~X6vo;ghep-@&yOivYP zC19L0D`jjKy1Yi-SGPAn94(768Tcf$urAf{)1)9W58P`6MA{YG%O?|07!g9(b`8PXG1B1Sh0?HQmeJtP0M$O$hI z{5G`&9XzYhh|y@qsF1GnHN|~^ru~HVf#)lOTSrv=S@DyR$UKQk zjdEPFDz{uHM&UM;=mG!xKvp;xAGHOBo~>_=WFTmh$chpC7c`~7?36h)7$fF~Ii}8q zF|YXxH-Z?d+Q+27Rs3X9S&K3N+)OBxMHn1u(vlrUC6ckBY@@jl+mgr#KQUKo#VeFm zFwNYgv0<%~Wn}KeLeD9e1$S>jhOq&(e*I@L<=I5b(?G(zpqI*WBqf|Zge0&aoDUsC zngMRA_Kt0>La+Erl=Uv_J^p(z=!?XHpenzn$%EA`JIq#yYF?JLDMYiPfM(&Csr#f{ zdd+LJL1by?xz|D8+(fgzRs~(N1k9DSyK@LJygwaYX8dZl0W!I&c^K?7)z{2is;OkE zd$VK-(uH#AUaZrp=1z;O*n=b?QJkxu`Xsw&7yrX0?(CX=I-C#T;yi8a<{E~?vr3W> zQrpPqOW2M+AnZ&p{hqmHZU-;Q(7?- zP8L|Q0RM~sB0w1w53f&Kd*y}ofx@c z5Y6B8qGel+uT1JMot$nT1!Tim6{>oZzJXdyA+4euOLME?5Fd_85Uk%#E*ln%y{u8Q z$|?|R@Hpb~yTVK-Yr_S#%NUy7EBfYGAg>b({J|5b+j-PBpPy$Ns`PaJin4JdRfOaS zE|<HjH%NuJgsd2wOlv>~y=np%=2)$M9LS|>P)zJ+Fei5vYo_N~B0XCn+GM76 z)Xz3tg*FRVFgIl9zpESgdpWAavvVViGlU8|UFY{{gVJskg*I!ZjWyk~OW-Td4(mZ6 zB&SQreAAMqwp}rjy`HsG({l2&q5Y52<@AULVAu~rWI$UbFuZs>Sc*x+XI<+ez%$U)|a^unjpiW0l0 zj1!K0(b6$8LOjzRqQ~K&dfbMIE=TF}XFAi)$+h}5SD3lo z%%Qd>p9se=VtQG{kQ;N`sI)G^u|DN#7{aoEd zkksYP%_X$Rq08);-s6o>CGJ<}v`qs%eYf+J%DQ^2k68C%nvikRsN?$ap--f+vCS`K z#&~)f7!N^;sdUXu54gl3L=LN>FB^tuK=y2e#|hWiWUls__n@L|>xH{%8lIJTd5`w? zSwZbnS;W~DawT4OwSJVdAylbY+u5S+ZH{4hAi2&}Iv~W(UvHg(1GTZRPz`@{SOqzy z(8g&Dz=$PfRV=6FgxN~zo+G8OoPI&d-thcGVR*_^(R8COTM@bq?fDwY{}WhsQS1AK zF6R1t8!RdFmfocpJ6?9Yv~;WYi~XPgs(|>{5})j!AR!voO7y9&cMPo#80A(`za@t>cx<0;qxM@S*m(jYP)dMXr*?q0E`oL;12}VAep179uEr8c<=D zr5?A*C{eJ`z9Ee;E$8)MECqatHkbHH z&Y+ho0B$31MIB-xm&;xyaFCtg<{m~M-QDbY)fQ>Q*Xibb~8ytxZQ?QMf9!%cV zU0_X1@b4d+Pg#R!`OJ~DOrQz3@cpiGy~XSKjZQQ|^4J1puvwKeScrH8o{bscBsowomu z^f12kTvje`yEI3eEXDHJ6L+O{Jv$HVj%IKb|J{IvD*l6IG8WUgDJ*UGz z3!C%>?=dlfSJ>4U88)V+`U-!9r^@AxJBx8R;)J4Fn@`~k>8>v0M9xp90OJElWP&R5 zM#v*vtT}*Gm1^)Bv!s72T3PB0yVIjJW)H7a)ilkAvoaH?)jjb`MP>2z{%Y?}83 zUIwBKn`-MSg)=?R)1Q0z3b>dHE^)D8LFs}6ASG1|daDly_^lOSy&zIIhm*HXm1?VS=_iacG);_I9c zUQH1>i#*?oPIwBMJkzi_*>HoUe}_4o>2(SHWzqQ=;TyhAHS;Enr7!#8;sdlty&(>d zl%5cjri8`2X^Ds`jnw7>A`X|bl=U8n+3LKLy(1dAu8`g@9=5iw$R0qk)w8Vh_Dt^U zIglK}sn^)W7aB(Q>HvrX=rxB z+*L)3DiqpQ_%~|m=44LcD4-bxO3OO*LPjsh%p(k?&jvLp0py57oMH|*IMa(<|{m1(0S|x)?R-mqJ=I;_YUZA>J z62v*eSK;5w!h8J+6Z2~oyGdZ68waWfy09?4fU&m7%u~zi?YPHPgK6LDwphgaYu%0j zurtw)AYOpYKgHBrkX189mlJ`q)w-f|6>IER{5Lk97%P~a-JyCRFjejW@L>n4vt6#hq;!|m;hNE||LK3nw1{bJOy+eBJjK=QqNjI;Q6;Rp5 z&035pZDUZ#%Oa;&_7x0T<7!RW`#YBOj}F380Bq?MjjEhrvlCATPdkCTTl+2efTX$k zH&0zR1n^`C3ef~^sXzJK-)52(T}uTG%OF8yDhT76L~|^+hZ2hiSM*QA9*D5odI1>& z9kV9jC~twA5MwyOx(lsGD_ggYmztXPD`2=_V|ks_FOx!_J8!zM zTzh^cc+=VNZ&(OdN=y4Juw)@8-85lwf_#VMN!Ed(eQiRiLB2^2e`4dp286h@v@`O%_b)Y~A; zv}r6U?zs&@uD_+(_4bwoy7*uozNvp?bXFoB8?l8yG0qsm1JYzIvB_OH4_2G*IIOwT zVl%HX1562vLVcxM_RG*~w_`FbIc!(T=3>r528#%mwwMK}uEhJ()3MEby zQQjzqjWkwfI~;Fuj(Lj=Ug0y`>~C7`w&wzjK(rPw+Hpd~EvQ-ufQOiB4OMpyUKJhw zqEt~jle9d7S~LI~$6Z->J~QJ{Vdn3!c}g9}*KG^Kzr^(7VI5Gk(mHLL{itj_hG?&K4Ws0+T4gLfi3eu$N=`s36geNC?c zm!~}vG6lx9Uf^5M;bWntF<-{p^bruy~f?sk9 zcETAPQZLoJ8JzMMg<-=ju4keY@SY%Wo?u9Gx=j&dfa6LIAB|IrbORLV1-H==Z1zCM zeZcOYpm5>U2fU7V*h;%n`8 zN95QhfD994={1*<2vKLCNF)feKOGk`R#K~G=;rfq}|)s20&MCa65 zUM?xF5!&e0lF%|U!#rD@I{~OsS_?=;s_MQ_b_s=PuWdC)q|UQ&ea)DMRh5>fpQjXe z%9#*x=7{iRCtBKT#H>#v%>77|{4_slZ)XCY{s3j_r{tdpvb#|r|sbS^dU1x70$eJMU!h{Y7Kd{dl}9&vxQl6Jt1a` zHQZrWyY0?!vqf@u-fxU_@+}u(%Wm>0I#KP48tiAPYY!TdW(o|KtVI|EUB9V`CBBNaBLVih7+yMVF|GSoIQD0Jfb{ z!OXq;(>Z?O`1gap(L~bUcp>Lc@Jl-})^=6P%<~~9ywY=$iu8pJ0m*hOPzr~q`23eX zgbs;VOxxENe0UMVeN*>uCn9Gk!4siN-e>x)pIKAbQz!G)TcqIJ0`JBBaX>1-4_XO_-HCS^vr2vjv#7KltDZdyQ{tlWh4$Gm zB>|O1cBDC)yG(sbnc*@w6e%e}r*|IhpXckx&;sQCwGdKH+3oSG-2)Bf#x`@<4ETAr z0My%7RFh6ZLiZ_;X6Mu1YmXx7C$lSZ^}1h;j`EZd6@%JNUe=btBE z%s=Xmo1Ps?8G`}9+6>iaB8bgjUdXT?=trMu|4yLX^m0Dg{m7rpKNJey|EwHI+nN1e zL^>qN%5Fg)dGs4DO~uwIdXImN)QJ*Jhpj7$fq_^`{3fwpztL@WBB}OwQ#Epo-mqMO zsM$UgpFiG&d#)lzEQ{3Q;)&zTw;SzGOah-Dpm{!q7<8*)Ti_;xvV2TYXa}=faXZy? z3y?~GY@kl)>G&EvEijk9y1S`*=zBJSB1iet>0;x1Ai)*`^{pj0JMs)KAM=@UyOGtO z3y0BouW$N&TnwU6!%zS%nIrnANvZF&vB1~P5_d`x-giHuG zPJ;>XkVoghm#kZXRf>qxxEix;2;D1CC~NrbO6NBX!`&_$iXwP~P*c($EVV|669kDO zKoTLZNF4Cskh!Jz5ga9uZ`3o%7Pv`d^;a=cXI|>y;zC3rYPFLQkF*nv(r>SQvD*## z(Vo%^9g`%XwS0t#94zPq;mYGLKu4LU3;txF26?V~A0xZbU4Lmy`)>SoQX^m7fd^*E z+%{R4eN!rIk~K)M&UEzxp9dbY;_I^c} zOc{wlIrN_P(PPqi51k_$>Lt|X6A^|CGYgKAmoI#Li?;Wq%q~q*L7ehZkUrMxW67Jl zhsb~+U?33QS>eqyN{(odAkbopo=Q$Az?L+NZW>j;#~@wCDX?=L5SI|OxI~7!Pli;e zELMFcZtJY3!|=Gr2L4>z8yQ-{To>(f80*#;6`4IAiqUw`=Pg$%C?#1 z_g@hIGerILSU>=P>z{gM|DS91A4cT@PEIB^hSop!uhMo#2G;+tQSpDO_6nOnPWSLU zS;a9m^DFMXR4?*X=}d7l;nXuHk&0|m`NQn%d?8|Ab3A9l9Jh5s120ibWBdB z$5YwsK3;wvp!Kn@)Qae{ef`0#NwlRpQ}k^r>yos_Ne1;xyKLO?4)t_G4eK~wkUS2A&@_;)K0-03XGBzU+5f+uMDxC z(s8!8!RvdC#@`~fx$r)TKdLD6fWEVdEYtV#{ncT-ZMX~eI#UeQ-+H(Z43vVn%Yj9X zLdu9>o%wnWdvzA-#d6Z~vzj-}V3FQ5;axDIZ;i(95IIU=GQ4WuU{tl-{gk!5{l4_d zvvb&uE{%!iFwpymz{wh?bKr1*qzeZb5f6e6m_ozRF&zux2mlK=v_(_s^R6b5lu?_W4W3#<$zeG~Pd)^!4tzhs}-Sx$FJP>)ZGF(hVTH|C3(U zs0PO&*h_ zNA-&qZpTP$$LtIgfiCn07}XDbK#HIXdmv8zdz4TY;ifNIH-0jy(gMSByG2EF~Th#eb_TueZC` zE?3I>UTMpKQ})=C;6p!?G)M6w^u*A57bD?2X`m3X^6;&4%i_m(uGJ3Z5h`nwxM<)H z$I5m?wN>O~8`BGnZ=y^p6;0+%_0K}Dcg|K;+fEi|qoBqvHj(M&aHGqNF48~XqhtU? z^ogwBzRlOfpAJ+Rw7IED8lRbTdBdyEK$gPUpUG}j-M42xDj_&qEAQEtbs>D#dRd7Y z<&TpSZ(quQDHiCFn&0xsrz~4`4tz!CdL8m~HxZM_agu@IrBpyeL1Ft}V$HX_ZqDPm z-f89)pjuEzGdq-PRu`b1m+qBGY{zr_>{6Ss>F|xHZlJj9dt5HD$u`1*WZe)qEIuDSR)%z+|n zatVlhQ?$w#XRS7xUrFE;Y8vMGhQS5*T{ZnY=q1P?w5g$OKJ#M&e??tAmPWHMj3xhS ziGxapy?kn@$~2%ZY;M8Bc@%$pkl%Rvj!?o%agBvpQ-Q61n9kznC4ttrRNQ4%GFR5u zyv%Yo9~yxQJWJSfj z?#HY$y=O~F|2pZs22pu|_&Ajd+D(Mt!nPUG{|1nlvP`=R#kKH zO*s$r_%ss5h1YO7k0bHJ2CXN)Yd6CHn~W!R=SqkWe=&nAZu(Q1G!xgcUilM@YVei@2@a`8he z9@pM`)VB*=e7-MWgLlXlc)t;fF&-AwM{E-EX}pViFn0I0CNw2bNEnN2dj!^4(^zS3 zobUm1uQnpqk_4q{pl*n06=TfK_C>UgurKFjRXsK_LEn};=79`TB12tv6KzwSu*-C8 z;=~ohDLZylHQ|Mpx-?yql>|e=vI1Z!epyUpAcDCp4T|*RV&X`Q$0ogNwy6mFALo^@ z9=&(9txO8V@E!@6^(W0{*~CT>+-MA~vnJULBxCTUW>X5>r7*eXYUT0B6+w@lzw%n> z_VjJ<2qf|(d6jYq2(x$(ZDf!yVkfnbvNmb5c|hhZ^2TV_LBz`9w!e_V*W_(MiA7|= z&EeIIkw*+$Xd!)j8<@_<}A5;~A_>3JT*kX^@}cDoLd>Qj<`Se^wdUa(j0dp+Tl8EptwBm{9OGsdFEq zM`!pjf(Lm(`$e3FLOjqA5LnN5o!}z{ zNf}rJuZh@yUtq&ErjHeGzX4(!luV!jB&;FAP|!R_QHYw#^Z1LwTePAKJ6X&IDNO#; z)#I@Xnnzyij~C@UH~X51JCgQeF0&hTXnuoElz#m{heZRexWc0k4<>0+ClX7%0 zEBqCCld1tD9Zwkr4{?Nor19#E5-YKfB8d?qgR82-Ow2^AuNevly2*tHA|sK!ybYkX zm-sLQH72P&{vEAW6+z~O5d0qd=xW~rua~5a?ymYFSD@8&gV)E5@RNNBAj^C99+Z5Z zR@Pq55mbCQbz+Mn$d_CMW<-+?TU960agEk1J<>d>0K=pF19yN))a~4>m^G&tc*xR+yMD*S=yip-q=H zIlredHpsJV8H(32@Zxc@bX6a21dUV95Th--8pE6C&3F>pk=yv$yd6@Haw;$v4+Fcb zRwn{Qo@0`7aPa2LQOP}j9v>sjOo5Kqvn|`FLizX zB+@-u4Lw|jsvz{p^>n8Vo8H2peIqJJnMN}A)q6%$Tmig7eu^}K2 zrh$X?T|ZMsoh{6pdw1G$_T<`Ds-G=jc;qcGdK4{?dN2-XxjDNbb(7pk|3JUVCU4y; z)?LXR>f+AAu)JEiti_Zy#z5{RgsC}R(@jl%9YZ>zu~hKQ*AxbvhC378-I@{~#%Y`Z zy=a=9YpewPIC+gkEUUwtUL7|RU7=!^Aa}Mk^6uxOgRGA#JXjWLsjFUnix|Mau{hDT z7mn*z1m5g`vP(#tjT0Zy4eAY(br&!RiiXE=ZI!{sE1#^#%x^Z7t1U)b<;%Y}Q9=5v z;wpDCEZ@OE36TWT=|gxigT@VaW9BvHS05;_P(#s z8zI4XFQys}q)<`tkX$WnSarn{3e!s}4(J!=Yf>+Y>cP3f;vr63f2{|S^`_pWc)^5_!R z*(x-fuBxL51@xe!lnDBKi}Br$c$BMZ3%f2Sa6kLabiBS{pq*yj;q|k(86x`PiC{p6 z_bxCW{>Q2BA8~Ggz&0jkrcU+-$ANBsOop*ms>34K9lNYil@}jC;?cYP(m^P}nR6FV zk(M%48Z&%2Rx$A&FhOEirEhY0(dn;-k(qkTU)sFQ`+-ih+s@A8g?r8Pw+}2;35WYf zi}VO`jS`p(tc)$X$a>-#WXoW!phhatC*$}|rk>|wUU71eUJG^$c6_jwX?iSHM@6__ zvV|6%U*$sSXJu9SX?2%M^kK|}a2QJ8AhF{fuXrHZxXsI~O zGKX45!K7p*MCPEQ=gp?eu&#AW*pR{lhQR##P_*{c_DjMGL|3T3-bSJ(o$|M{ytU}> zAV>wq*uE*qFo9KvnA^@juy{x<-u*#2NvkV={Ly}ysKYB-k`K3@K#^S1Bb$8Y#0L0# z`6IkSG&|Z$ODy|VLS+y5pFJx&8tvPmMd8c9FhCyiU8~k6FwkakUd^(_ml8`rnl>JS zZV){9G*)xBqPz^LDqRwyS6w86#D^~xP4($150M)SOZRe9sn=>V#aG0Iy(_^YcPpIz8QYM-#s+n% z@Jd?xQq?Xk6=<3xSY7XYP$$yd&Spu{A#uafiIfy8gRC`o0nk{ezEDjb=q_qRAlR1d zFq^*9Gn)yTG4b}R{!+3hWQ+u3GT~8nwl2S1lpw`s0X_qpxv)g+JIkVKl${sYf_nV~B>Em>M;RlqGb5WVil(89 zs=ld@|#;dq1*vQGz=7--Br-|l) zZ%Xh@v8>B7P?~}?Cg$q9_={59l%m~O&*a6TKsCMAzG&vD>k2WDzJ6!tc!V)+oxF;h zJH;apM=wO?r_+*#;ulohuP=E>^zon}a$NnlcQ{1$SO*i=jnGVcQa^>QOILc)e6;eNTI>os=eaJ{*^DE+~jc zS}TYeOykDmJ=6O%>m`i*>&pO_S;qMySJIyP=}4E&J%#1zju$RpVAkZbEl+p%?ZP^C z*$$2b4t%a(e+%>a>d_f_<JjxI#J1x;=hPd1zFPx=6T$;;X1TD*2(edZ3f46zaAoW>L53vS_J*N8TMB|n+;LD| zC=GkQPpyDY#Am4l49chDv*gojhRj_?63&&8#doW`INATAo(qY#{q}%nf@eTIXmtU< zdB<7YWfyCmBs|c)cK>1)v&M#!yNj#4d$~pVfDWQc_ke1?fw{T1Nce_b`v|Vp5ig(H zJvRD^+ps46^hLX;=e2!2e;w9y1D@!D$c@Jc&%%%IL=+xzw55&2?darw=9g~>P z9>?Kdc$r?6c$m%x2S$sdpPl>GQZ{rC9mPS63*qjCVa?OIBj!fW zm|g?>CVfGXNjOfcyqImXR_(tXS(F{FcoNzKvG5R$IgGaxC@)i(e+$ME}vPVIhd|mx2IIE+f zM?9opQHIVgBWu)^A|RzXw!^??S!x)SZOwZaJkGjc<_}2l^eSBm!eAJG9T>EC6I_sy z?bxzDIAn&K5*mX)$RQzDA?s)-no-XF(g*yl4%+GBf`##bDXJ==AQk*xmnatI;SsLp zP9XTHq5mmS=iWu~9ES>b%Q=1aMa|ya^vj$@qz9S!ih{T8_PD%Sf_QrNKwgrXw9ldm zHRVR98*{C?_XNpJn{abA!oix_mowRMu^2lV-LPi;0+?-F(>^5#OHX-fPED zCu^l7u3E%STI}c4{J2!)9SUlGP_@!d?5W^QJXOI-Ea`hFMKjR7TluLvzC-ozCPn1`Tpy z!vlv@_Z58ILX6>nDjTp-1LlFMx~-%GA`aJvG$?8*Ihn;mH37eK**rmOEwqegf-Ccx zrIX4;{c~RK>XuTXxYo5kMiWMy)!IC{*DHG@E$hx?RwP@+wuad(P1{@%tRkyJRqD)3 zMHHHZ4boqDn>-=DgR5VlhQTpfVy182Gk;A_S8A1-;U1RR>+$62>(MUx@Nox$vTjHq z%QR=j!6Gdyb5wu7y(YUktwMuW5<@jl?m4cv4BODiT5o8qVdC0MBqGr@-YBIwnpZAY znX9(_uQjP}JJ=!~Ve9#5I~rUnN|P_3D$LqZcvBnywYhjlMSFHm`;u9GPla{5QD7(7*6Tb3Svr8;(nuAd81q$*uq6HC_&~je*Ca7hP4sJp0av{M8480wF zxASi7Qv+~@2U%Nu1Ud;s-G4CTVWIPyx!sg&8ZG0Wq zG_}i3C(6_1>q3w!EH7$Kwq8uBp2F2N7}l65mk1p*9v0&+;th=_E-W)E;w}P(j⁢ zv5o9#E7!G0XmdzfsS{efPNi`1b44~SZ4Z8fuX!I}#8g+(wxzQwUT#Xb2(tbY1+EUhGKoT@KEU9Ktl>_0 z%bjDJg;#*gtJZv!-Zs`?^}v5eKmnbjqlvnSzE@_SP|LG_PJ6CYU+6zY6>92%E+ z=j@TZf-iW4(%U{lnYxQA;7Q!b;^brF8n0D>)`q5>|WDDXLrqYU_tKN2>=#@~OE7grMnNh?UOz-O~6 z6%rHy{#h9K0AT+lDC7q4{hw^|q6*Ry;;L%Q@)Ga}$60_q%D)rv(CtS$CQbpq9|y1e zRSrN4;$Jyl{m5bZw`$8TGvb}(LpY{-cQ)fcyJv7l3S52TLXVDsphtv&aPuDk1OzCA z4A^QtC(!11`IsNx_HnSy?>EKpHJWT^wmS~hc^p^zIIh@9f6U@I2 zC=Mve{j2^)mS#U$e{@Q?SO6%LDsXz@SY+=cK_QMmXBIU)j!$ajc-zLx3V60EXJ!qC zi<%2x8Q24YN+&8U@CIlN zrZkcT9yh%LrlGS9`G)KdP(@9Eo-AQz@8GEFWcb7U=a0H^ZVbLmz{+&M7W(nXJ4sN8 zJLR7eeK(K8`2-}j(T7JsO`L!+CvbueT%izanm-^A1Dn{`1Nw`9P?cq;7no+XfC`K(GO9?O^5zNIt4M+M8LM0=7Gz8UA@Z0N+lg+cX)NfazRu z5D)~HA^(u%w^cz+@2@_#S|u>GpB+j4KzQ^&Wcl9f z&hG#bCA(Yk0D&t&aJE^xME^&E-&xGHhXn%}psEIj641H+Nl-}boj;)Zt*t(4wZ5DN z@GXF$bL=&pBq-#vkTkh>7hl%K5|3 z{`Vn9b$iR-SoGENp}bn4;fR3>9sA%X2@1L3aE9yTra;Wb#_`xWwLSLdfu+PAu+o3| zGVnpzPr=ch{uuoHjtw7+_!L_2;knQ!DuDl0R`|%jr+}jFzXtrHIKc323?JO{l&;VF z*L1+}JU7%QJOg|5|Tc|D8fN zJORAg=_vsy{ak|o);@)Yh8Lkcg@$FG3k@ep36BRa^>~UmnRPziS>Z=`Jb2x*Q#`%A zU*i3&Vg?TluO@X0O;r2Jl6LKLUOVhSqg1*qOt^|8*c7 zo(298@+r$k_wQNGHv{|$tW(T8L+4_`FQ{kEW5Jgg{yf7ey4ss_(SNKfz(N9lx&a;< je(UuV8hP?p&}TPdm1I$XmG#(RzlD&B2izSj9sl%y5~4qc literal 0 HcmV?d00001 diff --git a/examples/gradle/wrapper/gradle-wrapper.properties b/examples/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3fa8f86 --- /dev/null +++ b/examples/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/gradlew b/examples/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/examples/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/gradlew.bat b/examples/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/examples/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/pom.xml b/examples/pom.xml new file mode 100644 index 0000000..12f4f08 --- /dev/null +++ b/examples/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.1.3 + + + com.github.firetail-io + spring-boot-demo + 0.0.1-SNAPSHOT + spring-boot-demo + Demo project for Spring Boot + + 17 + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.github.firetail-io + firetail-java-lib + 0.0.1.SNAPSHOT + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/examples/settings.gradle.kts b/examples/settings.gradle.kts new file mode 100644 index 0000000..97da9e0 --- /dev/null +++ b/examples/settings.gradle.kts @@ -0,0 +1,10 @@ +pluginManagement { + plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") +} + +rootProject.name = "firetail-spring-demo" diff --git a/examples/src/main/java/com/example/demo/DemoApplication.java b/examples/src/main/java/com/example/demo/DemoApplication.java new file mode 100644 index 0000000..2e3ef5e --- /dev/null +++ b/examples/src/main/java/com/example/demo/DemoApplication.java @@ -0,0 +1,21 @@ +package com.example.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootApplication +@RestController +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } + + @GetMapping("/hello") + public String hello(@RequestParam(value = "name", defaultValue = "World") String name) { + return String.format("Hello %s!", name); + } +} diff --git a/examples/src/main/resources/application.properties b/examples/src/main/resources/application.properties new file mode 100644 index 0000000..2199969 --- /dev/null +++ b/examples/src/main/resources/application.properties @@ -0,0 +1 @@ +logging.firetail.enabled=true diff --git a/examples/src/test/java/com/example/demo/DemoApplicationTests.java b/examples/src/test/java/com/example/demo/DemoApplicationTests.java new file mode 100644 index 0000000..2778a6a --- /dev/null +++ b/examples/src/test/java/com/example/demo/DemoApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.demo; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DemoApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c4cdf41af1ab109bc7f253b2b887023340..7f93135c49b765f8051ef9d0a6055ff8e46073d8 100644 GIT binary patch delta 28216 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 62f495d..3fa8f86 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index fcb6fca..1aa94a4 100755 --- a/gradlew +++ b/gradlew @@ -83,7 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -144,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -201,11 +202,11 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ From 8e1abf86f22f4dbb27b1088435703eeabf5c7bbc Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Tue, 31 Oct 2023 09:30:00 +0800 Subject: [PATCH 07/25] feat: Added Open API documentation --- build.gradle.kts | 2 + examples/README.md | 9 +++- examples/build.gradle.kts | 1 + examples/pom.xml | 47 ------------------- .../src/main/resources/application.properties | 1 + 5 files changed, 12 insertions(+), 48 deletions(-) delete mode 100644 examples/pom.xml diff --git a/build.gradle.kts b/build.gradle.kts index d811491..f740c09 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,12 +27,14 @@ repositories { group = "com.github.firetail-io" version = "0.0.1-SNAPSHOT" description = "firetail-java-lib" + // java.sourceCompatibility = JavaVersion.VERSION_1_8 dependencies { implementation( platform("org.springframework.boot:spring-boot-dependencies:2.7.17"), ) + api("org.yaml:snakeyaml:2.2") // Dependencies are transitively imported from spring-boot-dependencies api("org.jetbrains.kotlin:kotlin-stdlib-jdk8") api("commons-io:commons-io:2.7") diff --git a/examples/README.md b/examples/README.md index 8a6d526..e353560 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,4 +13,11 @@ cd examples ./gradlew bootRun curl http://localhost:8080/hello -``` \ No newline at end of file +``` + +## Open API documentation + +This example uses OpenAPI v3 + + 1. http://localhost:8080/api-docs + 2. http://localhost:8080/swagger-ui.html \ No newline at end of file diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts index b6cb38f..37d08a0 100644 --- a/examples/build.gradle.kts +++ b/examples/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation( platform("org.springframework.boot:spring-boot-dependencies:3.1.3"), ) + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-web") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/examples/pom.xml b/examples/pom.xml deleted file mode 100644 index 12f4f08..0000000 --- a/examples/pom.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 3.1.3 - - - com.github.firetail-io - spring-boot-demo - 0.0.1-SNAPSHOT - spring-boot-demo - Demo project for Spring Boot - - 17 - - - - org.springframework.boot - spring-boot-starter-web - - - - org.springframework.boot - spring-boot-starter-test - test - - - - com.github.firetail-io - firetail-java-lib - 0.0.1.SNAPSHOT - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - - diff --git a/examples/src/main/resources/application.properties b/examples/src/main/resources/application.properties index 2199969..b5d9828 100644 --- a/examples/src/main/resources/application.properties +++ b/examples/src/main/resources/application.properties @@ -1 +1,2 @@ logging.firetail.enabled=true +springdoc.api-docs.path=/api-docs From 56a0a5970879417185ae9f342aea7fd85f9be40b Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Thu, 23 Nov 2023 11:10:02 +0800 Subject: [PATCH 08/25] feat: Firetail Interceptor. Add tests to support correct disabling --- .gitignore | 3 +- .../src/main/resources/application.properties | 2 - examples/src/main/resources/application.yml | 10 +++ .../io/firetail/logging/base/Constants.kt | 2 + .../firetail/logging/base/FiretailConfig.kt | 38 +++++++-- .../firetail/logging/base/FiretailLogData.kt | 32 ++++++++ ...{FiretailLogger.kt => FiretailTemplate.kt} | 45 ++++++++--- .../base/SpringLoggerAutoConfiguration.kt | 81 ------------------- .../io/firetail/logging/base/TcpLogger.kt | 44 ++++++++++ .../logging/servlet/FiretailFilter.kt | 31 +++---- .../{LogContext.kt => FiretailLogContext.kt} | 4 +- .../firetail/logging/FiretailDisabledTest.kt | 56 +++++++++++++ .../firetail/logging/IdKeyGeneratorTests.kt | 8 +- .../logging/RequestInterceptorTests.kt | 30 ++++--- src/test/resources/application-disabled.yml | 4 + src/test/resources/application-test.yml | 7 ++ src/test/schema.json | 27 +++++++ 17 files changed, 294 insertions(+), 130 deletions(-) delete mode 100644 examples/src/main/resources/application.properties create mode 100644 examples/src/main/resources/application.yml create mode 100644 src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt rename src/main/kotlin/io/firetail/logging/base/{FiretailLogger.kt => FiretailTemplate.kt} (70%) delete mode 100644 src/main/kotlin/io/firetail/logging/base/SpringLoggerAutoConfiguration.kt create mode 100644 src/main/kotlin/io/firetail/logging/base/TcpLogger.kt rename src/main/kotlin/io/firetail/logging/util/{LogContext.kt => FiretailLogContext.kt} (81%) create mode 100644 src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt create mode 100644 src/test/resources/application-disabled.yml create mode 100644 src/test/resources/application-test.yml create mode 100644 src/test/schema.json diff --git a/.gitignore b/.gitignore index bb8b2ec..d47f292 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,5 @@ project.xcworkspace version.yml #Andorid -local.properties \ No newline at end of file +local.properties +/examples/src/main/resources/application-local.yml diff --git a/examples/src/main/resources/application.properties b/examples/src/main/resources/application.properties deleted file mode 100644 index b5d9828..0000000 --- a/examples/src/main/resources/application.properties +++ /dev/null @@ -1,2 +0,0 @@ -logging.firetail.enabled=true -springdoc.api-docs.path=/api-docs diff --git a/examples/src/main/resources/application.yml b/examples/src/main/resources/application.yml new file mode 100644 index 0000000..c0b6288 --- /dev/null +++ b/examples/src/main/resources/application.yml @@ -0,0 +1,10 @@ +firetail: + apikey: "set-your-ft-api-key-here" + url: "https://your.sandbox.firetail.app" + +logging: + firetail: + enabled: true +springdoc: + api-docs: + path: /api-docs diff --git a/src/main/kotlin/io/firetail/logging/base/Constants.kt b/src/main/kotlin/io/firetail/logging/base/Constants.kt index 30a3c33..fc41b6a 100644 --- a/src/main/kotlin/io/firetail/logging/base/Constants.kt +++ b/src/main/kotlin/io/firetail/logging/base/Constants.kt @@ -8,6 +8,8 @@ class Constants { const val RESPONSE_TIME = "X-Response-Time" const val RESPONSE_STATUS = "X-Response-Status" const val AUDIT = "audit" + + // const val FIRETAIL_APPENDER_NAME = "FIRETAIL" val empty = ByteArray(0) } } diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt b/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt index 9c3f794..5303871 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt @@ -1,27 +1,53 @@ package io.firetail.logging.base import io.firetail.logging.servlet.FiretailFilter -import io.firetail.logging.util.LogContext +import io.firetail.logging.servlet.FiretailHeaderInterceptor +import io.firetail.logging.util.FiretailLogContext import io.firetail.logging.util.StringUtils import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value -import org.springframework.context.ApplicationContext +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import +import org.springframework.web.client.RestTemplate @Configuration @Import( - FiretailLogger::class, StringUtils::class, - LogContext::class, FiretailFilter::class, ) +@ConditionalOnProperty("logging.firetail.enabled") class FiretailConfig @Autowired constructor( @Value("\${firetail.ignorePatterns:#null}") val ignorePatterns: String?, @Value("\${firetail.logHeaders:false}") val logHeaders: Boolean = false, + @Value("\${firetail.url:http://localhost:8500}") + val url: String, + @Value("\${firetail.apiKey:not-defined}") + val apiKey: String, ) { - @Autowired - lateinit var context: ApplicationContext + + val key = "X-FT-API-KEY" + + @Bean + fun firetailLogContext(): FiretailLogContext = FiretailLogContext() + + @Bean + fun firetailTemplate(): FiretailTemplate { + return FiretailTemplate(this) + } + + @Bean + fun firetailHeaderInterceptor(): FiretailHeaderInterceptor = FiretailHeaderInterceptor() + + @Bean + fun wireInterceptor( + restTemplate: RestTemplate, + firetailHeaderInterceptor: FiretailHeaderInterceptor, + ): RestTemplate { + restTemplate.interceptors.add(firetailHeaderInterceptor) + return restTemplate + } } diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt b/src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt new file mode 100644 index 0000000..0780184 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt @@ -0,0 +1,32 @@ +package io.firetail.logging.base + +import java.time.LocalDateTime +import java.time.ZoneOffset + +data class FireTailLog( + val version: String = "1.0.0-alpha", + val dateCreated: Long = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC), + val executionTime: Int, + val request: Request, + val response: Response, +) + +data class Request( + val headers: Headers, + val httpProtocol: String, + val method: String, + val body: String = "", + val ip: String, + val resource: String, + val uri: String, +) + +data class Headers( + val key: Map, +) + +data class Response( + val statusCode: Int, + val body: String, + val headers: Headers, +) diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailLogger.kt b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt similarity index 70% rename from src/main/kotlin/io/firetail/logging/base/FiretailLogger.kt rename to src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt index 8de9732..529bae4 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailLogger.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt @@ -1,17 +1,46 @@ package io.firetail.logging.base +import com.fasterxml.jackson.databind.ObjectMapper import io.firetail.logging.servlet.SpringRequestWrapper import io.firetail.logging.servlet.SpringResponseWrapper import io.firetail.logging.util.StringUtils import net.logstash.logback.argument.StructuredArguments import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import java.io.DataOutputStream +import java.net.HttpURLConnection +import java.net.URL + +@ConditionalOnProperty("logging.firetail.enabled") +class FiretailTemplate(private val firetailConfig: FiretailConfig) { + + private val uploadUrl = firetailConfig.url + "/logs/bulk" + private val connection = URL(uploadUrl).openConnection() as HttpURLConnection + private val objectMapper = ObjectMapper() + private val stringUtils: StringUtils = StringUtils() + + companion object { + private val LOGGER = LoggerFactory.getLogger(this::class.java) + } + + fun send(fireTailLog: FireTailLog) { + post(objectMapper.writeValueAsString(fireTailLog)) + } + + private fun post(jsonBody: String) { + // Set up the connection for a POST request + connection.requestMethod = "POST" + connection.doOutput = false + connection.setRequestProperty(firetailConfig.key, firetailConfig.apiKey) + connection.setRequestProperty("CONTENT-TYPE", "application/nd-json") + + // Write the JSON body to the request + val outputStream = DataOutputStream(connection.outputStream) + outputStream.writeBytes(jsonBody) + outputStream.flush() + outputStream.close() + } -@Service -class FiretailLogger( - private val stringUtils: StringUtils = StringUtils(), - private val firetailConfig: FiretailConfig, -) { fun logRequest(wrappedRequest: SpringRequestWrapper) = if (firetailConfig.logHeaders) { logWithHeaders(wrappedRequest) @@ -82,8 +111,4 @@ class FiretailLogger( StructuredArguments.value(Constants.AUDIT, true), ) } - - companion object { - private val LOGGER = LoggerFactory.getLogger(this::class.java) - } } diff --git a/src/main/kotlin/io/firetail/logging/base/SpringLoggerAutoConfiguration.kt b/src/main/kotlin/io/firetail/logging/base/SpringLoggerAutoConfiguration.kt deleted file mode 100644 index c119a72..0000000 --- a/src/main/kotlin/io/firetail/logging/base/SpringLoggerAutoConfiguration.kt +++ /dev/null @@ -1,81 +0,0 @@ -package io.firetail.logging.base - -import io.firetail.logging.servlet.FiretailHeaderInterceptor -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Import -import org.springframework.http.client.ClientHttpRequestInterceptor -import org.springframework.web.client.RestTemplate -import java.util.* -import javax.annotation.PostConstruct - -// import net.logstash.logback.appender.LogstashTcpSocketAppender; -// import net.logstash.logback.encoder.LogstashEncoder; -@Configuration -@ConfigurationProperties(prefix = "logging.logstash") -@Import(FiretailConfig::class) -class SpringLoggerAutoConfiguration { - // private static final String FIRETAIL_APPENDER_NAME = "FIRETAIL"; - var url = "localhost:8500" - var ignorePatterns: String? = null - var isLogHeaders = false - var trustStoreLocation: String? = null - var trustStorePassword: String? = null - - @Value("\${spring.application.name:-}") - lateinit var name: String - - @Autowired(required = false) - var template: Optional? = null - - @Bean - @ConditionalOnMissingBean(RestTemplate::class) - fun restTemplate(): RestTemplate { - val restTemplate = RestTemplate() - val interceptorList: MutableList = ArrayList() - interceptorList.add(FiretailHeaderInterceptor()) - restTemplate.interceptors = interceptorList - return restTemplate - } - - /* rewrite this method to send data to firetail backend - @Bean - @ConditionalOnProperty("logging.firetail.enabled") - public FiretailTcpSocketAppender firetailAppender() { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - LogstashTcpSocketAppender logstashTcpSocketAppender = new LogstashTcpSocketAppender(); - logstashTcpSocketAppender.setName(FIRETAIL_APPENDER_NAME); - logstashTcpSocketAppender.setContext(loggerContext); - logstashTcpSocketAppender.addDestination(url); - if (trustStoreLocation != null) { - SSLConfiguration sslConfiguration = new SSLConfiguration(); - KeyStoreFactoryBean factory = new KeyStoreFactoryBean(); - factory.setLocation(trustStoreLocation); - if (trustStorePassword != null) - factory.setPassword(trustStorePassword); - sslConfiguration.setTrustStore(factory); - logstashTcpSocketAppender.setSsl(sslConfiguration); - } - LogstashEncoder encoder = new LogstashEncoder(); - encoder.setContext(loggerContext); - encoder.setIncludeContext(true); - encoder.setCustomFields("{\"appname\":\"" + name + "\"}"); - encoder.start(); - logstashTcpSocketAppender.setEncoder(encoder); - logstashTcpSocketAppender.start(); - loggerContext.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(logstashTcpSocketAppender); - return logstashTcpSocketAppender; - } */ - @PostConstruct - fun init() { - template!!.ifPresent { restTemplate: RestTemplate -> - val interceptorList: MutableList = ArrayList() - interceptorList.add(FiretailHeaderInterceptor()) - restTemplate.interceptors = interceptorList - } - } -} diff --git a/src/main/kotlin/io/firetail/logging/base/TcpLogger.kt b/src/main/kotlin/io/firetail/logging/base/TcpLogger.kt new file mode 100644 index 0000000..1dff100 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/base/TcpLogger.kt @@ -0,0 +1,44 @@ +package io.firetail.logging.base + +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import + +@Configuration +@ConfigurationProperties(prefix = "logging.logstash") +@Import(FiretailConfig::class) +class TcpLogger(val firetailConfig: FiretailConfig) { + var trustStoreLocation: String? = null + var trustStorePassword: String? = null + + @Value("\${spring.application.name:-}") + lateinit var name: String + +// @Bean +// @ConditionalOnProperty("logging.firetail.enabled") +// fun firetailAppender(): LogstashTcpSocketAppender { +// val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext +// val logstashTcpSocketAppender = LogstashTcpSocketAppender() +// logstashTcpSocketAppender.name = FIRETAIL_APPENDER_NAME +// logstashTcpSocketAppender.context = loggerContext +// logstashTcpSocketAppender.addDestination(firetailConfig.url) +// if (trustStoreLocation != null) { +// val sslConfiguration = SSLConfiguration() +// val factory = KeyStoreFactoryBean() +// factory.location = trustStoreLocation +// if (trustStorePassword != null) factory.password = trustStorePassword +// sslConfiguration.trustStore = factory +// logstashTcpSocketAppender.ssl = sslConfiguration +// } +// val encoder = LogstashEncoder() +// encoder.context = loggerContext +// encoder.isIncludeContext = true +// encoder.customFields = "{\"appname\":\"$name\"}" +// encoder.start() +// logstashTcpSocketAppender.encoder = encoder +// logstashTcpSocketAppender.start() +// loggerContext.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(logstashTcpSocketAppender) +// return logstashTcpSocketAppender +// } +} diff --git a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt index c017d5d..0aca7ee 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt @@ -4,35 +4,38 @@ import io.firetail.logging.base.Constants.Companion.CORRELATION_ID import io.firetail.logging.base.Constants.Companion.OP_NAME import io.firetail.logging.base.Constants.Companion.REQUEST_ID import io.firetail.logging.base.FiretailConfig -import io.firetail.logging.base.FiretailLogger -import io.firetail.logging.util.LogContext +import io.firetail.logging.base.FiretailTemplate +import io.firetail.logging.util.FiretailLogContext import org.slf4j.LoggerFactory import org.slf4j.MDC import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean +import org.springframework.stereotype.Service import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping -import java.io.IOException import java.util.* import javax.servlet.FilterChain -import javax.servlet.ServletException import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse +@Service +@ConditionalOnProperty("logging.firetail.enabled") class FiretailFilter( - private val logContext: LogContext, + private val firetailLogContext: FiretailLogContext, + private val firetailConfig: FiretailConfig, ) { @Autowired - lateinit var firetailConfig: FiretailConfig + private lateinit var firetailTemplate: FiretailTemplate @Autowired - private lateinit var firetailLogger: FiretailLogger + lateinit var context: ApplicationContext @Bean fun firetailRequestFilter(): OncePerRequestFilter { return object : OncePerRequestFilter() { - @Throws(ServletException::class, IOException::class) override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, @@ -41,7 +44,7 @@ class FiretailFilter( if (firetailConfig.ignorePatterns != null && request.requestURI.matches(firetailConfig.ignorePatterns!!.toRegex())) { chain.doFilter(request, response) } else { - logContext.generateAndSetMDC(request) + firetailLogContext.generateAndSetMDC(request) try { getHandlerMethod(request) } catch (e: Exception) { @@ -49,7 +52,7 @@ class FiretailFilter( } val startTime = System.currentTimeMillis() val wrappedRequest = SpringRequestWrapper(request) - firetailLogger.logRequest(wrappedRequest) + firetailTemplate.logRequest(wrappedRequest) val wrappedResponse = SpringResponseWrapper(response) try { with(wrappedResponse) { @@ -57,9 +60,9 @@ class FiretailFilter( setHeader(CORRELATION_ID, MDC.get(CORRELATION_ID)) } chain.doFilter(wrappedRequest, wrappedResponse) - firetailLogger.logResponse(startTime, wrappedResponse) + firetailTemplate.logResponse(startTime, wrappedResponse) } catch (e: Exception) { - firetailLogger.logResponse(startTime, wrappedResponse, 500) + firetailTemplate.logResponse(startTime, wrappedResponse, 500) throw e } } @@ -68,7 +71,7 @@ class FiretailFilter( } private fun getHandlerMethod(request: HttpServletRequest) { - val mappings = firetailConfig.context.getBean("requestMappingHandlerMapping") + val mappings = context.getBean("requestMappingHandlerMapping") as RequestMappingHandlerMapping val nullableHandler = mappings.getHandler(request) if (Objects.nonNull(nullableHandler)) { @@ -78,6 +81,6 @@ class FiretailFilter( } companion object { - private val LOGGER = LoggerFactory.getLogger(FiretailLogger::class.java) + private val LOGGER = LoggerFactory.getLogger(FiretailTemplate::class.java) } } diff --git a/src/main/kotlin/io/firetail/logging/util/LogContext.kt b/src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt similarity index 81% rename from src/main/kotlin/io/firetail/logging/util/LogContext.kt rename to src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt index f73456a..dd1350f 100644 --- a/src/main/kotlin/io/firetail/logging/util/LogContext.kt +++ b/src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt @@ -3,11 +3,9 @@ package io.firetail.logging.util import io.firetail.logging.base.Constants.Companion.CORRELATION_ID import io.firetail.logging.base.Constants.Companion.REQUEST_ID import org.slf4j.MDC -import org.springframework.stereotype.Service import javax.servlet.http.HttpServletRequest -@Service -class LogContext(private val keyGenerator: KeyGenerator = KeyGenerator()) { +class FiretailLogContext(private val keyGenerator: KeyGenerator = KeyGenerator()) { fun generateAndSetMDC(request: HttpServletRequest) { MDC.clear() MDC.put(REQUEST_ID, getValue(request, REQUEST_ID)) diff --git a/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt b/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt new file mode 100644 index 0000000..a230751 --- /dev/null +++ b/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt @@ -0,0 +1,56 @@ +package io.firetail.logging + +import io.firetail.logging.base.FiretailConfig +import io.firetail.logging.base.FiretailTemplate +import io.firetail.logging.servlet.FiretailFilter +import io.firetail.logging.util.FiretailLogContext +import io.firetail.logging.util.StringUtils +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.web.client.RestTemplate +import kotlin.test.assertNotNull + +@ContextConfiguration( + classes = [ + RequestInterceptorTests.SimpleController::class, + StringUtils::class, + FiretailConfig::class, + RestTemplate::class, + ], +) +@ExtendWith(SpringExtension::class) +@ActiveProfiles("disabled") +class FiretailDisabledTest { + + @Autowired(required = false) + private val firetailConfig: FiretailConfig? = null + + @Autowired(required = false) + private val firetailTemplate: FiretailTemplate? = null + + @Autowired(required = false) + private val firetailFilter: FiretailFilter? = null + + @Autowired(required = false) + private val firetailLogContext: FiretailLogContext? = null + + @Autowired + private lateinit var restTemplate: RestTemplate + + @Test + fun assertNotWired() { + assertNull(firetailTemplate) + assertNull(firetailConfig) + assertNull(firetailFilter) + assertNull(firetailLogContext) + assertNotNull(restTemplate) + // Clean interceptor as FT interceptor is disabled + assertThat(restTemplate.interceptors).isEmpty() + } +} diff --git a/src/test/kotlin/io/firetail/logging/IdKeyGeneratorTests.kt b/src/test/kotlin/io/firetail/logging/IdKeyGeneratorTests.kt index 32fae56..5ad9a67 100644 --- a/src/test/kotlin/io/firetail/logging/IdKeyGeneratorTests.kt +++ b/src/test/kotlin/io/firetail/logging/IdKeyGeneratorTests.kt @@ -2,8 +2,8 @@ package io.firetail.logging import io.firetail.logging.base.Constants.Companion.CORRELATION_ID import io.firetail.logging.base.Constants.Companion.REQUEST_ID +import io.firetail.logging.util.FiretailLogContext import io.firetail.logging.util.KeyGenerator -import io.firetail.logging.util.LogContext import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -13,13 +13,13 @@ import org.springframework.mock.web.MockHttpServletRequest class IdKeyGeneratorTests { @Test fun mdcIsSetFromHeaderValues() { - val logContext = LogContext() // test with default generator + val firetailLogContext = FiretailLogContext() // test with default generator val httpRequest = MockHttpServletRequest() val requestId = "requestId" val correlationId = "correlationId" httpRequest.addHeader(REQUEST_ID, requestId) httpRequest.addHeader(CORRELATION_ID, correlationId) - logContext.generateAndSetMDC(httpRequest) + firetailLogContext.generateAndSetMDC(httpRequest) assertThat(MDC.get(REQUEST_ID)).isEqualTo(requestId) assertThat(MDC.get(CORRELATION_ID)).isEqualTo(correlationId) @@ -36,7 +36,7 @@ class IdKeyGeneratorTests { val httpRequest = MockHttpServletRequest() val id = "someValue" Mockito.`when`(keyGenerator.generate()).thenReturn(id) - val idGenerator = LogContext(keyGenerator) + val idGenerator = FiretailLogContext(keyGenerator) idGenerator.generateAndSetMDC(httpRequest) assertThat(httpRequest.headerNames.toList()).isEmpty() assertThat(MDC.get(REQUEST_ID)).isEqualTo(id) diff --git a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt index 8dc7463..e48ea50 100644 --- a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt +++ b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt @@ -2,8 +2,9 @@ package io.firetail.logging import io.firetail.logging.base.Constants import io.firetail.logging.base.FiretailConfig -import io.firetail.logging.base.FiretailLogger -import io.firetail.logging.util.LogContext +import io.firetail.logging.base.FiretailTemplate +import io.firetail.logging.servlet.FiretailFilter +import io.firetail.logging.servlet.FiretailHeaderInterceptor import io.firetail.logging.util.StringUtils import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -15,6 +16,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.ApplicationContext +import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc @@ -22,37 +24,47 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders import org.springframework.test.web.servlet.result.MockMvcResultMatchers import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestTemplate @WebMvcTest @ContextConfiguration( classes = [ RequestInterceptorTests.SimpleController::class, FiretailConfig::class, - FiretailLogger::class, StringUtils::class, + RestTemplate::class, ApplicationContext::class, ], ) @ExtendWith(SpringExtension::class) +@ActiveProfiles("test") class RequestInterceptorTests { @Autowired private lateinit var stringUtils: StringUtils @MockBean - private lateinit var firetailLogger: FiretailLogger + private lateinit var firetailTemplate: FiretailTemplate @Autowired - private lateinit var logContext: LogContext + private lateinit var firetailHeaderInterceptor: FiretailHeaderInterceptor @Autowired private lateinit var mockMvc: MockMvc + @Autowired + private lateinit var restTemplate: RestTemplate + + @Autowired + private lateinit var firetailFilter: FiretailFilter + @Test fun testWiring() { assertThat(stringUtils).isNotNull - assertThat(firetailLogger).isNotNull - assertThat(logContext).isNotNull + assertThat(firetailTemplate).isNotNull + assertThat(firetailFilter).isNotNull + assertThat(restTemplate).isNotNull + assertThat(restTemplate.interceptors).isNotEmpty.contains(firetailHeaderInterceptor) } @Test @@ -61,9 +73,9 @@ class RequestInterceptorTests { val result = mockMvc.perform(MockMvcRequestBuilders.get("/hello")) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() - verify(firetailLogger) + verify(firetailTemplate) .logRequest(any()) // Called once - verify(firetailLogger) + verify(firetailTemplate) .logResponse(any(), any(), any()) // Called once // Headers are set diff --git a/src/test/resources/application-disabled.yml b/src/test/resources/application-disabled.yml new file mode 100644 index 0000000..ae68068 --- /dev/null +++ b/src/test/resources/application-disabled.yml @@ -0,0 +1,4 @@ + +logging: + firetail: + enabled: false diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..09aef3e --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,7 @@ +firetail: + apikey: "test-key" + url: "https://sandbox.firetail.app" + +logging: + firetail: + enabled: true diff --git a/src/test/schema.json b/src/test/schema.json new file mode 100644 index 0000000..0b8a6c3 --- /dev/null +++ b/src/test/schema.json @@ -0,0 +1,27 @@ +{ + "version": "1.0.0-alpha", + "dateCreated": 1700616797, + "executionTime": 10, + "request": { + "headers": { + "key": [ + "value" + ] + }, + "httpProtocol": "2.0", + "method": "GET", + "body": "", + "ip": "127.0.0.1", + "resource": "/hello", + "uri": "http://hello" + }, + "response": { + "statusCode": 200, + "body": "body", + "headers": { + "key": [ + "value" + ] + } + } +} \ No newline at end of file From efabcac97b2ea2faa591feb4fe9f5ad33bcffef9 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Fri, 24 Nov 2023 09:29:07 +0800 Subject: [PATCH 09/25] feat: Serialization --- build.gradle.kts | 1 + .../firetail/logging/base/FiretailLogData.kt | 2 +- .../logging/FiretailDataSerialization.kt | 21 +++++++++++++++++++ ...GeneratorTests.kt => MDCGeneratorTests.kt} | 2 +- .../schemaV1Alpha.json} | 0 5 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt rename src/test/kotlin/io/firetail/logging/{IdKeyGeneratorTests.kt => MDCGeneratorTests.kt} (98%) rename src/test/{schema.json => resources/schemaV1Alpha.json} (100%) diff --git a/build.gradle.kts b/build.gradle.kts index f740c09..d257478 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { api("javax.annotation:javax.annotation-api:1.3.2") api("org.slf4j:slf4j-api") api("ch.qos.logback:logback-classic") + api("com.fasterxml.jackson.module:jackson-module-kotlin") compileOnly("javax.servlet:javax.servlet-api") compileOnly("org.springframework.boot:spring-boot-autoconfigure") compileOnly("org.springframework:spring-context") diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt b/src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt index 0780184..055acf0 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt @@ -22,7 +22,7 @@ data class Request( ) data class Headers( - val key: Map, + val key: List, ) data class Response( diff --git a/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt b/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt new file mode 100644 index 0000000..23c7008 --- /dev/null +++ b/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt @@ -0,0 +1,21 @@ +package io.firetail.logging + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.firetail.logging.base.FireTailLog +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.core.io.ClassPathResource + +class FiretailDataSerialization { + @Test + fun validateV1Alpha() { + val objectMapper = jacksonObjectMapper() + val jsonFile = ClassPathResource("/schemaV1Alpha.json").file + val firetailLog = objectMapper.readValue(jsonFile, FireTailLog::class.java) + assertThat(firetailLog) + .isNotNull + .hasFieldOrPropertyWithValue("version", "1.0.0-alpha") + .hasFieldOrProperty("request") + .hasFieldOrProperty("response") + } +} diff --git a/src/test/kotlin/io/firetail/logging/IdKeyGeneratorTests.kt b/src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt similarity index 98% rename from src/test/kotlin/io/firetail/logging/IdKeyGeneratorTests.kt rename to src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt index 5ad9a67..a09d06d 100644 --- a/src/test/kotlin/io/firetail/logging/IdKeyGeneratorTests.kt +++ b/src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt @@ -10,7 +10,7 @@ import org.mockito.Mockito import org.slf4j.MDC import org.springframework.mock.web.MockHttpServletRequest -class IdKeyGeneratorTests { +class MDCGeneratorTests { @Test fun mdcIsSetFromHeaderValues() { val firetailLogContext = FiretailLogContext() // test with default generator diff --git a/src/test/schema.json b/src/test/resources/schemaV1Alpha.json similarity index 100% rename from src/test/schema.json rename to src/test/resources/schemaV1Alpha.json From b35a62fe528bb5a59e525844e40954a23bc413e0 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Mon, 27 Nov 2023 12:40:11 +0800 Subject: [PATCH 10/25] feat: Map payload chore: wiring issues --- build.gradle.kts | 5 +- .../logging/base/FiretailBeanFactory.kt | 27 ++++++++++ .../firetail/logging/base/FiretailConfig.kt | 30 ++--------- .../{FiretailLogData.kt => FiretailLog.kt} | 18 +++---- .../firetail/logging/base/FiretailMapper.kt | 37 ++++++++++++++ .../firetail/logging/base/FiretailTemplate.kt | 7 +-- .../logging/servlet/FiretailFilter.kt | 3 ++ .../servlet/FiretailHeaderInterceptor.kt | 2 + .../io/firetail/logging/util/KeyGenerator.kt | 2 +- .../logging/FiretailDataSerialization.kt | 15 ++++-- .../io/firetail/logging/FiretailMapperTest.kt | 50 +++++++++++++++++++ .../kotlin/io/firetail/logging/MockServer.kt | 48 ++++++++++++++++++ .../logging/RequestInterceptorTests.kt | 28 +++++++---- 13 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt rename src/main/kotlin/io/firetail/logging/base/{FiretailLogData.kt => FiretailLog.kt} (66%) create mode 100644 src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt create mode 100644 src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt create mode 100644 src/test/kotlin/io/firetail/logging/MockServer.kt diff --git a/build.gradle.kts b/build.gradle.kts index d257478..7c7b330 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,12 +37,12 @@ dependencies { api("org.yaml:snakeyaml:2.2") // Dependencies are transitively imported from spring-boot-dependencies api("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + api("com.fasterxml.jackson.module:jackson-module-kotlin") api("commons-io:commons-io:2.7") api("net.logstash.logback:logstash-logback-encoder:7.4") api("javax.annotation:javax.annotation-api:1.3.2") api("org.slf4j:slf4j-api") api("ch.qos.logback:logback-classic") - api("com.fasterxml.jackson.module:jackson-module-kotlin") compileOnly("javax.servlet:javax.servlet-api") compileOnly("org.springframework.boot:spring-boot-autoconfigure") compileOnly("org.springframework:spring-context") @@ -50,8 +50,9 @@ dependencies { compileOnly("org.springframework:spring-web") compileOnly("org.springframework:spring-webmvc") testImplementation(kotlin("test")) - testImplementation("javax.servlet:javax.servlet-api") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.cloud:spring-cloud-contract-wiremock:3.1.8") + testImplementation("javax.servlet:javax.servlet-api") testImplementation("org.springframework:spring-webmvc") testImplementation("org.assertj:assertj-core") testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0") diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt b/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt new file mode 100644 index 0000000..5d237e7 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt @@ -0,0 +1,27 @@ +package io.firetail.logging.base + +import io.firetail.logging.servlet.FiretailHeaderInterceptor +import io.firetail.logging.util.FiretailLogContext +import org.springframework.context.annotation.Bean +import org.springframework.web.client.RestTemplate + +class FiretailBeanFactory { + + @Bean + fun firetailLogContext(): FiretailLogContext = FiretailLogContext() + + @Bean + fun firetailTemplate(firetailConfig: FiretailConfig): FiretailTemplate { + return FiretailTemplate(firetailConfig) + } + + @Bean + fun firetailHeaderInterceptor(restTemplate: RestTemplate): FiretailHeaderInterceptor { + val ftHeader = FiretailHeaderInterceptor() + restTemplate.interceptors.add(ftHeader) + return ftHeader + } + + @Bean + fun firetailMapper(): FiretailMapper = FiretailMapper() +} diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt b/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt index 5303871..943cc9b 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt @@ -1,13 +1,10 @@ package io.firetail.logging.base import io.firetail.logging.servlet.FiretailFilter -import io.firetail.logging.servlet.FiretailHeaderInterceptor -import io.firetail.logging.util.FiretailLogContext import io.firetail.logging.util.StringUtils import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import import org.springframework.web.client.RestTemplate @@ -16,38 +13,21 @@ import org.springframework.web.client.RestTemplate @Import( StringUtils::class, FiretailFilter::class, + FiretailBeanFactory::class, + RestTemplate::class, ) @ConditionalOnProperty("logging.firetail.enabled") class FiretailConfig @Autowired constructor( @Value("\${firetail.ignorePatterns:#null}") - val ignorePatterns: String?, + val ignorePatterns: String? = null, @Value("\${firetail.logHeaders:false}") val logHeaders: Boolean = false, @Value("\${firetail.url:http://localhost:8500}") val url: String, @Value("\${firetail.apiKey:not-defined}") - val apiKey: String, + val apiKey: String = "not-defined", ) { val key = "X-FT-API-KEY" - - @Bean - fun firetailLogContext(): FiretailLogContext = FiretailLogContext() - - @Bean - fun firetailTemplate(): FiretailTemplate { - return FiretailTemplate(this) - } - - @Bean - fun firetailHeaderInterceptor(): FiretailHeaderInterceptor = FiretailHeaderInterceptor() - - @Bean - fun wireInterceptor( - restTemplate: RestTemplate, - firetailHeaderInterceptor: FiretailHeaderInterceptor, - ): RestTemplate { - restTemplate.interceptors.add(firetailHeaderInterceptor) - return restTemplate - } + val logsBulk = "/logs/bulk" } diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt b/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt similarity index 66% rename from src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt rename to src/main/kotlin/io/firetail/logging/base/FiretailLog.kt index 055acf0..aca4310 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailLogData.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt @@ -3,16 +3,16 @@ package io.firetail.logging.base import java.time.LocalDateTime import java.time.ZoneOffset -data class FireTailLog( +data class FiretailLog( val version: String = "1.0.0-alpha", val dateCreated: Long = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC), val executionTime: Int, - val request: Request, - val response: Response, + val request: FtRequest, + val response: FtResponse, ) -data class Request( - val headers: Headers, +data class FtRequest( + val headers: Map>, val httpProtocol: String, val method: String, val body: String = "", @@ -21,12 +21,8 @@ data class Request( val uri: String, ) -data class Headers( - val key: List, -) - -data class Response( +data class FtResponse( val statusCode: Int, val body: String, - val headers: Headers, + val headers: Map>, ) diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt b/src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt new file mode 100644 index 0000000..fa9fbac --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt @@ -0,0 +1,37 @@ +package io.firetail.logging.base + +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class FiretailMapper { + fun from(request: HttpServletRequest, response: HttpServletResponse, startTime: Long): FiretailLog { + return FiretailLog(request = from(request), response = from(response), executionTime = startTime.toInt()) + } + + fun from(request: HttpServletRequest): FtRequest { + val headers = request.headerNames + .toList() + .mapIndexed { _, value -> value to listOf(request.getHeader(value)) } + .toMap() + + return FtRequest( + httpProtocol = request.protocol, + method = request.method, + headers = headers, + uri = request.requestURI, + ip = request.remoteAddr, + resource = request.queryString, + ) + } + + fun from(response: HttpServletResponse): FtResponse { + val headers = response.headerNames + .mapIndexed { _, value -> value to listOf(response.getHeader(value)) } + .toMap() + return FtResponse( + statusCode = response.status, + body = "", + headers = headers, + ) + } +} diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt index 529bae4..7386adc 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt @@ -14,8 +14,6 @@ import java.net.URL @ConditionalOnProperty("logging.firetail.enabled") class FiretailTemplate(private val firetailConfig: FiretailConfig) { - private val uploadUrl = firetailConfig.url + "/logs/bulk" - private val connection = URL(uploadUrl).openConnection() as HttpURLConnection private val objectMapper = ObjectMapper() private val stringUtils: StringUtils = StringUtils() @@ -23,12 +21,15 @@ class FiretailTemplate(private val firetailConfig: FiretailConfig) { private val LOGGER = LoggerFactory.getLogger(this::class.java) } - fun send(fireTailLog: FireTailLog) { + fun send(fireTailLog: FiretailLog) { post(objectMapper.writeValueAsString(fireTailLog)) } private fun post(jsonBody: String) { // Set up the connection for a POST request + val uploadUrl = firetailConfig.url + firetailConfig.logsBulk + val connection = URL(uploadUrl).openConnection() as HttpURLConnection + connection.requestMethod = "POST" connection.doOutput = false connection.setRequestProperty(firetailConfig.key, firetailConfig.apiKey) diff --git a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt index 0aca7ee..d1a5aac 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt @@ -4,6 +4,7 @@ import io.firetail.logging.base.Constants.Companion.CORRELATION_ID import io.firetail.logging.base.Constants.Companion.OP_NAME import io.firetail.logging.base.Constants.Companion.REQUEST_ID import io.firetail.logging.base.FiretailConfig +import io.firetail.logging.base.FiretailMapper import io.firetail.logging.base.FiretailTemplate import io.firetail.logging.util.FiretailLogContext import org.slf4j.LoggerFactory @@ -26,6 +27,7 @@ import javax.servlet.http.HttpServletResponse class FiretailFilter( private val firetailLogContext: FiretailLogContext, private val firetailConfig: FiretailConfig, + private val firetailMapper: FiretailMapper, ) { @Autowired private lateinit var firetailTemplate: FiretailTemplate @@ -61,6 +63,7 @@ class FiretailFilter( } chain.doFilter(wrappedRequest, wrappedResponse) firetailTemplate.logResponse(startTime, wrappedResponse) + val firetailLog = firetailMapper.from(request, response, startTime) } catch (e: Exception) { firetailTemplate.logResponse(startTime, wrappedResponse, 500) throw e diff --git a/src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt index dac6d02..a349cfc 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt @@ -7,7 +7,9 @@ import org.springframework.http.HttpRequest import org.springframework.http.client.ClientHttpRequestExecution import org.springframework.http.client.ClientHttpRequestInterceptor import org.springframework.http.client.ClientHttpResponse +import org.springframework.stereotype.Service +@Service class FiretailHeaderInterceptor : ClientHttpRequestInterceptor { override fun intercept( request: HttpRequest, diff --git a/src/main/kotlin/io/firetail/logging/util/KeyGenerator.kt b/src/main/kotlin/io/firetail/logging/util/KeyGenerator.kt index 6476bb5..9e94355 100644 --- a/src/main/kotlin/io/firetail/logging/util/KeyGenerator.kt +++ b/src/main/kotlin/io/firetail/logging/util/KeyGenerator.kt @@ -1,7 +1,7 @@ package io.firetail.logging.util import org.springframework.stereotype.Service -import java.util.UUID +import java.util.* @Service class KeyGenerator { diff --git a/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt b/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt index 23c7008..e4ff7ba 100644 --- a/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt +++ b/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt @@ -1,7 +1,7 @@ package io.firetail.logging import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.firetail.logging.base.FireTailLog +import io.firetail.logging.base.FiretailLog import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.springframework.core.io.ClassPathResource @@ -9,13 +9,20 @@ import org.springframework.core.io.ClassPathResource class FiretailDataSerialization { @Test fun validateV1Alpha() { - val objectMapper = jacksonObjectMapper() - val jsonFile = ClassPathResource("/schemaV1Alpha.json").file - val firetailLog = objectMapper.readValue(jsonFile, FireTailLog::class.java) + val firetailLog = firetailLog() assertThat(firetailLog) .isNotNull .hasFieldOrPropertyWithValue("version", "1.0.0-alpha") .hasFieldOrProperty("request") .hasFieldOrProperty("response") } + + companion object { + @JvmStatic + fun firetailLog(): FiretailLog? { + val objectMapper = jacksonObjectMapper() + val jsonFile = ClassPathResource("/schemaV1Alpha.json").file + return objectMapper.readValue(jsonFile, FiretailLog::class.java) + } + } } diff --git a/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt b/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt new file mode 100644 index 0000000..516f9ca --- /dev/null +++ b/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt @@ -0,0 +1,50 @@ +package io.firetail.logging + +import io.firetail.logging.base.FiretailMapper +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.util.* +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class FiretailMapperTest { + + private val firetailMapper = FiretailMapper() + + @Test + fun fromResponse() { + val mockResponse: HttpServletResponse = Mockito.mock(HttpServletResponse::class.java) + Mockito.`when`(mockResponse.headerNames).thenReturn(listOf(TEST)) + Mockito.`when`(mockResponse.getHeader(TEST)).thenReturn(TEST_RESULTS) + val result = firetailMapper.from(mockResponse) + Assertions.assertThat(result.headers) + .isNotNull + .hasFieldOrPropertyWithValue(TEST, listOf(TEST_RESULTS)) + } + + @Test + fun fromRequest() { + val mockRequest: HttpServletRequest = Mockito.mock(HttpServletRequest::class.java) + + Mockito.`when`(mockRequest.protocol).thenReturn("HTTP") + Mockito.`when`(mockRequest.method).thenReturn("GET") + Mockito.`when`(mockRequest.requestURI).thenReturn("/") + Mockito.`when`(mockRequest.remoteAddr).thenReturn("127.0.0.1") + Mockito.`when`(mockRequest.queryString).thenReturn("123") + Mockito.`when`(mockRequest.getHeader(TEST)).thenReturn(TEST_RESULTS) + Mockito.`when`(mockRequest.headerNames) + .thenReturn(Collections.enumeration(Collections.singletonList(TEST))) + + val result = firetailMapper.from(mockRequest) + + Assertions.assertThat(result.headers) + .isNotNull + .hasFieldOrPropertyWithValue(TEST, listOf(TEST_RESULTS)) + } + + companion object { + private const val TEST = "X-TEST" + private const val TEST_RESULTS = "TEST-RESULTS" + } +} diff --git a/src/test/kotlin/io/firetail/logging/MockServer.kt b/src/test/kotlin/io/firetail/logging/MockServer.kt new file mode 100644 index 0000000..593ae49 --- /dev/null +++ b/src/test/kotlin/io/firetail/logging/MockServer.kt @@ -0,0 +1,48 @@ +package io.firetail.logging + +import com.github.tomakehurst.wiremock.client.WireMock.ok +import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.stubFor +import io.firetail.logging.base.FiretailConfig +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock +import org.springframework.test.context.TestPropertySource +import org.springframework.web.client.RestTemplate +import org.springframework.web.client.postForObject + +@SpringBootTest(classes = [FiretailConfig::class]) +@AutoConfigureMockMvc +@AutoConfigureWireMock(port = 0) +@TestPropertySource( + properties = [ + "logging.firetail.enabled=true", + "firetail.url=http://localhost:\${wiremock.server.port}", + ], +) +class MockServer { + @Value("\${firetail.url}") + private lateinit var firetailUrl: String + + @Autowired + lateinit var firetailConfig: FiretailConfig + + @Test + fun testExternalServiceIntegration() { + stubFor( + post(firetailConfig.logsBulk).willReturn( + ok().withBody("Mocked Response"), + ), + ) + val url = "${firetailUrl}${firetailConfig.logsBulk}" + val firetailLog = FiretailDataSerialization.firetailLog() + + val response = RestTemplate().postForObject(url, firetailLog) + + assertEquals("Mocked Response", response) + } +} diff --git a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt index e48ea50..4479e12 100644 --- a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt +++ b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt @@ -2,23 +2,23 @@ package io.firetail.logging import io.firetail.logging.base.Constants import io.firetail.logging.base.FiretailConfig +import io.firetail.logging.base.FiretailMapper import io.firetail.logging.base.FiretailTemplate import io.firetail.logging.servlet.FiretailFilter import io.firetail.logging.servlet.FiretailHeaderInterceptor import io.firetail.logging.util.StringUtils import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith import org.mockito.kotlin.any import org.mockito.kotlin.verify import org.slf4j.MDC import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock import org.springframework.context.ApplicationContext -import org.springframework.test.context.ActiveProfiles -import org.springframework.test.context.ContextConfiguration -import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.context.TestPropertySource import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders import org.springframework.test.web.servlet.result.MockMvcResultMatchers @@ -26,8 +26,8 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController import org.springframework.web.client.RestTemplate -@WebMvcTest -@ContextConfiguration( +// @ExtendWith(SpringExtension::class) +@SpringBootTest( classes = [ RequestInterceptorTests.SimpleController::class, FiretailConfig::class, @@ -36,8 +36,14 @@ import org.springframework.web.client.RestTemplate ApplicationContext::class, ], ) -@ExtendWith(SpringExtension::class) -@ActiveProfiles("test") +@AutoConfigureMockMvc +@AutoConfigureWireMock(port = 0) +@TestPropertySource( + properties = [ + "logging.firetail.enabled=true", + "firetail.url=http://localhost:\${wiremock.server.port}", + ], +) class RequestInterceptorTests { @Autowired @@ -46,6 +52,9 @@ class RequestInterceptorTests { @MockBean private lateinit var firetailTemplate: FiretailTemplate + @MockBean + private lateinit var firetailMapper: FiretailMapper + @Autowired private lateinit var firetailHeaderInterceptor: FiretailHeaderInterceptor @@ -63,6 +72,7 @@ class RequestInterceptorTests { assertThat(stringUtils).isNotNull assertThat(firetailTemplate).isNotNull assertThat(firetailFilter).isNotNull + assertThat(firetailMapper).isNotNull assertThat(restTemplate).isNotNull assertThat(restTemplate.interceptors).isNotEmpty.contains(firetailHeaderInterceptor) } From ab40eaac8f4dfbb119036706af03f3ae1bb842a5 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Tue, 28 Nov 2023 16:51:02 +0800 Subject: [PATCH 11/25] feat: Send logs chore: tidy up dependency issues --- build.gradle.kts | 22 +++++----- examples/build.gradle.kts | 10 ++++- .../com/example/demo/DemoApplication.java | 16 +++---- examples/src/main/resources/application.yml | 18 ++++++++ .../io/firetail/logging/base/Firetail.kt | 8 ++++ .../logging/base/FiretailBeanFactory.kt | 42 +++++++++++++++++++ .../firetail/logging/base/FiretailConfig.kt | 13 ++++-- .../io/firetail/logging/base/FiretailLog.kt | 17 ++++---- .../firetail/logging/base/FiretailMapper.kt | 12 +++--- .../firetail/logging/base/FiretailTemplate.kt | 29 +++++++------ .../logging/servlet/FiretailFilter.kt | 12 ++++-- .../servlet/ServletOutputStreamWrapper.kt | 4 +- .../logging/servlet/SpringRequestWrapper.kt | 11 +++-- .../logging/servlet/SpringResponseWrapper.kt | 6 +-- .../logging/util/FiretailLogContext.kt | 2 +- .../io/firetail/logging/util/StringUtils.kt | 8 +--- .../io/firetail/logging/FiretailMapperTest.kt | 5 ++- .../logging/RequestInterceptorTests.kt | 15 ++++++- 18 files changed, 176 insertions(+), 74 deletions(-) create mode 100644 src/main/kotlin/io/firetail/logging/base/Firetail.kt diff --git a/build.gradle.kts b/build.gradle.kts index 7c7b330..fa72006 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { `java-library` + // application `maven-publish` signing // Not possible to set the version for a plugin from a variable. @@ -31,28 +32,29 @@ description = "firetail-java-lib" // java.sourceCompatibility = JavaVersion.VERSION_1_8 dependencies { + // Dependencies are transitively imported from spring-boot-dependencies implementation( - platform("org.springframework.boot:spring-boot-dependencies:2.7.17"), + platform("org.springframework.boot:spring-boot-dependencies:3.1.5"), ) - api("org.yaml:snakeyaml:2.2") - // Dependencies are transitively imported from spring-boot-dependencies - api("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + api("org.jetbrains.kotlin:kotlin-stdlib") api("com.fasterxml.jackson.module:jackson-module-kotlin") - api("commons-io:commons-io:2.7") api("net.logstash.logback:logstash-logback-encoder:7.4") - api("javax.annotation:javax.annotation-api:1.3.2") api("org.slf4j:slf4j-api") api("ch.qos.logback:logback-classic") - compileOnly("javax.servlet:javax.servlet-api") + compileOnly("org.yaml:snakeyaml:2.2") compileOnly("org.springframework.boot:spring-boot-autoconfigure") compileOnly("org.springframework:spring-context") compileOnly("org.springframework:spring-web") - compileOnly("org.springframework:spring-web") compileOnly("org.springframework:spring-webmvc") testImplementation(kotlin("test")) testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.cloud:spring-cloud-contract-wiremock:3.1.8") - testImplementation("javax.servlet:javax.servlet-api") + // javax vs. jakarta - Spring boot 3x uses jakarta. Need a version of this for < 3x for + // older SB apps + compileOnly("jakarta.annotation:jakarta.annotation-api") + compileOnly("jakarta.servlet:jakarta.servlet-api:5.0.0") + testImplementation("org.springframework.cloud:spring-cloud-contract-wiremock:4.0.4") + testImplementation("jakarta.servlet:jakarta.servlet-api") + // end javax vs. jakarta testImplementation("org.springframework:spring-webmvc") testImplementation("org.assertj:assertj-core") testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0") diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts index 37d08a0..73d849a 100644 --- a/examples/build.gradle.kts +++ b/examples/build.gradle.kts @@ -17,15 +17,21 @@ repositories { dependencies { implementation( - platform("org.springframework.boot:spring-boot-dependencies:3.1.3"), + platform("org.springframework.boot:spring-boot-dependencies:3.1.5"), ) + api("com.fasterxml.jackson.module:jackson-module-kotlin") + api("net.logstash.logback:logstash-logback-encoder:7.4") + api("org.slf4j:slf4j-api") + api("ch.qos.logback:logback-classic") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-logging") testImplementation("org.springframework.boot:spring-boot-starter-test") implementation("com.github.firetail-io:firetail-java-lib:$version") } tasks.named("bootRun") { - args("--spring.profiles.active=dev") + args("--spring.profiles.active=local") } diff --git a/examples/src/main/java/com/example/demo/DemoApplication.java b/examples/src/main/java/com/example/demo/DemoApplication.java index 2e3ef5e..c0fc6b6 100644 --- a/examples/src/main/java/com/example/demo/DemoApplication.java +++ b/examples/src/main/java/com/example/demo/DemoApplication.java @@ -1,5 +1,6 @@ package com.example.demo; +import io.firetail.logging.base.Firetail; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.web.bind.annotation.GetMapping; @@ -8,14 +9,15 @@ @SpringBootApplication @RestController +@Firetail public class DemoApplication { - public static void main(String[] args) { - SpringApplication.run(DemoApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } - @GetMapping("/hello") - public String hello(@RequestParam(value = "name", defaultValue = "World") String name) { - return String.format("Hello %s!", name); - } + @GetMapping("/hello") + public String hello(@RequestParam(value = "name", defaultValue = "World") String name) { + return String.format("Hello %s!", name); + } } diff --git a/examples/src/main/resources/application.yml b/examples/src/main/resources/application.yml index c0b6288..454d7e0 100644 --- a/examples/src/main/resources/application.yml +++ b/examples/src/main/resources/application.yml @@ -3,8 +3,26 @@ firetail: url: "https://your.sandbox.firetail.app" logging: + level: + root: debug + io: + firetail: debug + com.example.demo: debug + javax: error + java: debug + #java.net: debug + sun: error + netflix: error + jdk: error + org: + springframework: error + apache: error + + firetail: enabled: true + + springdoc: api-docs: path: /api-docs diff --git a/src/main/kotlin/io/firetail/logging/base/Firetail.kt b/src/main/kotlin/io/firetail/logging/base/Firetail.kt new file mode 100644 index 0000000..a3a4b0f --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/base/Firetail.kt @@ -0,0 +1,8 @@ +package io.firetail.logging.base + +import org.springframework.context.annotation.Import + +@Target(AnnotationTarget.TYPE) +@Retention(AnnotationRetention.RUNTIME) +@Import(FiretailConfig::class) +annotation class Firetail(val deploy: Boolean = true) diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt b/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt index 5d237e7..647c79d 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt @@ -2,9 +2,16 @@ package io.firetail.logging.base import io.firetail.logging.servlet.FiretailHeaderInterceptor import io.firetail.logging.util.FiretailLogContext +import org.springframework.beans.factory.config.BeanDefinition +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider +import org.springframework.core.type.filter.AnnotationTypeFilter import org.springframework.web.client.RestTemplate +import java.util.function.Consumer +@ConditionalOnProperty("logging.firetail.enabled") class FiretailBeanFactory { @Bean @@ -24,4 +31,39 @@ class FiretailBeanFactory { @Bean fun firetailMapper(): FiretailMapper = FiretailMapper() + +// @Bean +// fun conditionalDeploymentBeanPostProcessor(beanFactory: ConfigurableListableBeanFactory): ConditionalDeploymentBeanPostProcessor { +// return ConditionalDeploymentBeanPostProcessor(beanFactory) +// } + +// class ConditionalDeploymentBeanPostProcessor(private val beanFactory: ConfigurableListableBeanFactory) { +// init { +// scanAndDeploy() +// } +// +// private fun scanAndDeploy() { +// val scanner = ClassPathScanningCandidateComponentProvider(false) +// scanner.addIncludeFilter(AnnotationTypeFilter(Firetail::class.java)) +// scanner.findCandidateComponents("io.firetail.logging") // Specify the package to scan +// .forEach( +// Consumer { beanDefinition: BeanDefinition -> +// val className = beanDefinition.beanClassName +// try { +// val clazz = Class.forName(className) +// val annotation: Firetail = clazz.getAnnotation(Firetail::class.java) +// if (annotation.deploy) { +// beanFactory.registerSingleton(className!!, clazz.getDeclaredConstructor().newInstance()) +// } +// } catch (e: ClassNotFoundException) { +// e.printStackTrace() +// } catch (e: IllegalAccessException) { +// e.printStackTrace() +// } catch (e: InstantiationException) { +// e.printStackTrace() +// } +// }, +// ) +// } +// } } diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt b/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt index 943cc9b..1d23b4e 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt @@ -2,6 +2,8 @@ package io.firetail.logging.base import io.firetail.logging.servlet.FiretailFilter import io.firetail.logging.util.StringUtils +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -24,10 +26,15 @@ class FiretailConfig @Autowired constructor( val logHeaders: Boolean = false, @Value("\${firetail.url:http://localhost:8500}") val url: String, - @Value("\${firetail.apiKey:not-defined}") - val apiKey: String = "not-defined", + @Value("\${firetail.apikey:not-defined}") + val apikey: String = "not-defined", ) { - val key = "X-FT-API-KEY" + val key = "x-ft-api-key" val logsBulk = "/logs/bulk" + + @PostConstruct + fun logStatus() { + LoggerFactory.getLogger(FiretailConfig::class.java).info("Firetail Initialized. url: $url") + } } diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt b/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt index aca4310..ebd22e3 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt @@ -1,28 +1,29 @@ package io.firetail.logging.base +import org.springframework.http.HttpStatus import java.time.LocalDateTime import java.time.ZoneOffset data class FiretailLog( val version: String = "1.0.0-alpha", val dateCreated: Long = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC), - val executionTime: Int, + val executionTime: Int = 0, val request: FtRequest, val response: FtResponse, ) data class FtRequest( - val headers: Map>, - val httpProtocol: String, - val method: String, + val httpProtocol: String = "HTTP", + val method: String = "GET", val body: String = "", + val headers: Map> = mapOf(), val ip: String, - val resource: String, + val resource: String?, val uri: String, ) data class FtResponse( - val statusCode: Int, - val body: String, - val headers: Map>, + val statusCode: Int = HttpStatus.OK.value(), + val body: String = "", + val headers: Map> = mapOf(), ) diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt b/src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt index fa9fbac..678df74 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt @@ -1,11 +1,11 @@ package io.firetail.logging.base -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse class FiretailMapper { - fun from(request: HttpServletRequest, response: HttpServletResponse, startTime: Long): FiretailLog { - return FiretailLog(request = from(request), response = from(response), executionTime = startTime.toInt()) + fun from(request: HttpServletRequest, response: HttpServletResponse, executionTime: Long): FiretailLog { + return FiretailLog(request = from(request), response = from(response), executionTime = executionTime.toInt()) } fun from(request: HttpServletRequest): FtRequest { @@ -18,9 +18,9 @@ class FiretailMapper { httpProtocol = request.protocol, method = request.method, headers = headers, - uri = request.requestURI, ip = request.remoteAddr, - resource = request.queryString, + resource = request.requestURI, + uri = request.requestURL.toString(), // FT calls the defines the URI as URL. ) } diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt index 7386adc..250502e 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt @@ -7,7 +7,7 @@ import io.firetail.logging.util.StringUtils import net.logstash.logback.argument.StructuredArguments import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import java.io.DataOutputStream +import java.io.OutputStream import java.net.HttpURLConnection import java.net.URL @@ -18,28 +18,31 @@ class FiretailTemplate(private val firetailConfig: FiretailConfig) { private val stringUtils: StringUtils = StringUtils() companion object { - private val LOGGER = LoggerFactory.getLogger(this::class.java) + private val LOGGER = LoggerFactory.getLogger(FiretailTemplate::class.java) } fun send(fireTailLog: FiretailLog) { - post(objectMapper.writeValueAsString(fireTailLog)) - } - - private fun post(jsonBody: String) { + val jsonBody = objectMapper.writeValueAsString(fireTailLog) // Set up the connection for a POST request - val uploadUrl = firetailConfig.url + firetailConfig.logsBulk - val connection = URL(uploadUrl).openConnection() as HttpURLConnection + val connection = URL("${firetailConfig.url}${firetailConfig.logsBulk}") + .openConnection() as HttpURLConnection connection.requestMethod = "POST" - connection.doOutput = false - connection.setRequestProperty(firetailConfig.key, firetailConfig.apiKey) + connection.doOutput = true + connection.setRequestProperty(firetailConfig.key, firetailConfig.apikey) connection.setRequestProperty("CONTENT-TYPE", "application/nd-json") // Write the JSON body to the request - val outputStream = DataOutputStream(connection.outputStream) - outputStream.writeBytes(jsonBody) - outputStream.flush() + val outputStream: OutputStream = connection.outputStream + outputStream.write(jsonBody.toByteArray(Charsets.UTF_8)) outputStream.close() + if (connection.responseCode == HttpURLConnection.HTTP_OK) { + LOGGER.info("Dispatched request ${fireTailLog.request.resource}") + return connection.inputStream.bufferedReader().use { it.readText() } + } else { + LOGGER.info("Failed to dispatch request. Status code ${connection.responseCode}") + throw RuntimeException("HTTP POST request failed with status code: ${connection.responseCode}") + } } fun logRequest(wrappedRequest: SpringRequestWrapper) = diff --git a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt index d1a5aac..3e20288 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt @@ -7,6 +7,9 @@ import io.firetail.logging.base.FiretailConfig import io.firetail.logging.base.FiretailMapper import io.firetail.logging.base.FiretailTemplate import io.firetail.logging.util.FiretailLogContext +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory import org.slf4j.MDC import org.springframework.beans.factory.annotation.Autowired @@ -18,9 +21,7 @@ import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping import java.util.* -import javax.servlet.FilterChain -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse +import java.util.concurrent.CompletableFuture @Service @ConditionalOnProperty("logging.firetail.enabled") @@ -63,7 +64,10 @@ class FiretailFilter( } chain.doFilter(wrappedRequest, wrappedResponse) firetailTemplate.logResponse(startTime, wrappedResponse) - val firetailLog = firetailMapper.from(request, response, startTime) + CompletableFuture.runAsync { + val firetailLog = firetailMapper.from(request, response, startTime) + firetailTemplate.send(firetailLog) + } } catch (e: Exception) { firetailTemplate.logResponse(startTime, wrappedResponse, 500) throw e diff --git a/src/main/kotlin/io/firetail/logging/servlet/ServletOutputStreamWrapper.kt b/src/main/kotlin/io/firetail/logging/servlet/ServletOutputStreamWrapper.kt index 4a8981e..9c9e91f 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/ServletOutputStreamWrapper.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/ServletOutputStreamWrapper.kt @@ -1,9 +1,9 @@ package io.firetail.logging.servlet +import jakarta.servlet.ServletOutputStream +import jakarta.servlet.WriteListener import java.io.ByteArrayOutputStream import java.io.OutputStream -import javax.servlet.ServletOutputStream -import javax.servlet.WriteListener class ServletOutputStreamWrapper(private val outputStream: OutputStream) : ServletOutputStream() { private val copy: ByteArrayOutputStream = ByteArrayOutputStream() diff --git a/src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt b/src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt index 55a9611..a5aa9b7 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt @@ -1,22 +1,21 @@ package io.firetail.logging.servlet import io.firetail.logging.base.Constants.Companion.empty -import org.apache.commons.io.IOUtils +import jakarta.servlet.ReadListener +import jakarta.servlet.ServletInputStream +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletRequestWrapper import java.io.ByteArrayInputStream import java.io.IOException import java.util.* import java.util.function.Consumer -import javax.servlet.ReadListener -import javax.servlet.ServletInputStream -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletRequestWrapper class SpringRequestWrapper(request: HttpServletRequest) : HttpServletRequestWrapper(request) { private var body: ByteArray init { body = try { - IOUtils.toByteArray(request.inputStream) + request.inputStream.readBytes() } catch (ex: IOException) { empty } diff --git a/src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt b/src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt index 8ea6d8b..dfed35a 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt @@ -1,12 +1,12 @@ package io.firetail.logging.servlet import io.firetail.logging.base.Constants.Companion.empty +import jakarta.servlet.ServletOutputStream +import jakarta.servlet.http.HttpServletResponse +import jakarta.servlet.http.HttpServletResponseWrapper import java.io.OutputStreamWriter import java.io.PrintWriter import java.util.function.Consumer -import javax.servlet.ServletOutputStream -import javax.servlet.http.HttpServletResponse -import javax.servlet.http.HttpServletResponseWrapper class SpringResponseWrapper(response: HttpServletResponse?) : HttpServletResponseWrapper(response) { private var outputStream: ServletOutputStream? = null diff --git a/src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt b/src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt index dd1350f..8dca2f9 100644 --- a/src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt +++ b/src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt @@ -2,8 +2,8 @@ package io.firetail.logging.util import io.firetail.logging.base.Constants.Companion.CORRELATION_ID import io.firetail.logging.base.Constants.Companion.REQUEST_ID +import jakarta.servlet.http.HttpServletRequest import org.slf4j.MDC -import javax.servlet.http.HttpServletRequest class FiretailLogContext(private val keyGenerator: KeyGenerator = KeyGenerator()) { fun generateAndSetMDC(request: HttpServletRequest) { diff --git a/src/main/kotlin/io/firetail/logging/util/StringUtils.kt b/src/main/kotlin/io/firetail/logging/util/StringUtils.kt index 89ced9c..fdbc211 100644 --- a/src/main/kotlin/io/firetail/logging/util/StringUtils.kt +++ b/src/main/kotlin/io/firetail/logging/util/StringUtils.kt @@ -1,17 +1,13 @@ package io.firetail.logging.util -import org.apache.commons.io.IOUtils import org.springframework.stereotype.Service import java.nio.charset.Charset import kotlin.text.Charsets.UTF_8 @Service class StringUtils(private val defaultCharset: Charset = UTF_8) { - fun toString(inputStream: ByteArray, characterEncoding: String = charSet()): String { - return IOUtils.toString( - inputStream, - characterEncoding, - ) + fun toString(inputStream: ByteArray, characterEncoding: String = defaultCharset.toString()): String { + return inputStream.toString(Charset.forName(characterEncoding)) } fun charSet() = defaultCharset.toString() diff --git a/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt b/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt index 516f9ca..80aa787 100644 --- a/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt +++ b/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt @@ -1,12 +1,12 @@ package io.firetail.logging import io.firetail.logging.base.FiretailMapper +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test import org.mockito.Mockito import java.util.* -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse class FiretailMapperTest { @@ -30,6 +30,7 @@ class FiretailMapperTest { Mockito.`when`(mockRequest.protocol).thenReturn("HTTP") Mockito.`when`(mockRequest.method).thenReturn("GET") Mockito.`when`(mockRequest.requestURI).thenReturn("/") + Mockito.`when`(mockRequest.requestURL).thenReturn(StringBuffer().append("http://blah.com")) Mockito.`when`(mockRequest.remoteAddr).thenReturn("127.0.0.1") Mockito.`when`(mockRequest.queryString).thenReturn("123") Mockito.`when`(mockRequest.getHeader(TEST)).thenReturn(TEST_RESULTS) diff --git a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt index 4479e12..eb687e2 100644 --- a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt +++ b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt @@ -2,13 +2,17 @@ package io.firetail.logging import io.firetail.logging.base.Constants import io.firetail.logging.base.FiretailConfig +import io.firetail.logging.base.FiretailLog import io.firetail.logging.base.FiretailMapper import io.firetail.logging.base.FiretailTemplate +import io.firetail.logging.base.FtRequest +import io.firetail.logging.base.FtResponse import io.firetail.logging.servlet.FiretailFilter import io.firetail.logging.servlet.FiretailHeaderInterceptor import io.firetail.logging.util.StringUtils import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.verify import org.slf4j.MDC @@ -52,7 +56,7 @@ class RequestInterceptorTests { @MockBean private lateinit var firetailTemplate: FiretailTemplate - @MockBean + @Autowired private lateinit var firetailMapper: FiretailMapper @Autowired @@ -87,6 +91,15 @@ class RequestInterceptorTests { .logRequest(any()) // Called once verify(firetailTemplate) .logResponse(any(), any(), any()) // Called once +// Mockito.`when`(firetailMapper.from(any(), any(), any())) +// .thenReturn( +// FiretailLog( +// request = FtRequest(ip = "test", resource = "/", uri = ""), +// response = FtResponse(), +// ), +// ) + verify(firetailTemplate) + .send(any()) // Headers are set assertThat(result.response.headerNames) From b0e7ad883a8d758ff2a07dab6b6610c33cd6ec16 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Wed, 29 Nov 2023 12:13:41 +0800 Subject: [PATCH 12/25] feat: EnableFiretail annotation. remove config property feat: Eliminate log4j dependencies and implement FiretailMDC feat: Epoc date created --- build.gradle.kts | 3 +- examples/README.md | 13 ++++- examples/build.gradle.kts | 8 +-- .../com/example/demo/DemoApplication.java | 13 +++-- examples/src/main/resources/application.yml | 5 -- .../firetail/logging/base/EnableFiretail.kt | 12 +++++ .../io/firetail/logging/base/Firetail.kt | 8 --- .../logging/base/FiretailBeanFactory.kt | 51 +++---------------- .../firetail/logging/base/FiretailConfig.kt | 4 +- .../io/firetail/logging/base/FiretailLog.kt | 2 +- .../firetail/logging/base/FiretailTemplate.kt | 45 ++++++++-------- .../io/firetail/logging/base/TcpLogger.kt | 44 ---------------- .../logging/servlet/FiretailFilter.kt | 26 ++++++---- .../{base => servlet}/FiretailMapper.kt | 5 +- .../logging/util/FiretailLogContext.kt | 18 ------- .../io/firetail/logging/util/FiretailMDC.kt | 32 ++++++++++++ .../firetail/logging/FiretailDisabledTest.kt | 9 +--- .../io/firetail/logging/FiretailMapperTest.kt | 2 +- .../io/firetail/logging/MDCGeneratorTests.kt | 23 ++++----- .../logging/RequestInterceptorTests.kt | 30 +++++------ src/test/resources/application-disabled.yml | 4 -- src/test/resources/application-test.yml | 7 --- 22 files changed, 142 insertions(+), 222 deletions(-) create mode 100644 src/main/kotlin/io/firetail/logging/base/EnableFiretail.kt delete mode 100644 src/main/kotlin/io/firetail/logging/base/Firetail.kt delete mode 100644 src/main/kotlin/io/firetail/logging/base/TcpLogger.kt rename src/main/kotlin/io/firetail/logging/{base => servlet}/FiretailMapper.kt (88%) delete mode 100644 src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt create mode 100644 src/main/kotlin/io/firetail/logging/util/FiretailMDC.kt delete mode 100644 src/test/resources/application-disabled.yml delete mode 100644 src/test/resources/application-test.yml diff --git a/build.gradle.kts b/build.gradle.kts index fa72006..3ae35ca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,9 +36,8 @@ dependencies { implementation( platform("org.springframework.boot:spring-boot-dependencies:3.1.5"), ) - api("org.jetbrains.kotlin:kotlin-stdlib") + implementation("org.jetbrains.kotlin:kotlin-stdlib") api("com.fasterxml.jackson.module:jackson-module-kotlin") - api("net.logstash.logback:logstash-logback-encoder:7.4") api("org.slf4j:slf4j-api") api("ch.qos.logback:logback-classic") compileOnly("org.yaml:snakeyaml:2.2") diff --git a/examples/README.md b/examples/README.md index e353560..775b754 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,7 +1,15 @@ -Spring Boot Demo +## Spring Boot Demo Requires Java 17 +You will require an `application-local.yaml` file. It will look something like this: + +```yaml +firetail: + apikey: "PS-02....441b09761c3" + url: "https://your-apiapi.logging.eu-north-west-99.sandbox.firetail.app" +``` + Firstly, build the Firetail-Java-Library ```bash @@ -12,9 +20,10 @@ cd .. cd examples ./gradlew bootRun curl http://localhost:8080/hello - ``` +You can then login to the [FireTail app](https://www.sandbox.firetail.app/) and see your logs + ## Open API documentation This example uses OpenAPI v3 diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts index 73d849a..69464db 100644 --- a/examples/build.gradle.kts +++ b/examples/build.gradle.kts @@ -19,17 +19,11 @@ dependencies { implementation( platform("org.springframework.boot:spring-boot-dependencies:3.1.5"), ) - api("com.fasterxml.jackson.module:jackson-module-kotlin") - api("net.logstash.logback:logstash-logback-encoder:7.4") - api("org.slf4j:slf4j-api") - api("ch.qos.logback:logback-classic") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-logging") - testImplementation("org.springframework.boot:spring-boot-starter-test") implementation("com.github.firetail-io:firetail-java-lib:$version") + testImplementation("org.springframework.boot:spring-boot-starter-test") } tasks.named("bootRun") { diff --git a/examples/src/main/java/com/example/demo/DemoApplication.java b/examples/src/main/java/com/example/demo/DemoApplication.java index c0fc6b6..9191fa0 100644 --- a/examples/src/main/java/com/example/demo/DemoApplication.java +++ b/examples/src/main/java/com/example/demo/DemoApplication.java @@ -1,15 +1,18 @@ package com.example.demo; -import io.firetail.logging.base.Firetail; +import io.firetail.logging.base.EnableFiretail; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + @SpringBootApplication @RestController -@Firetail +@EnableFiretail public class DemoApplication { public static void main(String[] args) { @@ -17,7 +20,7 @@ public static void main(String[] args) { } @GetMapping("/hello") - public String hello(@RequestParam(value = "name", defaultValue = "World") String name) { - return String.format("Hello %s!", name); + public String hello() { + return String.format("Hello %s, utc: %s!", LocalDateTime.now(), ZonedDateTime.now(Clock.systemUTC())); } } diff --git a/examples/src/main/resources/application.yml b/examples/src/main/resources/application.yml index 454d7e0..bf8d402 100644 --- a/examples/src/main/resources/application.yml +++ b/examples/src/main/resources/application.yml @@ -18,11 +18,6 @@ logging: springframework: error apache: error - - firetail: - enabled: true - - springdoc: api-docs: path: /api-docs diff --git a/src/main/kotlin/io/firetail/logging/base/EnableFiretail.kt b/src/main/kotlin/io/firetail/logging/base/EnableFiretail.kt new file mode 100644 index 0000000..be39000 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/base/EnableFiretail.kt @@ -0,0 +1,12 @@ +package io.firetail.logging.base + +import org.springframework.context.annotation.Import + +/** + * Include this annotation to enable deployment of Firetail support + * classes + */ +@Target(AnnotationTarget.TYPE, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@Import(FiretailConfig::class) +annotation class EnableFiretail diff --git a/src/main/kotlin/io/firetail/logging/base/Firetail.kt b/src/main/kotlin/io/firetail/logging/base/Firetail.kt deleted file mode 100644 index a3a4b0f..0000000 --- a/src/main/kotlin/io/firetail/logging/base/Firetail.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.firetail.logging.base - -import org.springframework.context.annotation.Import - -@Target(AnnotationTarget.TYPE) -@Retention(AnnotationRetention.RUNTIME) -@Import(FiretailConfig::class) -annotation class Firetail(val deploy: Boolean = true) diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt b/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt index 647c79d..b8aec36 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt @@ -1,21 +1,19 @@ package io.firetail.logging.base import io.firetail.logging.servlet.FiretailHeaderInterceptor -import io.firetail.logging.util.FiretailLogContext -import org.springframework.beans.factory.config.BeanDefinition -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import io.firetail.logging.servlet.FiretailMapper +import io.firetail.logging.util.FiretailMDC +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider -import org.springframework.core.type.filter.AnnotationTypeFilter +import org.springframework.context.annotation.Configuration import org.springframework.web.client.RestTemplate -import java.util.function.Consumer -@ConditionalOnProperty("logging.firetail.enabled") +@Configuration +@ConditionalOnClass(FiretailConfig::class) class FiretailBeanFactory { @Bean - fun firetailLogContext(): FiretailLogContext = FiretailLogContext() + fun firetailMDC(): FiretailMDC = FiretailMDC() @Bean fun firetailTemplate(firetailConfig: FiretailConfig): FiretailTemplate { @@ -31,39 +29,4 @@ class FiretailBeanFactory { @Bean fun firetailMapper(): FiretailMapper = FiretailMapper() - -// @Bean -// fun conditionalDeploymentBeanPostProcessor(beanFactory: ConfigurableListableBeanFactory): ConditionalDeploymentBeanPostProcessor { -// return ConditionalDeploymentBeanPostProcessor(beanFactory) -// } - -// class ConditionalDeploymentBeanPostProcessor(private val beanFactory: ConfigurableListableBeanFactory) { -// init { -// scanAndDeploy() -// } -// -// private fun scanAndDeploy() { -// val scanner = ClassPathScanningCandidateComponentProvider(false) -// scanner.addIncludeFilter(AnnotationTypeFilter(Firetail::class.java)) -// scanner.findCandidateComponents("io.firetail.logging") // Specify the package to scan -// .forEach( -// Consumer { beanDefinition: BeanDefinition -> -// val className = beanDefinition.beanClassName -// try { -// val clazz = Class.forName(className) -// val annotation: Firetail = clazz.getAnnotation(Firetail::class.java) -// if (annotation.deploy) { -// beanFactory.registerSingleton(className!!, clazz.getDeclaredConstructor().newInstance()) -// } -// } catch (e: ClassNotFoundException) { -// e.printStackTrace() -// } catch (e: IllegalAccessException) { -// e.printStackTrace() -// } catch (e: InstantiationException) { -// e.printStackTrace() -// } -// }, -// ) -// } -// } } diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt b/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt index 1d23b4e..b13332d 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt @@ -6,7 +6,7 @@ import jakarta.annotation.PostConstruct import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import import org.springframework.web.client.RestTemplate @@ -18,7 +18,7 @@ import org.springframework.web.client.RestTemplate FiretailBeanFactory::class, RestTemplate::class, ) -@ConditionalOnProperty("logging.firetail.enabled") +@ConditionalOnClass(EnableFiretail::class) class FiretailConfig @Autowired constructor( @Value("\${firetail.ignorePatterns:#null}") val ignorePatterns: String? = null, diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt b/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt index ebd22e3..83a56a8 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt @@ -6,7 +6,7 @@ import java.time.ZoneOffset data class FiretailLog( val version: String = "1.0.0-alpha", - val dateCreated: Long = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC), + val dateCreated: Long = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) * 1000L, val executionTime: Int = 0, val request: FtRequest, val response: FtResponse, diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt index 250502e..7f0de29 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt @@ -4,14 +4,11 @@ import com.fasterxml.jackson.databind.ObjectMapper import io.firetail.logging.servlet.SpringRequestWrapper import io.firetail.logging.servlet.SpringResponseWrapper import io.firetail.logging.util.StringUtils -import net.logstash.logback.argument.StructuredArguments import org.slf4j.LoggerFactory -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import java.io.OutputStream import java.net.HttpURLConnection import java.net.URL -@ConditionalOnProperty("logging.firetail.enabled") class FiretailTemplate(private val firetailConfig: FiretailConfig) { private val objectMapper = ObjectMapper() @@ -26,21 +23,25 @@ class FiretailTemplate(private val firetailConfig: FiretailConfig) { // Set up the connection for a POST request val connection = URL("${firetailConfig.url}${firetailConfig.logsBulk}") .openConnection() as HttpURLConnection - - connection.requestMethod = "POST" - connection.doOutput = true - connection.setRequestProperty(firetailConfig.key, firetailConfig.apikey) - connection.setRequestProperty("CONTENT-TYPE", "application/nd-json") + with(connection) { + requestMethod = "POST" + doOutput = true + setRequestProperty(firetailConfig.key, firetailConfig.apikey) + setRequestProperty("CONTENT-TYPE", "application/nd-json") + } // Write the JSON body to the request val outputStream: OutputStream = connection.outputStream - outputStream.write(jsonBody.toByteArray(Charsets.UTF_8)) - outputStream.close() + with(outputStream) { + write(jsonBody.toByteArray(Charsets.UTF_8)) + flush() + close() + } if (connection.responseCode == HttpURLConnection.HTTP_OK) { - LOGGER.info("Dispatched request ${fireTailLog.request.resource}") + LOGGER.info("Dispatched request ${fireTailLog.request.resource}, correlationId: ${fireTailLog.response.headers[Constants.CORRELATION_ID]}") return connection.inputStream.bufferedReader().use { it.readText() } } else { - LOGGER.info("Failed to dispatch request. Status code ${connection.responseCode}") + LOGGER.info("Failed to dispatch request. Status code ${connection.responseCode}, correlationId: ${fireTailLog.response.headers[Constants.CORRELATION_ID]}") throw RuntimeException("HTTP POST request failed with status code: ${connection.responseCode}") } } @@ -54,22 +55,20 @@ class FiretailTemplate(private val firetailConfig: FiretailConfig) { private fun logNoHeaders(wrappedRequest: SpringRequestWrapper) { LOGGER.info( - "Request: method={}, uri={}, payload={}, audit={}", + "Request: method={}, uri={}, payload={}", wrappedRequest.method, wrappedRequest.requestURI, stringUtils.toString(wrappedRequest.inputStream.readAllBytes(), wrappedRequest.characterEncoding), - StructuredArguments.value(Constants.AUDIT, true), ) } private fun logWithHeaders(wrappedRequest: SpringRequestWrapper) { LOGGER.info( - "Request: method={}, uri={}, payload={}, headers={}, audit={}", + "Request: method={}, uri={}, payload={}, headers={}", wrappedRequest.method, wrappedRequest.requestURI, stringUtils.toString(wrappedRequest.inputStream.readAllBytes(), wrappedRequest.characterEncoding), wrappedRequest.allHeaders, - StructuredArguments.value(Constants.AUDIT, true), ) } @@ -93,11 +92,10 @@ class FiretailTemplate(private val firetailConfig: FiretailConfig) { wrappedResponse: SpringResponseWrapper, ) { LOGGER.info( - "Response({} ms): status={}, payload={}, audit={}", - StructuredArguments.value(Constants.RESPONSE_TIME, duration), - StructuredArguments.value(Constants.RESPONSE_STATUS, status), + "Response({} ms): status={}, payload={}", + duration, + status, stringUtils.toString(wrappedResponse.contentAsByteArray), - StructuredArguments.value(Constants.AUDIT, true), ) } @@ -107,12 +105,11 @@ class FiretailTemplate(private val firetailConfig: FiretailConfig) { wrappedResponse: SpringResponseWrapper, ) { LOGGER.info( - "Response({} ms): status={}, payload={}, headers={}, audit={}", - StructuredArguments.value(Constants.RESPONSE_TIME, duration), - StructuredArguments.value(Constants.RESPONSE_STATUS, status), + "Response({} ms): status={}, payload={}, headers={}", + duration, + status, stringUtils.toString(wrappedResponse.contentAsByteArray), wrappedResponse.allHeaders, - StructuredArguments.value(Constants.AUDIT, true), ) } } diff --git a/src/main/kotlin/io/firetail/logging/base/TcpLogger.kt b/src/main/kotlin/io/firetail/logging/base/TcpLogger.kt deleted file mode 100644 index 1dff100..0000000 --- a/src/main/kotlin/io/firetail/logging/base/TcpLogger.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.firetail.logging.base - -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Import - -@Configuration -@ConfigurationProperties(prefix = "logging.logstash") -@Import(FiretailConfig::class) -class TcpLogger(val firetailConfig: FiretailConfig) { - var trustStoreLocation: String? = null - var trustStorePassword: String? = null - - @Value("\${spring.application.name:-}") - lateinit var name: String - -// @Bean -// @ConditionalOnProperty("logging.firetail.enabled") -// fun firetailAppender(): LogstashTcpSocketAppender { -// val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext -// val logstashTcpSocketAppender = LogstashTcpSocketAppender() -// logstashTcpSocketAppender.name = FIRETAIL_APPENDER_NAME -// logstashTcpSocketAppender.context = loggerContext -// logstashTcpSocketAppender.addDestination(firetailConfig.url) -// if (trustStoreLocation != null) { -// val sslConfiguration = SSLConfiguration() -// val factory = KeyStoreFactoryBean() -// factory.location = trustStoreLocation -// if (trustStorePassword != null) factory.password = trustStorePassword -// sslConfiguration.trustStore = factory -// logstashTcpSocketAppender.ssl = sslConfiguration -// } -// val encoder = LogstashEncoder() -// encoder.context = loggerContext -// encoder.isIncludeContext = true -// encoder.customFields = "{\"appname\":\"$name\"}" -// encoder.start() -// logstashTcpSocketAppender.encoder = encoder -// logstashTcpSocketAppender.start() -// loggerContext.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(logstashTcpSocketAppender) -// return logstashTcpSocketAppender -// } -} diff --git a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt index 3e20288..837a4d0 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt @@ -4,16 +4,14 @@ import io.firetail.logging.base.Constants.Companion.CORRELATION_ID import io.firetail.logging.base.Constants.Companion.OP_NAME import io.firetail.logging.base.Constants.Companion.REQUEST_ID import io.firetail.logging.base.FiretailConfig -import io.firetail.logging.base.FiretailMapper import io.firetail.logging.base.FiretailTemplate -import io.firetail.logging.util.FiretailLogContext +import io.firetail.logging.util.FiretailMDC import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory -import org.slf4j.MDC import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean import org.springframework.stereotype.Service @@ -24,9 +22,9 @@ import java.util.* import java.util.concurrent.CompletableFuture @Service -@ConditionalOnProperty("logging.firetail.enabled") +@ConditionalOnClass(FiretailConfig::class) class FiretailFilter( - private val firetailLogContext: FiretailLogContext, + private val firetailLogContext: FiretailMDC, private val firetailConfig: FiretailConfig, private val firetailMapper: FiretailMapper, ) { @@ -37,6 +35,7 @@ class FiretailFilter( lateinit var context: ApplicationContext @Bean + @ConditionalOnClass(FiretailConfig::class) fun firetailRequestFilter(): OncePerRequestFilter { return object : OncePerRequestFilter() { override fun doFilterInternal( @@ -59,14 +58,19 @@ class FiretailFilter( val wrappedResponse = SpringResponseWrapper(response) try { with(wrappedResponse) { - setHeader(REQUEST_ID, MDC.get(REQUEST_ID)) - setHeader(CORRELATION_ID, MDC.get(CORRELATION_ID)) + setHeader(REQUEST_ID, firetailLogContext.get(REQUEST_ID)) + setHeader(CORRELATION_ID, firetailLogContext.get(CORRELATION_ID)) } chain.doFilter(wrappedRequest, wrappedResponse) firetailTemplate.logResponse(startTime, wrappedResponse) + val firetailLog = firetailMapper.from(wrappedRequest, wrappedResponse, startTime) CompletableFuture.runAsync { - val firetailLog = firetailMapper.from(request, response, startTime) - firetailTemplate.send(firetailLog) + try { + firetailTemplate.send(firetailLog) + } catch (e: Exception) { + LOGGER.error(e.message) + throw e + } } } catch (e: Exception) { firetailTemplate.logResponse(startTime, wrappedResponse, 500) @@ -83,7 +87,7 @@ class FiretailFilter( val nullableHandler = mappings.getHandler(request) if (Objects.nonNull(nullableHandler)) { val handler = nullableHandler?.handler as HandlerMethod - MDC.put(OP_NAME, handler.beanType.simpleName + "." + handler.method.name) + firetailLogContext.put(OP_NAME, handler.beanType.simpleName + "." + handler.method.name) } } diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailMapper.kt similarity index 88% rename from src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt rename to src/main/kotlin/io/firetail/logging/servlet/FiretailMapper.kt index 678df74..aa2a702 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailMapper.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailMapper.kt @@ -1,5 +1,8 @@ -package io.firetail.logging.base +package io.firetail.logging.servlet +import io.firetail.logging.base.FiretailLog +import io.firetail.logging.base.FtRequest +import io.firetail.logging.base.FtResponse import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse diff --git a/src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt b/src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt deleted file mode 100644 index 8dca2f9..0000000 --- a/src/main/kotlin/io/firetail/logging/util/FiretailLogContext.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.firetail.logging.util - -import io.firetail.logging.base.Constants.Companion.CORRELATION_ID -import io.firetail.logging.base.Constants.Companion.REQUEST_ID -import jakarta.servlet.http.HttpServletRequest -import org.slf4j.MDC - -class FiretailLogContext(private val keyGenerator: KeyGenerator = KeyGenerator()) { - fun generateAndSetMDC(request: HttpServletRequest) { - MDC.clear() - MDC.put(REQUEST_ID, getValue(request, REQUEST_ID)) - MDC.put(CORRELATION_ID, getValue(request, CORRELATION_ID)) - } - - private fun getValue(request: HttpServletRequest, key: String): String { - return request.getHeader(key) ?: keyGenerator.generate() - } -} diff --git a/src/main/kotlin/io/firetail/logging/util/FiretailMDC.kt b/src/main/kotlin/io/firetail/logging/util/FiretailMDC.kt new file mode 100644 index 0000000..e6c980f --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/util/FiretailMDC.kt @@ -0,0 +1,32 @@ +package io.firetail.logging.util + +import io.firetail.logging.base.Constants.Companion.CORRELATION_ID +import io.firetail.logging.base.Constants.Companion.REQUEST_ID +import jakarta.servlet.http.HttpServletRequest + +class FiretailMDC(private val keyGenerator: KeyGenerator = KeyGenerator()) { + + private val contextData: ThreadLocal> = ThreadLocal.withInitial { mutableMapOf() } + + fun put(key: String, value: String) { + contextData.get()[key] = value + } + + fun get(key: String): String? { + return contextData.get()[key] + } + + fun clear() { + contextData.remove() + } + + fun generateAndSetMDC(request: HttpServletRequest) { + clear() + put(REQUEST_ID, getValue(request, REQUEST_ID)) + put(CORRELATION_ID, getValue(request, CORRELATION_ID)) + } + + private fun getValue(request: HttpServletRequest, key: String): String { + return request.getHeader(key) ?: keyGenerator.generate() + } +} diff --git a/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt b/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt index a230751..74dde93 100644 --- a/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt +++ b/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt @@ -3,14 +3,12 @@ package io.firetail.logging import io.firetail.logging.base.FiretailConfig import io.firetail.logging.base.FiretailTemplate import io.firetail.logging.servlet.FiretailFilter -import io.firetail.logging.util.FiretailLogContext -import io.firetail.logging.util.StringUtils +import io.firetail.logging.util.FiretailMDC import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired -import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.web.client.RestTemplate @@ -19,13 +17,10 @@ import kotlin.test.assertNotNull @ContextConfiguration( classes = [ RequestInterceptorTests.SimpleController::class, - StringUtils::class, - FiretailConfig::class, RestTemplate::class, ], ) @ExtendWith(SpringExtension::class) -@ActiveProfiles("disabled") class FiretailDisabledTest { @Autowired(required = false) @@ -38,7 +33,7 @@ class FiretailDisabledTest { private val firetailFilter: FiretailFilter? = null @Autowired(required = false) - private val firetailLogContext: FiretailLogContext? = null + private val firetailLogContext: FiretailMDC? = null @Autowired private lateinit var restTemplate: RestTemplate diff --git a/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt b/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt index 80aa787..0697f00 100644 --- a/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt +++ b/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt @@ -1,6 +1,6 @@ package io.firetail.logging -import io.firetail.logging.base.FiretailMapper +import io.firetail.logging.servlet.FiretailMapper import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.assertj.core.api.Assertions diff --git a/src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt b/src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt index a09d06d..997229d 100644 --- a/src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt +++ b/src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt @@ -2,18 +2,17 @@ package io.firetail.logging import io.firetail.logging.base.Constants.Companion.CORRELATION_ID import io.firetail.logging.base.Constants.Companion.REQUEST_ID -import io.firetail.logging.util.FiretailLogContext +import io.firetail.logging.util.FiretailMDC import io.firetail.logging.util.KeyGenerator import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.mockito.Mockito -import org.slf4j.MDC import org.springframework.mock.web.MockHttpServletRequest class MDCGeneratorTests { @Test fun mdcIsSetFromHeaderValues() { - val firetailLogContext = FiretailLogContext() // test with default generator + val firetailLogContext = FiretailMDC() // test with default generator val httpRequest = MockHttpServletRequest() val requestId = "requestId" val correlationId = "correlationId" @@ -21,8 +20,8 @@ class MDCGeneratorTests { httpRequest.addHeader(CORRELATION_ID, correlationId) firetailLogContext.generateAndSetMDC(httpRequest) - assertThat(MDC.get(REQUEST_ID)).isEqualTo(requestId) - assertThat(MDC.get(CORRELATION_ID)).isEqualTo(correlationId) + assertThat(firetailLogContext.get(REQUEST_ID)).isEqualTo(requestId) + assertThat(firetailLogContext.get(CORRELATION_ID)).isEqualTo(correlationId) assertThat(httpRequest.getHeader(REQUEST_ID)).isEqualTo(requestId) assertThat(httpRequest.getHeader(CORRELATION_ID)).isEqualTo(correlationId) } @@ -30,17 +29,17 @@ class MDCGeneratorTests { @Test fun mdcIsSetWhenNoHeaderValues() { val keyGenerator = Mockito.mock(KeyGenerator::class.java) - MDC.clear() - assertThat(MDC.get(CORRELATION_ID)).isNull() - assertThat(MDC.get(REQUEST_ID)).isNull() + val firetailMdc = FiretailMDC(keyGenerator = keyGenerator) // test with default generator + firetailMdc.clear() + assertThat(firetailMdc.get(CORRELATION_ID)).isNull() + assertThat(firetailMdc.get(REQUEST_ID)).isNull() val httpRequest = MockHttpServletRequest() val id = "someValue" Mockito.`when`(keyGenerator.generate()).thenReturn(id) - val idGenerator = FiretailLogContext(keyGenerator) - idGenerator.generateAndSetMDC(httpRequest) + firetailMdc.generateAndSetMDC(httpRequest) assertThat(httpRequest.headerNames.toList()).isEmpty() - assertThat(MDC.get(REQUEST_ID)).isEqualTo(id) - assertThat(MDC.get(CORRELATION_ID)).isEqualTo(id) + assertThat(firetailMdc.get(REQUEST_ID)).isEqualTo(id) + assertThat(firetailMdc.get(CORRELATION_ID)).isEqualTo(id) } @Test diff --git a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt index eb687e2..f1e6aaf 100644 --- a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt +++ b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt @@ -1,18 +1,15 @@ package io.firetail.logging import io.firetail.logging.base.Constants -import io.firetail.logging.base.FiretailConfig -import io.firetail.logging.base.FiretailLog -import io.firetail.logging.base.FiretailMapper +import io.firetail.logging.base.EnableFiretail +import io.firetail.logging.servlet.FiretailMapper import io.firetail.logging.base.FiretailTemplate -import io.firetail.logging.base.FtRequest -import io.firetail.logging.base.FtResponse import io.firetail.logging.servlet.FiretailFilter import io.firetail.logging.servlet.FiretailHeaderInterceptor +import io.firetail.logging.util.FiretailMDC import io.firetail.logging.util.StringUtils import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.verify import org.slf4j.MDC @@ -21,7 +18,6 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock -import org.springframework.context.ApplicationContext import org.springframework.test.context.TestPropertySource import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders @@ -30,14 +26,9 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController import org.springframework.web.client.RestTemplate -// @ExtendWith(SpringExtension::class) @SpringBootTest( classes = [ RequestInterceptorTests.SimpleController::class, - FiretailConfig::class, - StringUtils::class, - RestTemplate::class, - ApplicationContext::class, ], ) @AutoConfigureMockMvc @@ -48,6 +39,7 @@ import org.springframework.web.client.RestTemplate "firetail.url=http://localhost:\${wiremock.server.port}", ], ) +@EnableFiretail class RequestInterceptorTests { @Autowired @@ -71,10 +63,14 @@ class RequestInterceptorTests { @Autowired private lateinit var firetailFilter: FiretailFilter + @Autowired + private lateinit var firetailMDC: FiretailMDC + @Test fun testWiring() { assertThat(stringUtils).isNotNull assertThat(firetailTemplate).isNotNull + assertThat(firetailMDC).isNotNull assertThat(firetailFilter).isNotNull assertThat(firetailMapper).isNotNull assertThat(restTemplate).isNotNull @@ -83,7 +79,7 @@ class RequestInterceptorTests { @Test fun fireTailRequestLoggingAndResponse() { - MDC.clear() + firetailMDC.clear() val result = mockMvc.perform(MockMvcRequestBuilders.get("/hello")) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() @@ -105,11 +101,11 @@ class RequestInterceptorTests { assertThat(result.response.headerNames) .contains(Constants.REQUEST_ID, Constants.CORRELATION_ID) - assertThat(MDC.get(Constants.REQUEST_ID)) - .isNotBlank() + assertThat(firetailMDC.get(Constants.REQUEST_ID)) + .isNotNull() .isEqualTo(result.response.getHeaderValue(Constants.REQUEST_ID)) - assertThat(MDC.get(Constants.CORRELATION_ID)) - .isNotBlank() + assertThat(firetailMDC.get(Constants.CORRELATION_ID)) + .isNotNull() .isEqualTo(result.response.getHeaderValue(Constants.CORRELATION_ID)) } diff --git a/src/test/resources/application-disabled.yml b/src/test/resources/application-disabled.yml deleted file mode 100644 index ae68068..0000000 --- a/src/test/resources/application-disabled.yml +++ /dev/null @@ -1,4 +0,0 @@ - -logging: - firetail: - enabled: false diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml deleted file mode 100644 index 09aef3e..0000000 --- a/src/test/resources/application-test.yml +++ /dev/null @@ -1,7 +0,0 @@ -firetail: - apikey: "test-key" - url: "https://sandbox.firetail.app" - -logging: - firetail: - enabled: true From aa49569e367bfa01154291f8667087d2b891a046 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Fri, 1 Dec 2023 09:36:58 +0800 Subject: [PATCH 13/25] feat: Tidyup logging and duration --- .../firetail/logging/base/FiretailTemplate.kt | 54 ++++--------------- .../logging/servlet/FiretailFilter.kt | 9 ++-- .../logging/RequestInterceptorTests.kt | 1 - 3 files changed, 16 insertions(+), 48 deletions(-) diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt index 7f0de29..ddaa8e4 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt +++ b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt @@ -16,11 +16,12 @@ class FiretailTemplate(private val firetailConfig: FiretailConfig) { companion object { private val LOGGER = LoggerFactory.getLogger(FiretailTemplate::class.java) + const val logRequestPrefix = "Request:" + const val logResponsePrefix = "Response:" } fun send(fireTailLog: FiretailLog) { val jsonBody = objectMapper.writeValueAsString(fireTailLog) - // Set up the connection for a POST request val connection = URL("${firetailConfig.url}${firetailConfig.logsBulk}") .openConnection() as HttpURLConnection with(connection) { @@ -38,10 +39,10 @@ class FiretailTemplate(private val firetailConfig: FiretailConfig) { close() } if (connection.responseCode == HttpURLConnection.HTTP_OK) { - LOGGER.info("Dispatched request ${fireTailLog.request.resource}, correlationId: ${fireTailLog.response.headers[Constants.CORRELATION_ID]}") + LOGGER.info("Dispatched status: ${HttpURLConnection.HTTP_OK}, request: ${fireTailLog.request.resource}, correlationId: ${fireTailLog.response.headers[Constants.CORRELATION_ID]}") return connection.inputStream.bufferedReader().use { it.readText() } } else { - LOGGER.info("Failed to dispatch request. Status code ${connection.responseCode}, correlationId: ${fireTailLog.response.headers[Constants.CORRELATION_ID]}") + LOGGER.info("Failed to dispatch request. Status code: ${connection.responseCode}, correlationId: ${fireTailLog.response.headers[Constants.CORRELATION_ID]}") throw RuntimeException("HTTP POST request failed with status code: ${connection.responseCode}") } } @@ -55,61 +56,26 @@ class FiretailTemplate(private val firetailConfig: FiretailConfig) { private fun logNoHeaders(wrappedRequest: SpringRequestWrapper) { LOGGER.info( - "Request: method={}, uri={}, payload={}", - wrappedRequest.method, - wrappedRequest.requestURI, - stringUtils.toString(wrappedRequest.inputStream.readAllBytes(), wrappedRequest.characterEncoding), + "$logRequestPrefix method: ${wrappedRequest.method}, uri: ${wrappedRequest.requestURI}", ) } private fun logWithHeaders(wrappedRequest: SpringRequestWrapper) { LOGGER.info( - "Request: method={}, uri={}, payload={}, headers={}", - wrappedRequest.method, - wrappedRequest.requestURI, - stringUtils.toString(wrappedRequest.inputStream.readAllBytes(), wrappedRequest.characterEncoding), - wrappedRequest.allHeaders, + "$logRequestPrefix " + + "method: ${wrappedRequest.method}, " + + "uri: ${wrappedRequest.requestURI}, " + + "headers: ${wrappedRequest.allHeaders}", ) } fun logResponse( - startTime: Long, wrappedResponse: SpringResponseWrapper, status: Int = wrappedResponse.status, - ) { - val duration = System.currentTimeMillis() - startTime - wrappedResponse.characterEncoding = stringUtils.charSet() - if (firetailConfig.logHeaders) { - logWithHeaders(duration, status, wrappedResponse) - } else { - logNoHeaders(duration, status, wrappedResponse) - } - } - - private fun logNoHeaders( - duration: Long, - status: Int, - wrappedResponse: SpringResponseWrapper, - ) { - LOGGER.info( - "Response({} ms): status={}, payload={}", - duration, - status, - stringUtils.toString(wrappedResponse.contentAsByteArray), - ) - } - - private fun logWithHeaders( duration: Long, - status: Int, - wrappedResponse: SpringResponseWrapper, ) { LOGGER.info( - "Response({} ms): status={}, payload={}, headers={}", - duration, - status, - stringUtils.toString(wrappedResponse.contentAsByteArray), - wrappedResponse.allHeaders, + "$logResponsePrefix ms: $duration, status: $status, headers: ${wrappedResponse.allHeaders}", ) } } diff --git a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt index 837a4d0..deb6324 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt @@ -62,8 +62,11 @@ class FiretailFilter( setHeader(CORRELATION_ID, firetailLogContext.get(CORRELATION_ID)) } chain.doFilter(wrappedRequest, wrappedResponse) - firetailTemplate.logResponse(startTime, wrappedResponse) - val firetailLog = firetailMapper.from(wrappedRequest, wrappedResponse, startTime) + val duration = System.currentTimeMillis() - startTime + firetailTemplate.logResponse(wrappedResponse, duration = duration) + val firetailLog = + firetailMapper.from(wrappedRequest, wrappedResponse, + duration) CompletableFuture.runAsync { try { firetailTemplate.send(firetailLog) @@ -73,7 +76,7 @@ class FiretailFilter( } } } catch (e: Exception) { - firetailTemplate.logResponse(startTime, wrappedResponse, 500) + firetailTemplate.logResponse(wrappedResponse, 500, startTime) throw e } } diff --git a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt index f1e6aaf..cb84360 100644 --- a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt +++ b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt @@ -12,7 +12,6 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.mockito.kotlin.any import org.mockito.kotlin.verify -import org.slf4j.MDC import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest From cbaf27c654229476b3c9e813d92662a2310158d4 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Mon, 4 Dec 2023 17:44:07 +0800 Subject: [PATCH 14/25] feat: Added a buffer for data logs. Minor package refactor fix: proper wiremock contract validation --- .../com/example/demo/DemoApplication.java | 2 +- examples/src/main/resources/application.yml | 3 + .../firetail/logging/base/FiretailTemplate.kt | 81 ------------------- .../logging/{base => core}/Constants.kt | 2 +- .../firetail/logging/core/FiretailBuffer.kt | 67 +++++++++++++++ .../FiretailLog.kt => core/FiretailData.kt} | 14 ++-- .../firetail/logging/core/FiretailLogger.kt | 43 ++++++++++ .../firetail/logging/core/FiretailTemplate.kt | 50 ++++++++++++ .../logging/servlet/FiretailFilter.kt | 32 +++++--- .../servlet/FiretailHeaderInterceptor.kt | 4 +- .../logging/servlet/FiretailMapper.kt | 22 +++-- .../logging/servlet/SpringRequestWrapper.kt | 2 +- .../logging/servlet/SpringResponseWrapper.kt | 2 +- .../{base => spring}/EnableFiretail.kt | 2 +- .../{base => spring}/FiretailBeanFactory.kt | 20 +++-- .../{base => spring}/FiretailConfig.kt | 16 +++- .../io/firetail/logging/util/FiretailMDC.kt | 4 +- .../io/firetail/logging/FiretailBufferTest.kt | 43 ++++++++++ .../logging/FiretailDataSerialization.kt | 6 +- .../firetail/logging/FiretailDisabledTest.kt | 4 +- .../io/firetail/logging/FiretailMapperTest.kt | 15 ++++ .../io/firetail/logging/MDCGeneratorTests.kt | 4 +- .../kotlin/io/firetail/logging/MockServer.kt | 48 ----------- .../logging/RequestInterceptorTests.kt | 50 ++++++------ src/test/resources/mappings/v1alpha.json | 19 +++++ 25 files changed, 355 insertions(+), 200 deletions(-) delete mode 100644 src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt rename src/main/kotlin/io/firetail/logging/{base => core}/Constants.kt (92%) create mode 100644 src/main/kotlin/io/firetail/logging/core/FiretailBuffer.kt rename src/main/kotlin/io/firetail/logging/{base/FiretailLog.kt => core/FiretailData.kt} (71%) create mode 100644 src/main/kotlin/io/firetail/logging/core/FiretailLogger.kt create mode 100644 src/main/kotlin/io/firetail/logging/core/FiretailTemplate.kt rename src/main/kotlin/io/firetail/logging/{base => spring}/EnableFiretail.kt (89%) rename src/main/kotlin/io/firetail/logging/{base => spring}/FiretailBeanFactory.kt (60%) rename src/main/kotlin/io/firetail/logging/{base => spring}/FiretailConfig.kt (75%) create mode 100644 src/test/kotlin/io/firetail/logging/FiretailBufferTest.kt delete mode 100644 src/test/kotlin/io/firetail/logging/MockServer.kt create mode 100644 src/test/resources/mappings/v1alpha.json diff --git a/examples/src/main/java/com/example/demo/DemoApplication.java b/examples/src/main/java/com/example/demo/DemoApplication.java index 9191fa0..6d28f62 100644 --- a/examples/src/main/java/com/example/demo/DemoApplication.java +++ b/examples/src/main/java/com/example/demo/DemoApplication.java @@ -1,6 +1,6 @@ package com.example.demo; -import io.firetail.logging.base.EnableFiretail; +import io.firetail.logging.spring.EnableFiretail; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.web.bind.annotation.GetMapping; diff --git a/examples/src/main/resources/application.yml b/examples/src/main/resources/application.yml index bf8d402..f366319 100644 --- a/examples/src/main/resources/application.yml +++ b/examples/src/main/resources/application.yml @@ -1,6 +1,9 @@ firetail: apikey: "set-your-ft-api-key-here" url: "https://your.sandbox.firetail.app" + buffer: + interval: 1000 + capacity: 10 logging: level: diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt b/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt deleted file mode 100644 index ddaa8e4..0000000 --- a/src/main/kotlin/io/firetail/logging/base/FiretailTemplate.kt +++ /dev/null @@ -1,81 +0,0 @@ -package io.firetail.logging.base - -import com.fasterxml.jackson.databind.ObjectMapper -import io.firetail.logging.servlet.SpringRequestWrapper -import io.firetail.logging.servlet.SpringResponseWrapper -import io.firetail.logging.util.StringUtils -import org.slf4j.LoggerFactory -import java.io.OutputStream -import java.net.HttpURLConnection -import java.net.URL - -class FiretailTemplate(private val firetailConfig: FiretailConfig) { - - private val objectMapper = ObjectMapper() - private val stringUtils: StringUtils = StringUtils() - - companion object { - private val LOGGER = LoggerFactory.getLogger(FiretailTemplate::class.java) - const val logRequestPrefix = "Request:" - const val logResponsePrefix = "Response:" - } - - fun send(fireTailLog: FiretailLog) { - val jsonBody = objectMapper.writeValueAsString(fireTailLog) - val connection = URL("${firetailConfig.url}${firetailConfig.logsBulk}") - .openConnection() as HttpURLConnection - with(connection) { - requestMethod = "POST" - doOutput = true - setRequestProperty(firetailConfig.key, firetailConfig.apikey) - setRequestProperty("CONTENT-TYPE", "application/nd-json") - } - - // Write the JSON body to the request - val outputStream: OutputStream = connection.outputStream - with(outputStream) { - write(jsonBody.toByteArray(Charsets.UTF_8)) - flush() - close() - } - if (connection.responseCode == HttpURLConnection.HTTP_OK) { - LOGGER.info("Dispatched status: ${HttpURLConnection.HTTP_OK}, request: ${fireTailLog.request.resource}, correlationId: ${fireTailLog.response.headers[Constants.CORRELATION_ID]}") - return connection.inputStream.bufferedReader().use { it.readText() } - } else { - LOGGER.info("Failed to dispatch request. Status code: ${connection.responseCode}, correlationId: ${fireTailLog.response.headers[Constants.CORRELATION_ID]}") - throw RuntimeException("HTTP POST request failed with status code: ${connection.responseCode}") - } - } - - fun logRequest(wrappedRequest: SpringRequestWrapper) = - if (firetailConfig.logHeaders) { - logWithHeaders(wrappedRequest) - } else { - logNoHeaders(wrappedRequest) - } - - private fun logNoHeaders(wrappedRequest: SpringRequestWrapper) { - LOGGER.info( - "$logRequestPrefix method: ${wrappedRequest.method}, uri: ${wrappedRequest.requestURI}", - ) - } - - private fun logWithHeaders(wrappedRequest: SpringRequestWrapper) { - LOGGER.info( - "$logRequestPrefix " + - "method: ${wrappedRequest.method}, " + - "uri: ${wrappedRequest.requestURI}, " + - "headers: ${wrappedRequest.allHeaders}", - ) - } - - fun logResponse( - wrappedResponse: SpringResponseWrapper, - status: Int = wrappedResponse.status, - duration: Long, - ) { - LOGGER.info( - "$logResponsePrefix ms: $duration, status: $status, headers: ${wrappedResponse.allHeaders}", - ) - } -} diff --git a/src/main/kotlin/io/firetail/logging/base/Constants.kt b/src/main/kotlin/io/firetail/logging/core/Constants.kt similarity index 92% rename from src/main/kotlin/io/firetail/logging/base/Constants.kt rename to src/main/kotlin/io/firetail/logging/core/Constants.kt index fc41b6a..80b4e81 100644 --- a/src/main/kotlin/io/firetail/logging/base/Constants.kt +++ b/src/main/kotlin/io/firetail/logging/core/Constants.kt @@ -1,4 +1,4 @@ -package io.firetail.logging.base +package io.firetail.logging.core class Constants { companion object { diff --git a/src/main/kotlin/io/firetail/logging/core/FiretailBuffer.kt b/src/main/kotlin/io/firetail/logging/core/FiretailBuffer.kt new file mode 100644 index 0000000..434d4c4 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/core/FiretailBuffer.kt @@ -0,0 +1,67 @@ +package io.firetail.logging.core + +import io.firetail.logging.servlet.FiretailMapper +import io.firetail.logging.spring.FiretailConfig +import org.slf4j.LoggerFactory +import java.util.* +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +class FiretailBuffer( + private val firetailConfig: FiretailConfig, + private val firetailTemplate: FiretailTemplate, + private val firetailMapper: FiretailMapper = FiretailMapper(), +) { + private val buffer: MutableList = mutableListOf() + private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + private val flushCallback = mutableListOf() + + init { + // Schedule the periodic flush task + scheduler.scheduleAtFixedRate( + object : TimerTask() { + override fun run() { + flush() + } + }, + firetailConfig.flushIntervalMillis, + firetailConfig.flushIntervalMillis, + TimeUnit.MILLISECONDS, + ) + } + + fun add(item: FiretailData) { + buffer.add(item) + if (buffer.size >= firetailConfig.capacity) { + flush() + } + } + + fun flush(): String { + if (buffer.isNotEmpty()) { + LOGGER.debug("Buffer flushing ${buffer.size}") + val result = firetailTemplate.send(buffer.toList()) + buffer.clear() + return firetailMapper.getResult(result) + } + return "" + } + + fun get(): List { + return buffer.toList() + } + + // Cleanup method to stop the scheduler + fun stop() { + scheduler.shutdown() + } + + fun size(): Int { + return buffer.size + } + + companion object { + val LOGGER = LoggerFactory.getLogger(FiretailBuffer::class.java) + } +} diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt b/src/main/kotlin/io/firetail/logging/core/FiretailData.kt similarity index 71% rename from src/main/kotlin/io/firetail/logging/base/FiretailLog.kt rename to src/main/kotlin/io/firetail/logging/core/FiretailData.kt index 83a56a8..ce082b9 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailLog.kt +++ b/src/main/kotlin/io/firetail/logging/core/FiretailData.kt @@ -1,15 +1,15 @@ -package io.firetail.logging.base +package io.firetail.logging.core import org.springframework.http.HttpStatus import java.time.LocalDateTime import java.time.ZoneOffset -data class FiretailLog( +data class FiretailData( val version: String = "1.0.0-alpha", val dateCreated: Long = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) * 1000L, val executionTime: Int = 0, - val request: FtRequest, - val response: FtResponse, + val request: FtRequest = FtRequest(), + val response: FtResponse = FtResponse(), ) data class FtRequest( @@ -17,9 +17,9 @@ data class FtRequest( val method: String = "GET", val body: String = "", val headers: Map> = mapOf(), - val ip: String, - val resource: String?, - val uri: String, + val ip: String = "127.0.0.1", + val resource: String? = "", + val uri: String = "/", ) data class FtResponse( diff --git a/src/main/kotlin/io/firetail/logging/core/FiretailLogger.kt b/src/main/kotlin/io/firetail/logging/core/FiretailLogger.kt new file mode 100644 index 0000000..bbad951 --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/core/FiretailLogger.kt @@ -0,0 +1,43 @@ +package io.firetail.logging.core + +import io.firetail.logging.spring.FiretailConfig +import io.firetail.logging.servlet.SpringRequestWrapper +import io.firetail.logging.servlet.SpringResponseWrapper +import org.slf4j.LoggerFactory + +class FiretailLogger (val firetailConfig: FiretailConfig) { + fun logRequest(wrappedRequest: SpringRequestWrapper) = + if (firetailConfig.logHeaders) { + logWithHeaders(wrappedRequest) + } else { + logNoHeaders(wrappedRequest) + } + + private fun logNoHeaders(wrappedRequest: SpringRequestWrapper) { + LOGGER.info( + "${FiretailTemplate.logRequestPrefix} method: ${wrappedRequest.method}, uri: ${wrappedRequest.requestURI}", + ) + } + + private fun logWithHeaders(wrappedRequest: SpringRequestWrapper) { + LOGGER.info( + "${FiretailTemplate.logRequestPrefix} " + + "method: ${wrappedRequest.method}, " + + "uri: ${wrappedRequest.requestURI}, " + + "headers: ${wrappedRequest.allHeaders}", + ) + } + + fun logResponse( + wrappedResponse: SpringResponseWrapper, + status: Int = wrappedResponse.status, + duration: Long, + ) { + LOGGER.info( + "${FiretailTemplate.logResponsePrefix} ms: $duration, status: $status, headers: ${wrappedResponse.allHeaders}", + ) + } + companion object { + val LOGGER = LoggerFactory.getLogger(FiretailLogger::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/firetail/logging/core/FiretailTemplate.kt b/src/main/kotlin/io/firetail/logging/core/FiretailTemplate.kt new file mode 100644 index 0000000..487278d --- /dev/null +++ b/src/main/kotlin/io/firetail/logging/core/FiretailTemplate.kt @@ -0,0 +1,50 @@ +package io.firetail.logging.core + +import io.firetail.logging.servlet.FiretailMapper +import io.firetail.logging.spring.FiretailConfig +import io.firetail.logging.util.StringUtils +import org.slf4j.LoggerFactory +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.URL + +class FiretailTemplate(private val firetailConfig: FiretailConfig, private val firetailMapper: FiretailMapper) { + + private val stringUtils: StringUtils = StringUtils() + + companion object { + private val LOGGER = LoggerFactory.getLogger(FiretailTemplate::class.java) + const val logRequestPrefix = "Request:" + const val logResponsePrefix = "Response:" + } + + fun send(fireTailData: List): String { + val jsonBody = firetailMapper.from(fireTailData) + val connection = URL("${firetailConfig.url}${firetailConfig.logsBulk}") + .openConnection() as HttpURLConnection + with(connection) { + requestMethod = "POST" + doOutput = true + setRequestProperty(firetailConfig.key, firetailConfig.apikey) + setRequestProperty("CONTENT-TYPE", "application/nd-json") + } + + // Write the JSON body to the request + val outputStream: OutputStream = connection.outputStream + with(outputStream) { + write(jsonBody.toByteArray(Charsets.UTF_8)) + flush() + close() + } + if (connection.responseCode == HttpURLConnection.HTTP_OK) { + LOGGER.info("Wrote ${fireTailData.size} rows to ${firetailConfig.url}") + return connection.inputStream.bufferedReader().readText() + } else { + LOGGER.error("Failed to dispatch request. Status code: ${connection.responseCode}") + throw RuntimeException( + "HTTP POST request failed with status code: ${connection.responseCode}, " + + "message: ${connection.inputStream.bufferedReader().readText()}", + ) + } + } +} diff --git a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt index deb6324..8bdb7c6 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt @@ -1,10 +1,12 @@ package io.firetail.logging.servlet -import io.firetail.logging.base.Constants.Companion.CORRELATION_ID -import io.firetail.logging.base.Constants.Companion.OP_NAME -import io.firetail.logging.base.Constants.Companion.REQUEST_ID -import io.firetail.logging.base.FiretailConfig -import io.firetail.logging.base.FiretailTemplate +import io.firetail.logging.core.Constants.Companion.CORRELATION_ID +import io.firetail.logging.core.Constants.Companion.OP_NAME +import io.firetail.logging.core.Constants.Companion.REQUEST_ID +import io.firetail.logging.core.FiretailBuffer +import io.firetail.logging.spring.FiretailConfig +import io.firetail.logging.core.FiretailLogger +import io.firetail.logging.core.FiretailTemplate import io.firetail.logging.util.FiretailMDC import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest @@ -25,11 +27,12 @@ import java.util.concurrent.CompletableFuture @ConditionalOnClass(FiretailConfig::class) class FiretailFilter( private val firetailLogContext: FiretailMDC, + private val firetailLogger: FiretailLogger, private val firetailConfig: FiretailConfig, private val firetailMapper: FiretailMapper, ) { @Autowired - private lateinit var firetailTemplate: FiretailTemplate + private lateinit var firetailBuffer: FiretailBuffer @Autowired lateinit var context: ApplicationContext @@ -54,7 +57,7 @@ class FiretailFilter( } val startTime = System.currentTimeMillis() val wrappedRequest = SpringRequestWrapper(request) - firetailTemplate.logRequest(wrappedRequest) + firetailLogger.logRequest(wrappedRequest) val wrappedResponse = SpringResponseWrapper(response) try { with(wrappedResponse) { @@ -63,20 +66,25 @@ class FiretailFilter( } chain.doFilter(wrappedRequest, wrappedResponse) val duration = System.currentTimeMillis() - startTime - firetailTemplate.logResponse(wrappedResponse, duration = duration) + firetailLogger.logResponse(wrappedResponse, duration = duration) val firetailLog = - firetailMapper.from(wrappedRequest, wrappedResponse, - duration) + firetailMapper.from( + wrappedRequest, + wrappedResponse, + duration, + ) CompletableFuture.runAsync { try { - firetailTemplate.send(firetailLog) + synchronized(firetailBuffer) { + firetailBuffer.add(firetailLog) + } } catch (e: Exception) { LOGGER.error(e.message) throw e } } } catch (e: Exception) { - firetailTemplate.logResponse(wrappedResponse, 500, startTime) + firetailLogger.logResponse(wrappedResponse, 500, startTime) throw e } } diff --git a/src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt index a349cfc..b871589 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailHeaderInterceptor.kt @@ -1,7 +1,7 @@ package io.firetail.logging.servlet -import io.firetail.logging.base.Constants.Companion.CORRELATION_ID -import io.firetail.logging.base.Constants.Companion.REQUEST_ID +import io.firetail.logging.core.Constants.Companion.CORRELATION_ID +import io.firetail.logging.core.Constants.Companion.REQUEST_ID import org.slf4j.MDC import org.springframework.http.HttpRequest import org.springframework.http.client.ClientHttpRequestExecution diff --git a/src/main/kotlin/io/firetail/logging/servlet/FiretailMapper.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailMapper.kt index aa2a702..e19d353 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/FiretailMapper.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailMapper.kt @@ -1,14 +1,17 @@ package io.firetail.logging.servlet -import io.firetail.logging.base.FiretailLog -import io.firetail.logging.base.FtRequest -import io.firetail.logging.base.FtResponse +import com.fasterxml.jackson.databind.ObjectMapper +import io.firetail.logging.core.FiretailData +import io.firetail.logging.core.FtRequest +import io.firetail.logging.core.FtResponse import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import java.util.HashMap class FiretailMapper { - fun from(request: HttpServletRequest, response: HttpServletResponse, executionTime: Long): FiretailLog { - return FiretailLog(request = from(request), response = from(response), executionTime = executionTime.toInt()) + private val objectMapper = ObjectMapper() + fun from(request: HttpServletRequest, response: HttpServletResponse, executionTime: Long): FiretailData { + return FiretailData(request = from(request), response = from(response), executionTime = executionTime.toInt()) } fun from(request: HttpServletRequest): FtRequest { @@ -37,4 +40,13 @@ class FiretailMapper { headers = headers, ) } + + fun getResult(result: String): String { + return objectMapper.readValue(result, HashMap::class.java) + .get("message") as String + } + + fun from(fireTailData: List): String { + return fireTailData.joinToString("\n") { objectMapper.writeValueAsString(it) } + } } diff --git a/src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt b/src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt index a5aa9b7..bcb347f 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/SpringRequestWrapper.kt @@ -1,6 +1,6 @@ package io.firetail.logging.servlet -import io.firetail.logging.base.Constants.Companion.empty +import io.firetail.logging.core.Constants.Companion.empty import jakarta.servlet.ReadListener import jakarta.servlet.ServletInputStream import jakarta.servlet.http.HttpServletRequest diff --git a/src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt b/src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt index dfed35a..b7a2bb2 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/SpringResponseWrapper.kt @@ -1,6 +1,6 @@ package io.firetail.logging.servlet -import io.firetail.logging.base.Constants.Companion.empty +import io.firetail.logging.core.Constants.Companion.empty import jakarta.servlet.ServletOutputStream import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponseWrapper diff --git a/src/main/kotlin/io/firetail/logging/base/EnableFiretail.kt b/src/main/kotlin/io/firetail/logging/spring/EnableFiretail.kt similarity index 89% rename from src/main/kotlin/io/firetail/logging/base/EnableFiretail.kt rename to src/main/kotlin/io/firetail/logging/spring/EnableFiretail.kt index be39000..da6e7aa 100644 --- a/src/main/kotlin/io/firetail/logging/base/EnableFiretail.kt +++ b/src/main/kotlin/io/firetail/logging/spring/EnableFiretail.kt @@ -1,4 +1,4 @@ -package io.firetail.logging.base +package io.firetail.logging.spring import org.springframework.context.annotation.Import diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt b/src/main/kotlin/io/firetail/logging/spring/FiretailBeanFactory.kt similarity index 60% rename from src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt rename to src/main/kotlin/io/firetail/logging/spring/FiretailBeanFactory.kt index b8aec36..1fb6d9e 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailBeanFactory.kt +++ b/src/main/kotlin/io/firetail/logging/spring/FiretailBeanFactory.kt @@ -1,5 +1,7 @@ -package io.firetail.logging.base +package io.firetail.logging.spring +import io.firetail.logging.core.FiretailBuffer +import io.firetail.logging.core.FiretailTemplate import io.firetail.logging.servlet.FiretailHeaderInterceptor import io.firetail.logging.servlet.FiretailMapper import io.firetail.logging.util.FiretailMDC @@ -16,17 +18,23 @@ class FiretailBeanFactory { fun firetailMDC(): FiretailMDC = FiretailMDC() @Bean - fun firetailTemplate(firetailConfig: FiretailConfig): FiretailTemplate { - return FiretailTemplate(firetailConfig) + fun firetailMapper(): FiretailMapper = FiretailMapper() + + @Bean + fun firetailTemplate(firetailConfig: FiretailConfig, firetailMapper: FiretailMapper): FiretailTemplate { + return FiretailTemplate(firetailConfig, firetailMapper) } + @Bean + fun firetailBuffer(firetailConfig: FiretailConfig, + firetailTemplate: FiretailTemplate, + firetailMapper: FiretailMapper): FiretailBuffer = + FiretailBuffer(firetailConfig, firetailTemplate, firetailMapper) + @Bean fun firetailHeaderInterceptor(restTemplate: RestTemplate): FiretailHeaderInterceptor { val ftHeader = FiretailHeaderInterceptor() restTemplate.interceptors.add(ftHeader) return ftHeader } - - @Bean - fun firetailMapper(): FiretailMapper = FiretailMapper() } diff --git a/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt b/src/main/kotlin/io/firetail/logging/spring/FiretailConfig.kt similarity index 75% rename from src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt rename to src/main/kotlin/io/firetail/logging/spring/FiretailConfig.kt index b13332d..4646dfd 100644 --- a/src/main/kotlin/io/firetail/logging/base/FiretailConfig.kt +++ b/src/main/kotlin/io/firetail/logging/spring/FiretailConfig.kt @@ -1,5 +1,7 @@ -package io.firetail.logging.base +package io.firetail.logging.spring +import io.firetail.logging.core.FiretailBuffer +import io.firetail.logging.core.FiretailLogger import io.firetail.logging.servlet.FiretailFilter import io.firetail.logging.util.StringUtils import jakarta.annotation.PostConstruct @@ -7,6 +9,7 @@ import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnClass +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import import org.springframework.web.client.RestTemplate @@ -28,11 +31,20 @@ class FiretailConfig @Autowired constructor( val url: String, @Value("\${firetail.apikey:not-defined}") val apikey: String = "not-defined", -) { + + @Value("\${firetail.buffer.interval:60000}") + val flushIntervalMillis: Long = 60000, + @Value("\${firetail.buffer.capacity:1}") + val capacity: Int = 1, + + ) { val key = "x-ft-api-key" val logsBulk = "/logs/bulk" + @Bean + fun firetailLogger(): FiretailLogger = FiretailLogger(this) + @PostConstruct fun logStatus() { LoggerFactory.getLogger(FiretailConfig::class.java).info("Firetail Initialized. url: $url") diff --git a/src/main/kotlin/io/firetail/logging/util/FiretailMDC.kt b/src/main/kotlin/io/firetail/logging/util/FiretailMDC.kt index e6c980f..d6c8521 100644 --- a/src/main/kotlin/io/firetail/logging/util/FiretailMDC.kt +++ b/src/main/kotlin/io/firetail/logging/util/FiretailMDC.kt @@ -1,7 +1,7 @@ package io.firetail.logging.util -import io.firetail.logging.base.Constants.Companion.CORRELATION_ID -import io.firetail.logging.base.Constants.Companion.REQUEST_ID +import io.firetail.logging.core.Constants.Companion.CORRELATION_ID +import io.firetail.logging.core.Constants.Companion.REQUEST_ID import jakarta.servlet.http.HttpServletRequest class FiretailMDC(private val keyGenerator: KeyGenerator = KeyGenerator()) { diff --git a/src/test/kotlin/io/firetail/logging/FiretailBufferTest.kt b/src/test/kotlin/io/firetail/logging/FiretailBufferTest.kt new file mode 100644 index 0000000..d8acdac --- /dev/null +++ b/src/test/kotlin/io/firetail/logging/FiretailBufferTest.kt @@ -0,0 +1,43 @@ +package io.firetail.logging + +import io.firetail.logging.core.FiretailBuffer +import io.firetail.logging.core.FiretailData +import io.firetail.logging.core.FiretailTemplate +import io.firetail.logging.core.FtRequest +import io.firetail.logging.core.FtResponse +import io.firetail.logging.servlet.FiretailMapper +import io.firetail.logging.spring.FiretailConfig +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import kotlin.test.Test + +class FiretailBufferTest { + + @Mock + private lateinit var firetailConfig: FiretailConfig + + @Mock + private lateinit var firetailTemplate: FiretailTemplate + + @BeforeEach + fun setUp() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun buffer() { + val firetailConfig = FiretailConfig(capacity = 10, url = "") + // val firetailTemplate: FiretailTemplate = FiretailTemplate(firetailConfig) + val firetailBuffer = FiretailBuffer(firetailConfig, firetailTemplate) + firetailBuffer.add(FiretailData(request = FtRequest(), response = FtResponse())) + assertThat(firetailBuffer.size() == 1) + Mockito.`when`(firetailTemplate.send(any())) + .thenReturn("{\n \"message\": \"success\"\n}") + firetailBuffer.flush() + assertThat(firetailBuffer.size() == 0) + } +} diff --git a/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt b/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt index e4ff7ba..56849b8 100644 --- a/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt +++ b/src/test/kotlin/io/firetail/logging/FiretailDataSerialization.kt @@ -1,7 +1,7 @@ package io.firetail.logging import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.firetail.logging.base.FiretailLog +import io.firetail.logging.core.FiretailData import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.springframework.core.io.ClassPathResource @@ -19,10 +19,10 @@ class FiretailDataSerialization { companion object { @JvmStatic - fun firetailLog(): FiretailLog? { + fun firetailLog(): FiretailData? { val objectMapper = jacksonObjectMapper() val jsonFile = ClassPathResource("/schemaV1Alpha.json").file - return objectMapper.readValue(jsonFile, FiretailLog::class.java) + return objectMapper.readValue(jsonFile, FiretailData::class.java) } } } diff --git a/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt b/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt index 74dde93..6e9bf36 100644 --- a/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt +++ b/src/test/kotlin/io/firetail/logging/FiretailDisabledTest.kt @@ -1,7 +1,7 @@ package io.firetail.logging -import io.firetail.logging.base.FiretailConfig -import io.firetail.logging.base.FiretailTemplate +import io.firetail.logging.spring.FiretailConfig +import io.firetail.logging.core.FiretailTemplate import io.firetail.logging.servlet.FiretailFilter import io.firetail.logging.util.FiretailMDC import org.assertj.core.api.Assertions.assertThat diff --git a/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt b/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt index 0697f00..178dc96 100644 --- a/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt +++ b/src/test/kotlin/io/firetail/logging/FiretailMapperTest.kt @@ -1,9 +1,13 @@ package io.firetail.logging +import io.firetail.logging.core.FiretailData +import io.firetail.logging.core.FtRequest +import io.firetail.logging.core.FtResponse import io.firetail.logging.servlet.FiretailMapper import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.mockito.Mockito import java.util.* @@ -44,6 +48,17 @@ class FiretailMapperTest { .hasFieldOrPropertyWithValue(TEST, listOf(TEST_RESULTS)) } + @Test + fun jsonNd() { + val firetailMapper = FiretailMapper() + val rows = listOf( + FiretailData(request = FtRequest(body = "body1"), response = FtResponse()), + FiretailData(request = FtRequest(body = "body2"), response = FtResponse()) + ) + val result = firetailMapper.from(rows) + assertThat(result).contains("body1").contains("body2") + } + companion object { private const val TEST = "X-TEST" private const val TEST_RESULTS = "TEST-RESULTS" diff --git a/src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt b/src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt index 997229d..b0afc21 100644 --- a/src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt +++ b/src/test/kotlin/io/firetail/logging/MDCGeneratorTests.kt @@ -1,7 +1,7 @@ package io.firetail.logging -import io.firetail.logging.base.Constants.Companion.CORRELATION_ID -import io.firetail.logging.base.Constants.Companion.REQUEST_ID +import io.firetail.logging.core.Constants.Companion.CORRELATION_ID +import io.firetail.logging.core.Constants.Companion.REQUEST_ID import io.firetail.logging.util.FiretailMDC import io.firetail.logging.util.KeyGenerator import org.assertj.core.api.Assertions.assertThat diff --git a/src/test/kotlin/io/firetail/logging/MockServer.kt b/src/test/kotlin/io/firetail/logging/MockServer.kt deleted file mode 100644 index 593ae49..0000000 --- a/src/test/kotlin/io/firetail/logging/MockServer.kt +++ /dev/null @@ -1,48 +0,0 @@ -package io.firetail.logging - -import com.github.tomakehurst.wiremock.client.WireMock.ok -import com.github.tomakehurst.wiremock.client.WireMock.post -import com.github.tomakehurst.wiremock.client.WireMock.stubFor -import io.firetail.logging.base.FiretailConfig -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock -import org.springframework.test.context.TestPropertySource -import org.springframework.web.client.RestTemplate -import org.springframework.web.client.postForObject - -@SpringBootTest(classes = [FiretailConfig::class]) -@AutoConfigureMockMvc -@AutoConfigureWireMock(port = 0) -@TestPropertySource( - properties = [ - "logging.firetail.enabled=true", - "firetail.url=http://localhost:\${wiremock.server.port}", - ], -) -class MockServer { - @Value("\${firetail.url}") - private lateinit var firetailUrl: String - - @Autowired - lateinit var firetailConfig: FiretailConfig - - @Test - fun testExternalServiceIntegration() { - stubFor( - post(firetailConfig.logsBulk).willReturn( - ok().withBody("Mocked Response"), - ), - ) - val url = "${firetailUrl}${firetailConfig.logsBulk}" - val firetailLog = FiretailDataSerialization.firetailLog() - - val response = RestTemplate().postForObject(url, firetailLog) - - assertEquals("Mocked Response", response) - } -} diff --git a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt index cb84360..b606261 100644 --- a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt +++ b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt @@ -1,11 +1,13 @@ package io.firetail.logging -import io.firetail.logging.base.Constants -import io.firetail.logging.base.EnableFiretail -import io.firetail.logging.servlet.FiretailMapper -import io.firetail.logging.base.FiretailTemplate +import io.firetail.logging.core.Constants +import io.firetail.logging.core.FiretailBuffer +import io.firetail.logging.spring.EnableFiretail +import io.firetail.logging.core.FiretailLogger +import io.firetail.logging.core.FiretailTemplate import io.firetail.logging.servlet.FiretailFilter import io.firetail.logging.servlet.FiretailHeaderInterceptor +import io.firetail.logging.servlet.FiretailMapper import io.firetail.logging.util.FiretailMDC import io.firetail.logging.util.StringUtils import org.assertj.core.api.Assertions.assertThat @@ -34,25 +36,30 @@ import org.springframework.web.client.RestTemplate @AutoConfigureWireMock(port = 0) @TestPropertySource( properties = [ - "logging.firetail.enabled=true", "firetail.url=http://localhost:\${wiremock.server.port}", ], ) @EnableFiretail class RequestInterceptorTests { + @MockBean + private lateinit var firetailLogger: FiretailLogger + @Autowired - private lateinit var stringUtils: StringUtils + private lateinit var firetailMapper: FiretailMapper - @MockBean + @Autowired private lateinit var firetailTemplate: FiretailTemplate @Autowired - private lateinit var firetailMapper: FiretailMapper + private lateinit var stringUtils: StringUtils @Autowired private lateinit var firetailHeaderInterceptor: FiretailHeaderInterceptor + @Autowired + private lateinit var firetailBuffer: FiretailBuffer + @Autowired private lateinit var mockMvc: MockMvc @@ -73,6 +80,7 @@ class RequestInterceptorTests { assertThat(firetailFilter).isNotNull assertThat(firetailMapper).isNotNull assertThat(restTemplate).isNotNull + assertThat(firetailBuffer).isNotNull assertThat(restTemplate.interceptors).isNotEmpty.contains(firetailHeaderInterceptor) } @@ -82,30 +90,26 @@ class RequestInterceptorTests { val result = mockMvc.perform(MockMvcRequestBuilders.get("/hello")) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() - verify(firetailTemplate) - .logRequest(any()) // Called once - verify(firetailTemplate) - .logResponse(any(), any(), any()) // Called once -// Mockito.`when`(firetailMapper.from(any(), any(), any())) -// .thenReturn( -// FiretailLog( -// request = FtRequest(ip = "test", resource = "/", uri = ""), -// response = FtResponse(), -// ), -// ) - verify(firetailTemplate) - .send(any()) - - // Headers are set + assertThat(result.response.headerNames) .contains(Constants.REQUEST_ID, Constants.CORRELATION_ID) + verify(firetailLogger) + .logRequest(any()) // Called once + + verify(firetailLogger) + .logResponse(any(), any(), any()) // Called once + assertThat(firetailMDC.get(Constants.REQUEST_ID)) .isNotNull() .isEqualTo(result.response.getHeaderValue(Constants.REQUEST_ID)) + assertThat(firetailMDC.get(Constants.CORRELATION_ID)) .isNotNull() .isEqualTo(result.response.getHeaderValue(Constants.CORRELATION_ID)) + + assertThat(firetailBuffer.size() == 1) + assertThat(firetailBuffer.flush()).isEqualTo("success") } // Emulates a general MVC controller for which we want to diff --git a/src/test/resources/mappings/v1alpha.json b/src/test/resources/mappings/v1alpha.json new file mode 100644 index 0000000..fbaece4 --- /dev/null +++ b/src/test/resources/mappings/v1alpha.json @@ -0,0 +1,19 @@ +{ + "request": { + "method": "POST", + "url": "/logs/bulk", + "headers": { + "Content-Type": { + "equalTo": "application/nd-json" + } + } + }, + + "response": { + "status": 200, + "body": "{\n \"message\": \"success\"\n}", + "headers": { + "Content-Type": "application/json" + } + } +} \ No newline at end of file From 87b1186653fea06c31b77ad7704ebfb16ecfdea2 Mon Sep 17 00:00:00 2001 From: Mike Holdsworth Date: Tue, 5 Dec 2023 09:44:34 +0800 Subject: [PATCH 15/25] refactor: Internalise locking of the Buffer and remove the need to sycronize in the filter --- examples/README.md | 11 ++++++- .../firetail/logging/core/FiretailBuffer.kt | 31 ++++++++++++++----- .../logging/servlet/FiretailFilter.kt | 6 ++-- .../logging/RequestInterceptorTests.kt | 3 +- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/examples/README.md b/examples/README.md index 775b754..2dac8f4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,12 +2,19 @@ Requires Java 17 -You will require an `application-local.yaml` file. It will look something like this: +You will require an `application-local.yml` file. It will look something like this: ```yaml firetail: apikey: "PS-02....441b09761c3" url: "https://your-apiapi.logging.eu-north-west-99.sandbox.firetail.app" + ## Cache control before dispatching logs to API + buffer: + # Millis + interval: 100000 + # Max capacity + capacity: 5 + ``` Firstly, build the Firetail-Java-Library @@ -19,6 +26,8 @@ cd .. # Run the example cd examples ./gradlew bootRun +# By default, you'll want to hit this endpoint 5 times before the logs are dispatched +# Otherwise hit it < 5 and wait for 10 seconds curl http://localhost:8080/hello ``` diff --git a/src/main/kotlin/io/firetail/logging/core/FiretailBuffer.kt b/src/main/kotlin/io/firetail/logging/core/FiretailBuffer.kt index 434d4c4..c43f14c 100644 --- a/src/main/kotlin/io/firetail/logging/core/FiretailBuffer.kt +++ b/src/main/kotlin/io/firetail/logging/core/FiretailBuffer.kt @@ -1,5 +1,6 @@ package io.firetail.logging.core +import io.firetail.logging.core.FiretailLogger.Companion.LOGGER import io.firetail.logging.servlet.FiretailMapper import io.firetail.logging.spring.FiretailConfig import org.slf4j.LoggerFactory @@ -7,6 +8,7 @@ import java.util.* import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock class FiretailBuffer( private val firetailConfig: FiretailConfig, @@ -16,6 +18,7 @@ class FiretailBuffer( private val buffer: MutableList = mutableListOf() private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() private val flushCallback = mutableListOf() + private val bufferLock = ReentrantLock() init { // Schedule the periodic flush task @@ -31,19 +34,31 @@ class FiretailBuffer( ) } + // Caller sycronizes access before calling this function fun add(item: FiretailData) { - buffer.add(item) - if (buffer.size >= firetailConfig.capacity) { - flush() + bufferLock.lock() + try { + buffer.add(item) + if (buffer.size >= firetailConfig.capacity) { + flush() + } + } finally { + bufferLock.unlock() } } + // Threadsafe - write and reset the cached data fun flush(): String { - if (buffer.isNotEmpty()) { - LOGGER.debug("Buffer flushing ${buffer.size}") - val result = firetailTemplate.send(buffer.toList()) - buffer.clear() - return firetailMapper.getResult(result) + bufferLock.lock() + try { + if (buffer.isNotEmpty()) { + LOGGER.debug("Buffer flushing ${buffer.size}") + val result = firetailTemplate.send(buffer) + buffer.clear() + return firetailMapper.getResult(result) + } + } finally { + bufferLock.unlock() } return "" } diff --git a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt index 8bdb7c6..5d9823f 100644 --- a/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt +++ b/src/main/kotlin/io/firetail/logging/servlet/FiretailFilter.kt @@ -4,9 +4,9 @@ import io.firetail.logging.core.Constants.Companion.CORRELATION_ID import io.firetail.logging.core.Constants.Companion.OP_NAME import io.firetail.logging.core.Constants.Companion.REQUEST_ID import io.firetail.logging.core.FiretailBuffer -import io.firetail.logging.spring.FiretailConfig import io.firetail.logging.core.FiretailLogger import io.firetail.logging.core.FiretailTemplate +import io.firetail.logging.spring.FiretailConfig import io.firetail.logging.util.FiretailMDC import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest @@ -75,9 +75,7 @@ class FiretailFilter( ) CompletableFuture.runAsync { try { - synchronized(firetailBuffer) { - firetailBuffer.add(firetailLog) - } + firetailBuffer.add(firetailLog) } catch (e: Exception) { LOGGER.error(e.message) throw e diff --git a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt index b606261..469f2c5 100644 --- a/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt +++ b/src/test/kotlin/io/firetail/logging/RequestInterceptorTests.kt @@ -2,12 +2,12 @@ package io.firetail.logging import io.firetail.logging.core.Constants import io.firetail.logging.core.FiretailBuffer -import io.firetail.logging.spring.EnableFiretail import io.firetail.logging.core.FiretailLogger import io.firetail.logging.core.FiretailTemplate import io.firetail.logging.servlet.FiretailFilter import io.firetail.logging.servlet.FiretailHeaderInterceptor import io.firetail.logging.servlet.FiretailMapper +import io.firetail.logging.spring.EnableFiretail import io.firetail.logging.util.FiretailMDC import io.firetail.logging.util.StringUtils import org.assertj.core.api.Assertions.assertThat @@ -37,6 +37,7 @@ import org.springframework.web.client.RestTemplate @TestPropertySource( properties = [ "firetail.url=http://localhost:\${wiremock.server.port}", + "firetail.buffer.capacity=5", ], ) @EnableFiretail From bfd9baf151b947e33ae0912e8be7f1deaa78387b Mon Sep 17 00:00:00 2001 From: Muhammad Nuzaihan Bin Kamal Luddin Date: Wed, 6 Dec 2023 00:59:28 +0800 Subject: [PATCH 16/25] add issue notes and github actions --- .github/pull_request_template.md | 11 +++++++++ .github/workflows/main.yml | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/main.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..b2ad03f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Describe your changes + +## Issue ticket number and link + +## Checklist before requesting a review + +- [ ] I have resolved any merge conflicts +- [ ] I have run tests locally and they pass +- [ ] I have linted and auto-formatted the code +- [ ] If there is new or changed functionality, I have added/updated the tests +- [ ] If there is new or changed functionality, I have added/updated the documentation diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..89191a7 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,38 @@ +name: Main + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + + # https://help.github.com/en/articles/workflow-syntax-for-github-actions#jobsjob_idstrategymatrix + strategy: + fail-fast: false + matrix: + # Uncomment ci_node_total and ci_node_index, ONLY if we have multiple tests + # and need to run it in parallel + # [n] - where the n is a number of parallel jobs you want to run your tests on. + # Use a higher number if you have slow tests to split them between more parallel jobs. + # Remember to update the value of the `ci_node_index` below to (0..n-1). + #ci_node_total: [8] + # Indexes for parallel jobs (starting from zero). + # E.g. use [0, 1] for 2 parallel jobs, [0, 1, 2] for 3 parallel jobs, etc. + #ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7] + ruby-version: ['2.7', '3.0', '3.1'] + + env: + TZ: "Europe/Ireland" + + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK 11 + uses: actions/setup-java@v2 + with: + java-version: '11' + distribution: 'oracle' + + - name: Run tests + run: | + ./gradlew test From 140ace4c81745eeb4f07a614763582e63c5e4ecb Mon Sep 17 00:00:00 2001 From: Muhammad Nuzaihan Bin Kamal Luddin Date: Wed, 6 Dec 2023 01:05:28 +0800 Subject: [PATCH 17/25] change version for jdk --- .github/workflows/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 89191a7..d3709e4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,10 +27,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDK 11 - uses: actions/setup-java@v2 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: '11' + java-version: '17' distribution: 'oracle' - name: Run tests From d7a373711fe26bc653719103428eccc6f970f7a1 Mon Sep 17 00:00:00 2001 From: Muhammad Nuzaihan Bin Kamal Luddin Date: Wed, 6 Dec 2023 01:06:45 +0800 Subject: [PATCH 18/25] check for multiple java versions --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d3709e4..310a0ce 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,7 @@ jobs: # Indexes for parallel jobs (starting from zero). # E.g. use [0, 1] for 2 parallel jobs, [0, 1, 2] for 3 parallel jobs, etc. #ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7] - ruby-version: ['2.7', '3.0', '3.1'] + java-version: ['11', '17', '21'] env: TZ: "Europe/Ireland" From 1e956da6d69d689817d7c2af9c092b697c564ad8 Mon Sep 17 00:00:00 2001 From: Muhammad Nuzaihan Bin Kamal Luddin Date: Wed, 6 Dec 2023 01:08:24 +0800 Subject: [PATCH 19/25] remove java 17 --- .github/workflows/main.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 310a0ce..a203236 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,7 +30,6 @@ jobs: - name: Set up JDK 17 uses: actions/setup-java@v4 with: - java-version: '17' distribution: 'oracle' - name: Run tests From a57ba9d67431fc5185da0bef727466548769fcf7 Mon Sep 17 00:00:00 2001 From: Muhammad Nuzaihan Bin Kamal Luddin Date: Wed, 6 Dec 2023 01:09:22 +0800 Subject: [PATCH 20/25] add back java-version --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a203236..efc0e76 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,9 +27,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDK 17 + - name: Set up JDK uses: actions/setup-java@v4 with: + java-version: 17 distribution: 'oracle' - name: Run tests From 24eaa35802c2337bbf8383b00d2bc0c90d26c698 Mon Sep 17 00:00:00 2001 From: Muhammad Nuzaihan Bin Kamal Luddin Date: Wed, 6 Dec 2023 01:13:21 +0800 Subject: [PATCH 21/25] cache builds --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index efc0e76..0bed0d7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,6 +32,7 @@ jobs: with: java-version: 17 distribution: 'oracle' + cache: 'gradle' - name: Run tests run: | From 238b8788f90c9de608bfe063bf9472fa1ce44c11 Mon Sep 17 00:00:00 2001 From: Muhammad Nuzaihan Bin Kamal Luddin Date: Tue, 8 Oct 2024 04:25:42 +0800 Subject: [PATCH 22/25] add documentation --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 993305e..ee1a642 100644 --- a/README.md +++ b/README.md @@ -1 +1,35 @@ # firetail-java-lib + +## Building the library + +1. In the projects root folder, run `./gradlew build -x test` +2. Run `./gradlew publishToMavenLocal` + +### Springboot configuration +1. In `build.gradle.kts` (if you are using kotlin configuration), add within `dependencies {}`, the firetail library: + +``` +dependencies { + implementation("com.github.firetail-io:firetail-java-lib:$version") +} +``` +2. Also in `build.gradle.kts` add below the `plugins {}`: + +``` +group = "com.github.firetail-io" +version = "0.0.1-SNAPSHOT" +``` + +3. In your springboot project, add `application.yml` in `build/resources` + +```yaml +firetail: + apikey: "PS-02....441b09761c3" + url: "https://your-apiapi.logging.eu-north-west-99.sandbox.firetail.app" + ## Cache control before dispatching logs to API + buffer: + # Millis + interval: 100000 + # Max capacity + capacity: 5 +``` From e6a01ba1c7154739337560b58c72b8721e874c2a Mon Sep 17 00:00:00 2001 From: Muhammad Nuzaihan Bin Kamal Luddin Date: Wed, 9 Oct 2024 01:11:11 +0800 Subject: [PATCH 23/25] should be application-local.yml --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ee1a642..4a569b9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ group = "com.github.firetail-io" version = "0.0.1-SNAPSHOT" ``` -3. In your springboot project, add `application.yml` in `build/resources` +3. In your springboot project, add `application-local.yml` in `build/resources` ```yaml firetail: From 45665671c5eea355cdd54ee42d7cddc1c6377eb6 Mon Sep 17 00:00:00 2001 From: Muhammad Nuzaihan Bin Kamal Luddin Date: Wed, 16 Oct 2024 02:13:59 +0800 Subject: [PATCH 24/25] add a note to tell user about env vars --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 4a569b9..f502187 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ version = "0.0.1-SNAPSHOT" 3. In your springboot project, add `application-local.yml` in `build/resources` +**NOTE: Ensure there is no FIRETAIL_URL and FIRETAIL_API_KEY variable in your environment,** + **else this will override your yaml file configuration** + ```yaml firetail: apikey: "PS-02....441b09761c3" From d43259b2357020bc60078399a8688826263383e3 Mon Sep 17 00:00:00 2001 From: Muhammad Nuzaihan Bin Kamal Luddin Date: Wed, 16 Oct 2024 02:14:27 +0800 Subject: [PATCH 25/25] rephrase it a bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f502187..b34020e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ version = "0.0.1-SNAPSHOT" 3. In your springboot project, add `application-local.yml` in `build/resources` **NOTE: Ensure there is no FIRETAIL_URL and FIRETAIL_API_KEY variable in your environment,** - **else this will override your yaml file configuration** + **which will override your yaml file configuration** ```yaml firetail: