diff --git a/.editorconfig b/.editorconfig index 7e731d3d..307d2448 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,7 +8,7 @@ max_line_length = 199 tab_width = 4 ij_continuation_indent_size = 4 -[*.java] +[*.{java,kt}] ij_java_imports_layout = *,|,java.**,javax.**,|,$* ij_java_align_multiline_parameters = true ij_java_blank_lines_after_class_header = 1 @@ -30,8 +30,8 @@ ij_java_keep_simple_blocks_in_one_line = true ij_java_keep_simple_classes_in_one_line = false ij_java_keep_simple_lambdas_in_one_line = true ij_java_keep_simple_methods_in_one_line = true -ij_java_names_count_to_use_import_on_demand = 101 -ij_java_class_count_to_use_import_on_demand = 101 +ij_java_names_count_to_use_import_on_demand = 201 +ij_java_class_count_to_use_import_on_demand = 201 ij_java_packages_to_use_import_on_demand = java.awt.*, javax.swing.* ij_java_prefer_longer_names = true ij_java_space_after_closing_angle_bracket_in_type_argument = false diff --git a/build.gradle.kts b/build.gradle.kts index 9529387d..3f95c1ef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ description = "Experiments with Java" allprojects { group = "io.github.mfvanek" - version = "0.3.3" + version = "0.4.0" repositories { mavenLocal() diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index c9beb2c4..ee4310e5 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -14,4 +14,8 @@ dependencies { implementation("de.thetaphi:forbiddenapis:3.9") implementation("com.github.spotbugs.snom:spotbugs-gradle-plugin:6.1.7") implementation("org.gradle:test-retry-gradle-plugin:1.6.2") + val kotlinVersion = "2.0.21" + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + implementation("org.jetbrains.kotlin:kotlin-allopen:$kotlinVersion") + implementation(libs.detekt) } diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 00000000..74279e55 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,9 @@ +rootProject.name = "sb-ot-demo-conventions" + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/kotlin/sb-ot-demo.jacoco-rules.gradle.kts b/buildSrc/src/main/kotlin/sb-ot-demo.jacoco-rules.gradle.kts new file mode 100644 index 00000000..170ae653 --- /dev/null +++ b/buildSrc/src/main/kotlin/sb-ot-demo.jacoco-rules.gradle.kts @@ -0,0 +1,46 @@ +plugins { + id("java") + id("jacoco") +} +tasks { + jacocoTestCoverageVerification { + dependsOn(jacocoTestReport) + violationRules { + rule { + limit { + counter = "CLASS" + value = "MISSEDCOUNT" + maximum = "0.0".toBigDecimal() + } + } + rule { + limit { + counter = "METHOD" + value = "MISSEDCOUNT" + maximum = "2.0".toBigDecimal() + } + } + rule { + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = "7.0".toBigDecimal() + } + } + rule { + limit { + counter = "INSTRUCTION" + value = "COVEREDRATIO" + minimum = "0.93".toBigDecimal() + } + } + rule { + limit { + counter = "BRANCH" + value = "COVEREDRATIO" + minimum = "0.66".toBigDecimal() + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/sb-ot-demo.java-compile.gradle.kts b/buildSrc/src/main/kotlin/sb-ot-demo.java-compile.gradle.kts new file mode 100644 index 00000000..4761e4b1 --- /dev/null +++ b/buildSrc/src/main/kotlin/sb-ot-demo.java-compile.gradle.kts @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +plugins { + id("java") + id("jacoco") + id("com.google.osdetector") + id("org.gradle.test-retry") +} + +dependencies { + testImplementation("org.assertj:assertj-core") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // https://github.com/netty/netty/issues/11020 + if (osdetector.arch == "aarch_64") { + testImplementation("io.netty:netty-all:4.1.104.Final") + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + withJavadocJar() + withSourcesJar() +} + +tasks { + withType().configureEach { + options.compilerArgs.add("-parameters") + options.compilerArgs.add("--should-stop=ifError=FLOW") + } + + test { + useJUnitPlatform() + + finalizedBy(jacocoTestReport, jacocoTestCoverageVerification) + maxParallelForks = 1 + + retry { + maxRetries.set(2) + maxFailures.set(5) + failOnPassedAfterRetry.set(false) + } + } + + jacocoTestReport { + dependsOn(test) + reports { + xml.required.set(true) + html.required.set(true) + } + } + + check { + dependsOn(jacocoTestCoverageVerification) + } +} diff --git a/buildSrc/src/main/kotlin/sb-ot-demo.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/sb-ot-demo.java-conventions.gradle.kts index f849bfd6..6fc75472 100644 --- a/buildSrc/src/main/kotlin/sb-ot-demo.java-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/sb-ot-demo.java-conventions.gradle.kts @@ -19,6 +19,8 @@ plugins { id("net.ltgt.errorprone") id("com.google.osdetector") id("org.gradle.test-retry") + id("sb-ot-demo.java-compile") + id("sb-ot-demo.jacoco-rules") } dependencies { @@ -43,16 +45,6 @@ dependencies { spotbugsPlugins("com.mebigfatguy.sb-contrib:sb-contrib:7.6.9") } -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -jacoco { - toolVersion = "0.8.12" -} - checkstyle { toolVersion = "10.21.1" configFile = file("${rootDir}/config/checkstyle/checkstyle.xml") @@ -84,8 +76,6 @@ tasks.withType().configureEach { tasks { withType().configureEach { - options.compilerArgs.add("-parameters") - options.compilerArgs.add("--should-stop=ifError=FLOW") options.errorprone { disableWarningsInGeneratedCode.set(true) disable("Slf4jLoggerShouldBeNonStatic") @@ -93,68 +83,6 @@ tasks { } test { - useJUnitPlatform() dependsOn(checkstyleMain, checkstyleTest, pmdMain, pmdTest, spotbugsMain, spotbugsTest) - finalizedBy(jacocoTestReport, jacocoTestCoverageVerification) - maxParallelForks = 1 - - retry { - maxRetries.set(2) - maxFailures.set(5) - failOnPassedAfterRetry.set(false) - } - } - - jacocoTestCoverageVerification { - dependsOn(jacocoTestReport) - violationRules { - rule { - limit { - counter = "CLASS" - value = "MISSEDCOUNT" - maximum = "0.0".toBigDecimal() - } - } - rule { - limit { - counter = "METHOD" - value = "MISSEDCOUNT" - maximum = "2.0".toBigDecimal() - } - } - rule { - limit { - counter = "LINE" - value = "MISSEDCOUNT" - maximum = "7.0".toBigDecimal() - } - } - rule { - limit { - counter = "INSTRUCTION" - value = "COVEREDRATIO" - minimum = "0.93".toBigDecimal() - } - } - rule { - limit { - counter = "BRANCH" - value = "COVEREDRATIO" - minimum = "0.66".toBigDecimal() - } - } - } - } - - jacocoTestReport { - dependsOn(test) - reports { - xml.required.set(true) - html.required.set(true) - } - } - - check { - dependsOn(jacocoTestCoverageVerification) } } diff --git a/buildSrc/src/main/kotlin/sb-ot-demo.kotlin-conventions.gradle.kts b/buildSrc/src/main/kotlin/sb-ot-demo.kotlin-conventions.gradle.kts new file mode 100644 index 00000000..0332eee1 --- /dev/null +++ b/buildSrc/src/main/kotlin/sb-ot-demo.kotlin-conventions.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("java") + id("jacoco") + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.spring") + id("io.gitlab.arturbosch.detekt") + id("sb-ot-demo.forbidden-apis") + id("sb-ot-demo.java-compile") +} + +private val libs = extensions.getByType().named("libs") + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-reflect") + libs.findLibrary("detekt-formatting").ifPresent { + detektPlugins(it) + } + libs.findLibrary("detekt-libraries").ifPresent { + detektPlugins(it) + } +} + +tasks.withType { + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + jvmTarget = JvmTarget.JVM_21 + } +} + +detekt { + toolVersion = libs.findVersion("detekt").get().requiredVersion + config.setFrom(file("${rootDir}/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + autoCorrect = true +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 00000000..41fedb13 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,220 @@ +performance: + SpreadOperator: + active: false + +formatting: + active: true + android: false + AnnotationOnSeparateLine: + active: false + ChainWrapping: + active: true + CommentSpacing: + active: true + Filename: + active: true + FinalNewline: + active: true + ImportOrdering: + active: false + Indentation: + active: true + indentSize: 4 + # continuationIndentSize: 4 + MaximumLineLength: + active: true + maxLineLength: 240 + ModifierOrdering: + active: true + MultiLineIfElse: + active: true + NoBlankLineBeforeRbrace: + active: true + NoConsecutiveBlankLines: + active: true + NoEmptyClassBody: + active: true + NoLineBreakAfterElse: + active: true + NoLineBreakBeforeAssignment: + active: true + NoMultipleSpaces: + active: true + NoSemicolons: + active: true + NoTrailingSpaces: + active: true + NoUnitReturn: + active: true + NoUnusedImports: + active: true + NoWildcardImports: + active: true + PackageName: + active: true + ParameterListWrapping: + active: true + # indentSize: 4 + SpacingAroundColon: + active: true + SpacingAroundComma: + active: true + SpacingAroundCurly: + active: true + SpacingAroundDot: + active: true + SpacingAroundKeyword: + active: true + SpacingAroundOperators: + active: true + SpacingAroundParens: + active: true + SpacingAroundRangeOperator: + active: true + StringTemplate: + active: true + +style: + active: true + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: ['to'] + DataClassShouldBeImmutable: + active: false + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenComment: + active: false + comments: [ 'TODO:','FIXME:','STOPSHIP:' ] + allowedPatterns: "" + ForbiddenImport: + active: false + imports: [ ] + forbiddenPatterns: "" + ForbiddenVoid: + active: false + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + excludedFunctions: [ 'describeContents' ] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: [ "**/test/**","**/*.Test.kt","**/*.Spec.kt","**/*.Spek.kt","**/*Properties.kt","**/*Configuration.kt" ] + ignoreNumbers: [ '-1','0','1','2' ] + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + MaxLineLength: + active: true + maxLineLength: 240 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + MayBeConst: + active: true + ModifierOrder: + active: true + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: true + NoTabs: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + PreferToOverPairSyntax: + active: true + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantVisibilityModifierRule: + active: true + ReturnCount: + active: true + max: 2 + excludedFunctions: [ "equals" ] + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 2 + TrailingWhitespace: + active: false + UnderscoresInNumericLiterals: + active: false + acceptableLength: 5 + UnnecessaryAbstractClass: + active: false + UnnecessaryApply: + active: false + UnnecessaryInheritance: + active: true + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: true + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: "(_|ignored|expected|serialVersionUID)" + UseArrayLiteralsInAnnotations: + active: false + UseCheckOrError: + active: false + UseDataClass: + active: false + ignoreAnnotated: [ ] + allowVars: false + UseIfInsteadOfWhen: + active: false + UseRequire: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: false + WildcardImport: + active: true + +libraries: + LibraryEntitiesShouldNotBePublic: + active: false + ForbiddenPublicDataClass: + active: false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..5b054c2c --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,16 @@ +[versions] +detekt = "1.23.8" +spring-boot-v3 = "3.4.4" + +[libraries] +detekt = { group = "io.gitlab.arturbosch.detekt", name = "detekt-gradle-plugin", version.ref = "detekt" } +detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" } +detekt-libraries = { group = "io.gitlab.arturbosch.detekt", name = "detekt-rules-libraries", version.ref = "detekt" } +spring-boot-v3-dependencies = { group = "org.springframework.boot", name = "spring-boot-dependencies", version.ref = "spring-boot-v3" } +springdoc-openapi = "org.springdoc:springdoc-openapi:2.8.6" +spring-cloud = "org.springframework.cloud:spring-cloud-dependencies:2024.0.1" +datasource-micrometer = "net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.0" +logstash = "net.logstash.logback:logstash-logback-encoder:8.0" + +[plugins] +spring-boot-v3 = { id = "org.springframework.boot", version.ref = "spring-boot-v3" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 85907c1e..25f7935a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,17 +1,7 @@ rootProject.name = "spring-boot-open-telemetry-demo" + include("spring-boot-3-demo-app") include("common-internal-bom") include("spring-boot-2-demo-app") include("db-migrations") - -dependencyResolutionManagement { - versionCatalogs { - create("libs") { - val springBoot3Version = version("spring-boot-v3", "3.4.4") - plugin("spring-boot-v3", "org.springframework.boot") - .versionRef(springBoot3Version) - library("spring-boot-v3-dependencies", "org.springframework.boot", "spring-boot-dependencies") - .versionRef(springBoot3Version) - } - } -} +include("spring-boot-3-demo-app-kotlin") diff --git a/spring-boot-3-demo-app-kotlin/build.gradle.kts b/spring-boot-3-demo-app-kotlin/build.gradle.kts new file mode 100644 index 00000000..176b1469 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/build.gradle.kts @@ -0,0 +1,109 @@ + +plugins { + id("sb-ot-demo.kotlin-conventions") + id("sb-ot-demo.forbidden-apis") + id("sb-ot-demo.docker") + alias(libs.plugins.spring.boot.v3) +} + +dependencies { + implementation(platform(project(":common-internal-bom"))) + implementation(platform(libs.springdoc.openapi)) + implementation(platform(libs.spring.boot.v3.dependencies)) + implementation(platform(libs.spring.cloud)) + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("io.micrometer:micrometer-registry-prometheus") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui") + + implementation("org.springframework.kafka:spring-kafka") + + implementation("io.micrometer:micrometer-tracing-bridge-otel") + implementation("io.opentelemetry:opentelemetry-exporter-otlp") + + implementation("org.springframework.boot:spring-boot-starter-jdbc") + implementation("org.postgresql:postgresql") + implementation("com.zaxxer:HikariCP") + implementation(project(":db-migrations")) + implementation("org.liquibase:liquibase-core") + implementation("com.github.blagerweij:liquibase-sessionlock") + implementation(libs.datasource.micrometer) + implementation(libs.logstash) + implementation("io.github.oshai:kotlin-logging-jvm:7.0.0") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webflux") + testImplementation("org.testcontainers:postgresql") + testImplementation("org.testcontainers:kafka") + testImplementation("org.springframework.kafka:spring-kafka-test") + testImplementation("org.awaitility:awaitility") + testImplementation("io.github.mfvanek:pg-index-health-test-starter") + testImplementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner") +} + +tasks { + jacocoTestCoverageVerification { + dependsOn(jacocoTestReport) + violationRules { + rule { + limit { + counter = "CLASS" + value = "MISSEDCOUNT" + maximum = "0.0".toBigDecimal() + } + } + rule { + limit { + counter = "METHOD" + value = "MISSEDCOUNT" + maximum = "5.0".toBigDecimal() + } + } + rule { + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = "0.0".toBigDecimal() + } + } + rule { + limit { + counter = "INSTRUCTION" + value = "COVEREDRATIO" + minimum = "0.92".toBigDecimal() + } + } + rule { + limit { + counter = "BRANCH" + value = "COVEREDRATIO" + minimum = "0.57".toBigDecimal() + } + } + } + } +} + +val coverageExcludeList = listOf("**/*ApplicationKt.class") +listOf(JacocoCoverageVerification::class, JacocoReport::class).forEach { taskType -> + tasks.withType(taskType) { + afterEvaluate { + classDirectories.setFrom( + files( + classDirectories.files.map { file -> + fileTree(file).apply { + exclude(coverageExcludeList) + } + } + ) + ) + } + } +} + +springBoot { + buildInfo() +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/Application.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/Application.kt new file mode 100644 index 00000000..5c224a7f --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/Application.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class Application + +fun main(args: Array) { + runApplication(*args) +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/ClockConfig.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/ClockConfig.kt new file mode 100644 index 00000000..0351c524 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/ClockConfig.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.Clock + +@Configuration(proxyBeanMethods = false) +class ClockConfig { + @Bean + fun clock(): Clock = Clock.systemUTC() +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/OpenTelemetryConfig.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/OpenTelemetryConfig.kt new file mode 100644 index 00000000..e3528d81 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/OpenTelemetryConfig.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.config + +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter +import jakarta.annotation.Nonnull +import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingAutoConfiguration +import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingProperties +import org.springframework.boot.autoconfigure.AutoConfigureBefore +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@AutoConfigureBefore(OtlpTracingAutoConfiguration::class) +@Configuration(proxyBeanMethods = false) +class OpenTelemetryConfig { + @Bean + @ConditionalOnMissingBean(OtlpGrpcSpanExporter::class) + fun otelJaegerGrpcSpanExporter(@Nonnull otlpProperties: OtlpTracingProperties): OtlpGrpcSpanExporter { + val builder = OtlpGrpcSpanExporter.builder() + .setEndpoint(otlpProperties.endpoint) + .setTimeout(otlpProperties.timeout) + .setConnectTimeout(otlpProperties.connectTimeout) + .setCompression(otlpProperties.compression.toString().lowercase()) + otlpProperties.headers.forEach { (key, value) -> builder.addHeader(key, value) } + return builder.build() + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/SecurityConfig.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/SecurityConfig.kt new file mode 100644 index 00000000..cedc2826 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/SecurityConfig.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.SecurityFilterChain + +@Configuration(proxyBeanMethods = false) +class SecurityConfig { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = + http.authorizeHttpRequests { authorize -> + authorize.anyRequest().permitAll() + } + .build() +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/WebClientConfig.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/WebClientConfig.kt new file mode 100644 index 00000000..40464c01 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/config/WebClientConfig.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.function.client.WebClient + +@Configuration(proxyBeanMethods = false) +class WebClientConfig { + @Value("\${app.external-base-url}") + private lateinit var external: String + + @Bean + fun webClient(builder: WebClient.Builder): WebClient = builder + .baseUrl(external) + .build() +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/HomeController.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/HomeController.kt new file mode 100644 index 00000000..f8c38cbd --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/HomeController.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.controllers + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class HomeController { + @GetMapping("/") + fun home(): String { + val response = "Hello!" + return response + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/RedirectController.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/RedirectController.kt new file mode 100644 index 00000000..9b2b0146 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/RedirectController.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.controllers + +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController +import java.net.URI + +@RestController +class RedirectController { + // http://localhost:8080/redirect + @GetMapping(path = ["/redirect"]) + fun redirectToGoogle(): ResponseEntity { + val google = URI("https://www.google.com") + val httpHeaders = HttpHeaders() + httpHeaders.location = google + return ResponseEntity(httpHeaders, HttpStatus.SEE_OTHER) + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/TimeController.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/TimeController.kt new file mode 100644 index 00000000..a23d5cb7 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/TimeController.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.controllers + +import io.github.mfvanek.spring.boot3.kotlin.test.service.KafkaSendingService +import io.github.mfvanek.spring.boot3.kotlin.test.service.PublicApiService +import io.github.oshai.kotlinlogging.KotlinLogging +import io.micrometer.tracing.Tracer +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController +import java.time.Clock +import java.time.LocalDateTime + +private val logger = KotlinLogging.logger {} + +@RestController +class TimeController( + private val tracer: Tracer, + private val clock: Clock, + private val kafkaSendingService: KafkaSendingService, + private val publicApiService: PublicApiService +) { + + @GetMapping(path = ["/current-time"]) + fun getNow(): LocalDateTime { + logger.trace { "tracer $tracer" } + val traceId = tracer.currentSpan()?.context()?.traceId() + logger.info { "Called method getNow. TraceId = $traceId" } + val nowFromRemote = publicApiService.getZonedTime() + val now = nowFromRemote ?: LocalDateTime.now(clock) + kafkaSendingService.sendNotification("Current time = $now") + .thenRun { logger.info { "Awaiting acknowledgement from Kafka" } } + .get() + return now + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/filters/TraceIdInResponseServletFilter.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/filters/TraceIdInResponseServletFilter.kt new file mode 100644 index 00000000..e1bd4f24 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/filters/TraceIdInResponseServletFilter.kt @@ -0,0 +1,27 @@ +package io.github.mfvanek.spring.boot3.kotlin.test.filters + +import io.micrometer.tracing.Tracer +import jakarta.servlet.Filter +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component + +@Component +class TraceIdInResponseServletFilter( + private val tracer: Tracer +) : Filter { + companion object { + const val TRACE_ID_HEADER_NAME = "X-TraceId" + } + + override fun doFilter(servletRequest: ServletRequest, servletResponse: ServletResponse, filterChain: FilterChain) { + val currentSpan = tracer.currentSpan() + if (currentSpan != null) { + val resp = servletResponse as HttpServletResponse + resp.addHeader(TRACE_ID_HEADER_NAME, currentSpan.context().traceId()) + } + filterChain.doFilter(servletRequest, servletResponse) + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/KafkaReadingService.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/KafkaReadingService.kt new file mode 100644 index 00000000..477d2cf7 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/KafkaReadingService.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.oshai.kotlinlogging.withLoggingContext +import io.micrometer.tracing.Tracer +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.springframework.beans.factory.annotation.Value +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.support.Acknowledgment +import org.springframework.stereotype.Service +import java.time.Clock +import java.time.LocalDateTime +import java.util.* + +private val logger = KotlinLogging.logger {} + +@Service +class KafkaReadingService( + @Value("\${app.tenant.name}") private val tenantName: String, + private val tracer: Tracer, + private val clock: Clock, + private val jdbcTemplate: NamedParameterJdbcTemplate +) { + @KafkaListener(topics = ["\${spring.kafka.template.default-topic}"]) + fun listen(message: ConsumerRecord, ack: Acknowledgment) { + withLoggingContext("tenant.name" to tenantName) { + processMessage(message) + ack.acknowledge() + } + } + + private fun processMessage(message: ConsumerRecord) { + val currentSpan = tracer.currentSpan() + val traceId = currentSpan?.context()?.traceId() ?: "" + logger.info { "Received record: ${message.value()} with traceId $traceId" } + jdbcTemplate.update( + "insert into otel_demo.storage(message, trace_id, created_at) values(:msg, :traceId, :createdAt);", + mapOf( + "msg" to message.value(), + "traceId" to traceId, + "createdAt" to LocalDateTime.now(clock) + ) + ) + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/KafkaSendingService.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/KafkaSendingService.kt new file mode 100644 index 00000000..7e965252 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/KafkaSendingService.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.oshai.kotlinlogging.withLoggingContext +import org.springframework.beans.factory.annotation.Value +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.support.SendResult +import org.springframework.stereotype.Service +import java.util.* +import java.util.concurrent.CompletableFuture + +private val logger = KotlinLogging.logger {} + +@Service +class KafkaSendingService( + @Value("\${app.tenant.name}") private val tenantName: String, + private val kafkaTemplate: KafkaTemplate +) { + fun sendNotification(message: String): CompletableFuture> { + withLoggingContext("tenant.name" to tenantName) { + logger.info { "Sending message \"$message\" to Kafka" } + return kafkaTemplate.sendDefault(UUID.randomUUID(), message) + } + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/PublicApiService.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/PublicApiService.kt new file mode 100644 index 00000000..c084fa0e --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/PublicApiService.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ +package io.github.mfvanek.spring.boot3.kotlin.test.service + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.mfvanek.spring.boot3.kotlin.test.service.dto.CurrentTime +import io.github.mfvanek.spring.boot3.kotlin.test.service.dto.ParsedDateTime +import io.github.mfvanek.spring.boot3.kotlin.test.service.dto.toLocalDateTime +import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.oshai.kotlinlogging.withLoggingContext +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.retry.ExhaustedRetryException +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import reactor.util.retry.Retry +import java.time.Duration +import java.time.LocalDateTime +import java.util.* + +private val logger = KotlinLogging.logger {} + +@Service +class PublicApiService( + @Value("\${app.retries}") private val retries: Int, + private val objectMapper: ObjectMapper, + private val webClient: WebClient +) { + fun getZonedTime(): LocalDateTime? { + try { + val result: ParsedDateTime = getZonedTimeFromWorldTimeApi().datetime + return result.toLocalDateTime() + } catch (e: ExhaustedRetryException) { + logger.warn { "Failed to get response $e" } + } catch (e: JsonProcessingException) { + logger.warn { "Failed to convert response $e" } + } + return null + } + + private fun getZonedTimeFromWorldTimeApi(): CurrentTime { + val zoneName = TimeZone.getDefault().id + val response = webClient.get() + .uri(zoneName) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(String::class.java) + .retryWhen(prepareRetry(zoneName)) + return objectMapper.readValue(response.block(), CurrentTime::class.java) + } + + private fun prepareRetry(zoneName: String): Retry { + return Retry.fixedDelay(retries.toLong(), Duration.ofSeconds(2)) + .doBeforeRetry { retrySignal: Retry.RetrySignal -> + withLoggingContext("instance_timezone" to zoneName) { + logger.info { + "Retrying request to '/$zoneName', attempt ${retrySignal.totalRetries() + 1}/$retries " + + "due to error: ${retrySignal.failure()}" + } + } + } + .onRetryExhaustedThrow { _, retrySignal: Retry.RetrySignal -> + logger.error { "Request to '/$zoneName' failed after ${retrySignal.totalRetries() + 1} attempts." } + ExhaustedRetryException("Retries exhausted", retrySignal.failure()) + } + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/dto/CurrentTime.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/dto/CurrentTime.kt new file mode 100644 index 00000000..b053b218 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/dto/CurrentTime.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.service.dto + +data class CurrentTime( + val datetime: ParsedDateTime +) diff --git a/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/dto/ParsedDateTime.kt b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/dto/ParsedDateTime.kt new file mode 100644 index 00000000..5ca5b271 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/dto/ParsedDateTime.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2020-2025. Ivan Vakhrushev and others. + * https://github.com/mfvanek/spring-boot-open-telemetry-demo + * + * Licensed under the Apache License 2.0 + */ + +package io.github.mfvanek.spring.boot3.kotlin.test.service.dto + +import java.time.LocalDateTime + +data class ParsedDateTime( + val year: Int, + val monthValue: Int, + val dayOfMonth: Int, + val hour: Int, + val minute: Int +) + +fun ParsedDateTime.toLocalDateTime(): LocalDateTime = + LocalDateTime.of(year, monthValue, dayOfMonth, hour, minute) +fun LocalDateTime.toParsedDateTime(): ParsedDateTime = + ParsedDateTime(year = year, monthValue = month.value, dayOfMonth = dayOfMonth, hour = hour, minute = minute) diff --git a/spring-boot-3-demo-app-kotlin/src/main/resources/application.yml b/spring-boot-3-demo-app-kotlin/src/main/resources/application.yml new file mode 100644 index 00000000..bd395aea --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/resources/application.yml @@ -0,0 +1,144 @@ +app: + external-base-url: "http://worldtimeapi.org/api/timezone/" + retries: 3 + tenant.name: ru-a1-private + +server: + port: 8080 + # See also https://docs.spring.io/spring-boot/docs/2.7.9/reference/html/application-properties.html#appendix.application-properties.server + tomcat: + accept-count: 10 + max-connections: 400 + threads: + max: 10 + min-spare: 5 # actuator port uses the same configuration + shutdown: graceful + +demo: + kafka: + opentelemetry: + username: sb-ot-demo-user + password: pwdForSbOtDemoApp + +spring: + application.name: spring-boot-3-demo-app + datasource: + username: otel_demo_user + password: otel_demo_password + # socketTimeout should be greater than the longest sql query + url: jdbc:postgresql://localhost:6432/otel_demo_db?prepareThreshold=0&targetServerType=primary&hostRecheckSeconds=2&connectTimeout=1&socketTimeout=600 + liquibase: + change-log: classpath:/db/changelog/db.changelog-master.yaml + kafka: + template: + default-topic: open.telemetry.sb3.queue + observation-enabled: true # Important!!! + producer: + key-serializer: org.apache.kafka.common.serialization.UUIDSerializer + listener: + observation-enabled: true # Important!!! + ack-mode: manual_immediate + consumer: + auto-offset-reset: earliest + group-id: ${spring.kafka.template.default-topic}-group + client-id: open.telemetry.client + bootstrap-servers: localhost:9092 + security: + protocol: SASL_PLAINTEXT + properties: + sasl: + mechanism: PLAIN + jaas.config: org.apache.kafka.common.security.plain.PlainLoginModule required username="${demo.kafka.opentelemetry.username}" password="${demo.kafka.opentelemetry.password}"; + jdbc: + template: + query-timeout: 1s + reactor: + context-propagation: auto + +management: + server: + port: 8085 + endpoints: + web: + exposure.include: '*' + cors: + allowed-methods: '*' + allowed-origins: '*' + allowed-headers: '*' + access: + default: read_only + endpoint: + health: + probes.enabled: true + group: + readiness: + include: readinessState, db + additional-path: server:/readyz # In order to collect probes from application main port + access: read_only + prometheus: + access: read_only + liquibase: + access: read_only + info: + access: read_only + threaddump: + access: read_only + heapdump: + access: read_only + metrics: + distribution: + percentiles-histogram: + http.server.requests: true + minimum-expected-value: + http.server.requests: 10ms + maximum-expected-value: + http.server.requests: 10s + slo: + http.server.requests: 1s + health: + livenessState: + enabled: true + readinessState: + enabled: true + prometheus: + metrics: + export: + enabled: true + tracing: + enabled: true + propagation: + type: + - b3 + - w3c + sampling: + probability: 1.0 + observations: + enable: + spring: + security: false + otlp: + tracing: + endpoint: http://localhost:4317 + compression: none + timeout: 5s + +springdoc: + show-actuator: true + use-management-port: true + +jdbc: + datasource-proxy: + include-parameter-values: true + query: + enable-logging: true + log-level: INFO + includes: QUERY + +--- + +spring: + config.activate.on-profile: docker + kafka.bootstrap-servers: kafka1:29092 + datasource.url: jdbc:postgresql://postgres:5432/otel_demo_db?prepareThreshold=0&targetServerType=primary&hostRecheckSeconds=2&connectTimeout=1&socketTimeout=600 + +management.otlp.tracing.endpoint: http://jaeger:4317 diff --git a/spring-boot-3-demo-app-kotlin/src/main/resources/logback-spring.xml b/spring-boot-3-demo-app-kotlin/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..3a7509ad --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/main/resources/logback-spring.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + ${logging.pattern.console} + utf8 + + + + + + + + false + true + ${includeNonStructuredArguments} + + @timestamp + message + thread + logger + level + [ignore] + [ignore] + + + {"applicationName":"${applicationName}"} + + + + + + + ${CONSOLE_LOG_PATTERN} + utf8 + + + + + + + + + diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/ActuatorEndpointTest.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/ActuatorEndpointTest.kt new file mode 100644 index 00000000..dd70f168 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/ActuatorEndpointTest.kt @@ -0,0 +1,119 @@ +package io.github.mfvanek.spring.boot3.kotlin.test + +import io.github.mfvanek.spring.boot3.kotlin.test.support.TestBase +import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.springframework.boot.test.web.server.LocalManagementPort +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.MediaType +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.web.util.UriBuilder + +class ActuatorEndpointTest : TestBase() { + @LocalServerPort + private val port = 0 + + @LocalManagementPort + private val actuatorPort = 0 + + private lateinit var actuatorClient: WebTestClient + + @BeforeEach + fun setUp() { + this.actuatorClient = WebTestClient.bindToServer() + .baseUrl("http://localhost:$actuatorPort/actuator/") + .build() + } + + @Test + fun actuatorShouldBeRunOnSeparatePort() { + Assertions.assertThat(actuatorPort) + .isNotEqualTo(port) + } + + @ParameterizedTest + @CsvSource( + value = [ + "prometheus|jvm_threads_live_threads|text/plain", + "health|{\"status\":\"UP\",\"groups\":[\"liveness\",\"readiness\"]}|application/json", + "health/liveness|{\"status\":\"UP\"}|application/json", + "health/readiness|{\"status\":\"UP\"}|application/json", "info|\"version\":|application/json" + ], + delimiter = '|' + ) + fun actuatorEndpointShouldReturnOk( + endpointName: String, + expectedSubstring: String, + mediaType: String + ) { + val result = actuatorClient.get() + .uri { uriBuilder: UriBuilder -> + uriBuilder + .path(endpointName) + .build() + } + .accept(MediaType.valueOf(mediaType)) + .exchange() + .expectStatus().isOk() + .expectBody(String::class.java) + .returnResult() + .responseBody + assertThat(result) + .contains(expectedSubstring) + } + + @Test + fun swaggerUiEndpointShouldReturnFound() { + val result = actuatorClient.get() + .uri { uriBuilder: UriBuilder -> + uriBuilder + .pathSegment("swagger-ui") + .build() + } + .accept(MediaType.TEXT_HTML) + .exchange() + .expectStatus().isFound() + .expectHeader().location("/actuator/swagger-ui/index.html") + .expectBody() + .returnResult() + .responseBody + assertThat(result).isNull() + } + + @Test + fun readinessProbeShouldBeCollectedFromApplicationMainPort() { + val result = webTestClient.get() + .uri { uriBuilder: UriBuilder -> + uriBuilder + .pathSegment("readyz") + .build() + } + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody(String::class.java) + .returnResult() + .responseBody + assertThat(result) + .isEqualTo("{\"status\":\"UP\"}") + + val metricsResult = actuatorClient.get() + .uri { uriBuilder: UriBuilder -> + uriBuilder + .path("prometheus") + .build() + } + .accept(MediaType.valueOf("text/plain")) + .exchange() + .expectStatus().isOk() + .expectBody(String::class.java) + .returnResult() + .responseBody + assertThat(metricsResult) + .contains("http_server_requests_seconds_bucket") + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/ApplicationTests.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/ApplicationTests.kt new file mode 100644 index 00000000..92c3b07b --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/ApplicationTests.kt @@ -0,0 +1,74 @@ +package io.github.mfvanek.spring.boot3.kotlin.test + +import io.github.mfvanek.spring.boot3.kotlin.test.support.JaegerInitializer +import io.github.mfvanek.spring.boot3.kotlin.test.support.TestBase +import io.micrometer.observation.ObservationRegistry +import io.micrometer.tracing.Span +import io.micrometer.tracing.Tracer +import io.micrometer.tracing.otel.bridge.OtelTracer +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter +import java.util.Locale +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatNoException +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.assertj.core.api.ThrowingConsumer +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.dao.DataAccessResourceFailureException + +class ApplicationTests : TestBase() { + @Autowired + private lateinit var applicationContext: ApplicationContext + + @Test + fun contextLoads() { + assertThat(applicationContext.containsBean("otlpMeterRegistry")) + .isFalse() + assertThat(applicationContext.getBean(ObservationRegistry::class.java)) + .isNotNull() + .isInstanceOf(ObservationRegistry::class.java) + assertThat(applicationContext.getBean(Tracer::class.java)) + .isNotNull() + .isInstanceOf(OtelTracer::class.java) + .satisfies( + ThrowingConsumer { t: Tracer -> + assertThat(t.currentSpan()) + .isNotEqualTo(Span.NOOP) + } + ) + assertThat(applicationContext.getBean("otelJaegerGrpcSpanExporter")) + .isNotNull() + .isInstanceOf(OtlpGrpcSpanExporter::class.java) + .hasToString( + String.format( + Locale.ROOT, + """ + OtlpGrpcSpanExporter{exporterName=otlp, type=span, endpoint=http://localhost:%d, endpointPath=/opentelemetry.proto.collector.trace.v1.TraceService/Export, timeoutNanos=5000000000, connectTimeoutNanos=10000000000, compressorEncoding=null, headers=Headers{User-Agent=OBFUSCATED}, retryPolicy=RetryPolicy{maxAttempts=5, initialBackoff=PT1S, maxBackoff=PT5S, backoffMultiplier=1.5}, memoryMode=IMMUTABLE_DATA} + """.trimIndent(), + JaegerInitializer.getFirstMappedPort() + ) + ) + } + + @Test + fun jdbcQueryTimeoutFromProperties() { + assertThat(jdbcTemplate.queryTimeout) + .isEqualTo(1) + } + + @Test + @DisplayName("Throws exception when query exceeds timeout") + fun exceptionWithLongQuery() { + assertThatThrownBy { jdbcTemplate.execute("select pg_sleep(1.1);") } + .isInstanceOf(DataAccessResourceFailureException::class.java) + .hasMessageContaining("ERROR: canceling statement due to user request") + } + + @Test + @DisplayName("Does not throw exception when query does not exceed timeout") + fun exceptionNotThrownWithNotLongQuery() { + assertThatNoException().isThrownBy { jdbcTemplate.execute("select pg_sleep(0.9);") } + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/IndexesMaintenanceTest.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/IndexesMaintenanceTest.kt new file mode 100644 index 00000000..e188484e --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/IndexesMaintenanceTest.kt @@ -0,0 +1,39 @@ +package io.github.mfvanek.spring.boot3.kotlin.test + +import io.github.mfvanek.pg.core.checks.common.DatabaseCheckOnHost +import io.github.mfvanek.pg.core.checks.common.Diagnostic +import io.github.mfvanek.pg.model.context.PgContext +import io.github.mfvanek.pg.model.dbobject.DbObject +import io.github.mfvanek.pg.model.predicates.SkipLiquibaseTablesPredicate +import io.github.mfvanek.spring.boot3.kotlin.test.support.TestBase +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired + +class IndexesMaintenanceTest : TestBase() { + @Autowired + private lateinit var checks: List> + + @Test + @DisplayName("Always check PostgreSQL version in your tests") + fun checkPostgresVersion() { + val pgVersion = jdbcTemplate.queryForObject("select version();", String::class.java) + assertThat(pgVersion) + .startsWith("PostgreSQL 17.2") + } + + @Test + fun databaseStructureCheckForPublicSchema() { + assertThat(checks) + .hasSameSizeAs(Diagnostic.entries.toTypedArray()) + + checks + .filter { obj: DatabaseCheckOnHost? -> obj!!.isStatic } + .forEach { check: DatabaseCheckOnHost? -> + assertThat(check!!.check(PgContext.ofPublic(), SkipLiquibaseTablesPredicate.ofPublic())) + .`as`(check.diagnostic.name) + .isEmpty() + } + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/HomeControllerTest.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/HomeControllerTest.kt new file mode 100644 index 00000000..e9df263b --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/HomeControllerTest.kt @@ -0,0 +1,22 @@ +package io.github.mfvanek.spring.boot3.kotlin.test.controllers + +import io.github.mfvanek.spring.boot3.kotlin.test.filters.TraceIdInResponseServletFilter.Companion.TRACE_ID_HEADER_NAME +import io.github.mfvanek.spring.boot3.kotlin.test.support.TestBase +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class HomeControllerTest : TestBase() { + @Test + fun homeControllerShouldWork() { + val result = webTestClient.get() + .uri("/") + .exchange() + .expectStatus().isEqualTo(200) + .expectHeader().exists(TRACE_ID_HEADER_NAME) + .expectBody(String::class.java) + .returnResult() + .responseBody + assertThat(result) + .isEqualTo("Hello!") + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/RedirectControllerTest.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/RedirectControllerTest.kt new file mode 100644 index 00000000..4da36cc7 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/RedirectControllerTest.kt @@ -0,0 +1,23 @@ +package io.github.mfvanek.spring.boot3.kotlin.test.controllers + +import io.github.mfvanek.spring.boot3.kotlin.test.filters.TraceIdInResponseServletFilter.Companion.TRACE_ID_HEADER_NAME +import io.github.mfvanek.spring.boot3.kotlin.test.support.TestBase +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class RedirectControllerTest : TestBase() { + @Test + fun redirectShouldWork() { + val result = webTestClient.get() + .uri("/redirect") + .exchange() + .expectStatus().isEqualTo(303) + .expectHeader().exists(TRACE_ID_HEADER_NAME) + .expectHeader().location("https://www.google.com") + .expectBody(Any::class.java) + .returnResult() + .responseBody + assertThat(result) + .isNull() + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/TimeControllerTest.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/TimeControllerTest.kt new file mode 100644 index 00000000..b8da6b47 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/controllers/TimeControllerTest.kt @@ -0,0 +1,200 @@ +package io.github.mfvanek.spring.boot3.kotlin.test.controllers + +import io.github.mfvanek.spring.boot3.kotlin.test.filters.TraceIdInResponseServletFilter.Companion.TRACE_ID_HEADER_NAME +import io.github.mfvanek.spring.boot3.kotlin.test.service.dto.toParsedDateTime +import io.github.mfvanek.spring.boot3.kotlin.test.support.KafkaInitializer +import io.github.mfvanek.spring.boot3.kotlin.test.support.TestBase +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import java.util.Locale +import java.util.UUID +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import org.apache.kafka.clients.CommonClientConfigs +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.common.config.SaslConfigs +import org.apache.kafka.common.serialization.UUIDDeserializer +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.kafka.KafkaProperties +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.listener.ContainerProperties +import org.springframework.kafka.listener.KafkaMessageListenerContainer +import org.springframework.kafka.listener.MessageListener +import org.springframework.kafka.test.utils.ContainerTestUtils +import org.springframework.kafka.test.utils.KafkaTestUtils +import org.testcontainers.shaded.org.awaitility.Awaitility + +@ExtendWith(OutputCaptureExtension::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TimeControllerTest : TestBase() { + private lateinit var container: KafkaMessageListenerContainer + private val consumerRecords = LinkedBlockingQueue>() + + @Autowired + private lateinit var kafkaProperties: KafkaProperties + + @BeforeAll + fun setUpKafkaConsumer() { + container = setUpKafkaConsumer(kafkaProperties, consumerRecords) + } + + @AfterAll + fun tearDownKafkaConsumer() { + container.stop() + } + + @BeforeEach + fun cleanUpDatabase() { + jdbcTemplate.execute("truncate table otel_demo.storage") + } + + @Order(1) + @Test + fun spanShouldBeReportedInLogs(output: CapturedOutput) { + stubOkResponse((LocalDateTime.now(clock).minusDays(1)).toParsedDateTime()) + + val result = webTestClient.get() + .uri { uriBuilder -> uriBuilder.path("current-time").build() } + .exchange() + .expectStatus().isOk() + .expectHeader().exists(TRACE_ID_HEADER_NAME) + .expectBody(LocalDateTime::class.java) + .returnResult() + val traceId = result.responseHeaders.getFirst(TRACE_ID_HEADER_NAME) + assertThat(traceId).isNotBlank() + assertThat(result.responseBody) + .isBefore(LocalDateTime.now(clock)) + assertThat(output.all) + .contains("Called method getNow. TraceId = $traceId") + .contains("Awaiting acknowledgement from Kafka") + + val received = consumerRecords.poll(10, TimeUnit.SECONDS) + assertThat(received).isNotNull() + assertThatTraceIdPresentInKafkaHeaders(received!!, traceId!!) + + awaitStoringIntoDatabase() + + assertThat(output.all) + .contains("Received record: " + received.value() + " with traceId " + traceId) + .contains("\"tenant.name\":\"ru-a1-private\"") + val messageFromDb = namedParameterJdbcTemplate.queryForObject( + "select message from otel_demo.storage where trace_id = :traceId", + mapOf("traceId" to traceId), + String::class.java + ) + assertThat(messageFromDb).isEqualTo(received.value()) + } + + @Order(2) + @Test + fun spanAndMdcShouldBeReportedWhenRetry(output: CapturedOutput) { + val zoneName = stubErrorResponse() + + val result = webTestClient.get().uri { uriBuilder -> uriBuilder.path("current-time").build() } + .header("traceparent", "00-38c19768104ab8ae64fabbeed65bbbdf-4cac1747d4e1ee10-01") + .exchange() + .expectStatus().isOk() + .expectHeader().exists(TRACE_ID_HEADER_NAME) + .expectBody(LocalDateTime::class.java) + .returnResult() + val traceId = result.responseHeaders.getFirst(TRACE_ID_HEADER_NAME) + assertThat(traceId) + .isEqualTo("38c19768104ab8ae64fabbeed65bbbdf") + assertThat(output.all) + .containsPattern( + String.format( + Locale.ROOT, + ".*\"message\":\"Request to '/%s' failed after 2 attempts.\"," + + "\"logger\":\"io\\.github\\.mfvanek\\.spring\\.boot3\\.kotlin\\.test\\.service\\.PublicApiService\"," + + "\"thread\":\"[^\"]+\",\"level\":\"ERROR\"," + + "\"traceId\":\"38c19768104ab8ae64fabbeed65bbbdf\",\"spanId\":\"[a-f0-9]+\",\"applicationName\":\"spring-boot-3-demo-app\"\\}%n", + zoneName + ).toPattern() + ) + .containsPattern( + String.format( + Locale.ROOT, + ".*\"message\":\"Request to '/%s' failed after 2 attempts.\"," + + "\"logger\":\"io\\.github\\.mfvanek\\.spring\\.boot3\\.kotlin\\.test\\.service\\.PublicApiService\"," + + "\"thread\":\"[^\"]+\",\"level\":\"ERROR\"," + + "\"traceId\":\"38c19768104ab8ae64fabbeed65bbbdf\",\"spanId\":\"[a-f0-9]+\",\"applicationName\":\"spring-boot-3-demo-app\"\\}%n", + zoneName + ).toPattern() + ) + } + + @Order(3) + @Test + fun currentTimeReceivedFromClockWhenRemoteServiceGivesBadResponse() { + stubErrorResponse() + + val localDateTime = LocalDateTime.now(clock) + val result = webTestClient.get().uri { uriBuilder -> uriBuilder.path("current-time").build() } + .header("traceparent", "00-38c19768104ab8ae64fabbeed65bbbdf-4cac1747d4e1ee10-01") + .exchange() + .expectStatus().isOk() + .expectHeader().exists(TRACE_ID_HEADER_NAME) + .expectBody(LocalDateTime::class.java) + .returnResult() + + assertThat(result).isNotNull + assertThat(result.responseBody).isCloseTo(localDateTime, within(5, ChronoUnit.SECONDS)) + } + + private fun countRecordsInTable(): Long { + val queryResult = jdbcTemplate.queryForObject("select count(*) from otel_demo.storage", Long::class.java) + return queryResult ?: 0L + } + + private fun assertThatTraceIdPresentInKafkaHeaders(received: ConsumerRecord, expectedTraceId: String) { + assertThat(received.value()).startsWith("Current time = ") + val headers = received.headers().toArray() + val headerNames = headers.map { it.key() } + assertThat(headerNames) + .hasSize(2) + .containsExactlyInAnyOrder("traceparent", "b3") + val headerValues = headers + .map { String(it.value(), StandardCharsets.UTF_8) } + assertThat(headerValues) + .hasSameSizeAs(headerNames) + .allSatisfy { assertThat(it).contains(expectedTraceId) } + } + + private fun awaitStoringIntoDatabase() { + Awaitility + .await() + .atMost(10, TimeUnit.SECONDS) + .pollInterval(Duration.ofMillis(500L)) + .until { countRecordsInTable() >= 1L } + } +} + +private fun setUpKafkaConsumer(kafkaProperties: KafkaProperties, consumerRecords: BlockingQueue>): KafkaMessageListenerContainer { + val containerProperties = ContainerProperties(kafkaProperties.template.defaultTopic) + val consumerProperties = KafkaTestUtils.consumerProps(KafkaInitializer.getBootstrapSevers(), "test-group", "false") + consumerProperties[CommonClientConfigs.SECURITY_PROTOCOL_CONFIG] = "SASL_PLAINTEXT" + consumerProperties[SaslConfigs.SASL_MECHANISM] = "PLAIN" + consumerProperties[SaslConfigs.SASL_JAAS_CONFIG] = KafkaInitializer.plainJaas() + consumerProperties[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = UUIDDeserializer::class.java + val consumer = DefaultKafkaConsumerFactory(consumerProperties) + val container = KafkaMessageListenerContainer(consumer, containerProperties) + container.setupMessageListener(MessageListener { consumerRecords.add(it) }) + container.start() + ContainerTestUtils.waitForAssignment(container, 1) + return container +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/PublicApiServiceTest.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/PublicApiServiceTest.kt new file mode 100644 index 00000000..2dcf060b --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/service/PublicApiServiceTest.kt @@ -0,0 +1,111 @@ +package io.github.mfvanek.spring.boot3.kotlin.test.service + +import com.github.tomakehurst.wiremock.client.WireMock +import io.github.mfvanek.spring.boot3.kotlin.test.service.dto.toParsedDateTime +import io.github.mfvanek.spring.boot3.kotlin.test.support.TestBase +import io.micrometer.observation.Observation +import io.micrometer.observation.ObservationRegistry +import io.micrometer.tracing.Tracer +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import java.util.Locale +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.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension + +@ExtendWith(OutputCaptureExtension::class) +class PublicApiServiceTest : TestBase() { + @Autowired + private lateinit var publicApiService: PublicApiService + + @Autowired + private lateinit var tracer: Tracer + + @Autowired + private lateinit var observationRegistry: ObservationRegistry + + @Test + fun printTimeZoneSuccessfully(output: CapturedOutput) { + val localDateTimeNow = LocalDateTime.now(clock) + val zoneName = + stubOkResponse(localDateTimeNow.toParsedDateTime()) + + val result = publicApiService.getZonedTime() + WireMock.verify( + WireMock.getRequestedFor( + WireMock.urlPathMatching( + "/$zoneName" + ) + ) + ) + + assertThat(result).isNotNull() + assertThat(result!!.truncatedTo(ChronoUnit.MINUTES)) + .isEqualTo(localDateTimeNow.truncatedTo(ChronoUnit.MINUTES)) + assertThat(output.all) + .contains("Request received:") + .doesNotContain( + "Retrying request to ", + "Retries exhausted", + "Failed to convert response ", + "timezone" + ) + } + + @Test + fun retriesOnceToGetZonedTime(output: CapturedOutput) { + val zoneName = stubErrorResponse() + + Observation.createNotStarted("test", observationRegistry).observe { + val traceId = tracer.currentSpan()!!.context().traceId() + val result = publicApiService.getZonedTime() + assertThat(result).isNull() + assertThat(output.all) + .containsPattern( + String.format( + Locale.ROOT, + ".*\"message\":\"Retrying request to '[^']+?', attempt 1/1 due to error: .+?," + + "\"logger\":\"io\\.github\\.mfvanek\\.spring\\.boot3\\.kotlin\\.test\\.service\\.PublicApiService\"," + + "\"thread\":\"[^\"]+\",\"level\":\"INFO\"," + + "\"traceId\":\"%s\",\"spanId\":\"[a-f0-9]+\",\"instance_timezone\":\"%s\",\"applicationName\":\"spring-boot-3-demo-app\"\\}%n", + traceId, + zoneName + ) + .toPattern() + ) + .containsPattern( + String.format( + Locale.ROOT, + ".*\"message\":\"Request to '[^']+?' failed after 2 attempts.\"," + + "\"logger\":\"io\\.github\\.mfvanek\\.spring\\.boot3\\.kotlin\\.test\\.service\\.PublicApiService\"," + + "\"thread\":\"[^\"]+\",\"level\":\"ERROR\",\"traceId\":\"%s\",\"spanId\":\"[a-f0-9]+\",\"applicationName\":\"spring-boot-3-demo-app\"}%n", + traceId + ) + .toPattern() + ) + .doesNotContain("Failed to convert response ") + } + WireMock.verify( + 2, + WireMock.getRequestedFor( + WireMock.urlPathMatching( + "/$zoneName" + ) + ) + ) + } + + @Test + fun throwsJsonProcessingExceptionWithBdResponse(output: CapturedOutput) { + stubBadResponse() + Observation.createNotStarted("test", observationRegistry).observe { + val result = publicApiService.getZonedTime() + assertThat(result).isNull() + assertThat(tracer.currentSpan()?.context()?.traceId()).isNotNull() + assertThat(output.all).contains("Failed to convert response") + } + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/JaegerInitializer.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/JaegerInitializer.kt new file mode 100644 index 00000000..1f57e958 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/JaegerInitializer.kt @@ -0,0 +1,29 @@ +package io.github.mfvanek.spring.boot3.kotlin.test.support + +import org.springframework.boot.test.util.TestPropertyValues +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.ConfigurableApplicationContext +import org.testcontainers.containers.GenericContainer +import org.testcontainers.utility.DockerImageName + +class JaegerInitializer : ApplicationContextInitializer { + + override fun initialize(context: ConfigurableApplicationContext) { + JAEGER.start() + val jaegerUrl = "http://localhost:" + JAEGER.firstMappedPort + TestPropertyValues.of( + "management.otlp.tracing.endpoint=$jaegerUrl" + ).applyTo(context.environment) + } + + companion object { + @JvmStatic + private val IMAGE: DockerImageName = DockerImageName.parse("jaegertracing/all-in-one:1.53") + + @JvmStatic + private val JAEGER = GenericContainer(IMAGE).withExposedPorts(4317) + + @JvmStatic + fun getFirstMappedPort(): Int = JAEGER.firstMappedPort + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/KafkaInitializer.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/KafkaInitializer.kt new file mode 100644 index 00000000..747e27ac --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/KafkaInitializer.kt @@ -0,0 +1,44 @@ +package io.github.mfvanek.spring.boot3.kotlin.test.support + +import org.apache.kafka.common.security.plain.PlainLoginModule +import org.springframework.boot.test.util.TestPropertyValues.of +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.ConfigurableApplicationContext +import org.testcontainers.containers.KafkaContainer +import org.testcontainers.utility.DockerImageName + +private const val KAFKA_USER_NAME = "sb-ot-demo-user" +private const val KAFKA_USER_PASSWORD = "pwdForSbOtDemoApp" + +internal class KafkaInitializer : ApplicationContextInitializer { + + override fun initialize(applicationContext: ConfigurableApplicationContext) { + KAFKA_CONTAINER.start() + of( + "spring.kafka.bootstrap-servers=${KAFKA_CONTAINER.bootstrapServers}", + "demo.kafka.opentelemetry.username=$KAFKA_USER_NAME", + "demo.kafka.opentelemetry.password=$KAFKA_USER_PASSWORD" + ).applyTo(applicationContext.environment) + } + + companion object { + private val IMAGE_NAME = DockerImageName.parse("confluentinc/cp-kafka:7.7.1") + private val KAFKA_CONTAINER = KafkaContainer(IMAGE_NAME) + .withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "PLAINTEXT:SASL_PLAINTEXT,BROKER:PLAINTEXT") + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_SASL_ENABLED_MECHANISMS", "PLAIN") + .withEnv("KAFKA_SASL_JAAS_CONFIG", plainJaas()) + .withEnv( + "KAFKA_LISTENER_NAME_PLAINTEXT_PLAIN_SASL_JAAS_CONFIG", + plainJaas(mapOf(KAFKA_USER_NAME to KAFKA_USER_PASSWORD)) + ) + + internal fun plainJaas(additionalUsers: Map = mapOf()): String = + additionalUsers.entries + .joinToString(" ") { (key, value) -> "user_$key=\"$value\"" } + .let { + "${PlainLoginModule::class.java.name} required username=\"$KAFKA_USER_NAME\" password=\"$KAFKA_USER_PASSWORD\" $it;" + } + + internal fun getBootstrapSevers(): String = KAFKA_CONTAINER.bootstrapServers + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/PostgresInitializer.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/PostgresInitializer.kt new file mode 100644 index 00000000..03344ed1 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/PostgresInitializer.kt @@ -0,0 +1,39 @@ +package io.github.mfvanek.spring.boot3.kotlin.test.support + +import org.springframework.boot.test.util.TestPropertyValues +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.ConfigurableApplicationContext +import org.testcontainers.containers.Network +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.utility.DockerImageName + +class PostgresInitializer : ApplicationContextInitializer { + + override fun initialize(context: ConfigurableApplicationContext) { + CONTAINER + .withNetwork(NETWORK) + .withUsername("otel_demo_user") + .withPassword("otel_demo_password") + .withUrlParam("prepareThreshold", "0") + .waitingFor(Wait.forListeningPort()) + .start() + + TestPropertyValues.of( + "spring.datasource.url=${CONTAINER.jdbcUrl}", + "spring.datasource.username=${CONTAINER.username}", + "spring.datasource.password=${CONTAINER.password}" + ).applyTo(context.environment) + } + + companion object { + @JvmStatic + private val IMAGE = DockerImageName.parse("postgres:17.2") + + @JvmStatic + private val NETWORK = Network.newNetwork() + + @JvmStatic + private val CONTAINER = PostgreSQLContainer(IMAGE) + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/TestBase.kt b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/TestBase.kt new file mode 100644 index 00000000..c5ee1c91 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/kotlin/io/github/mfvanek/spring/boot3/kotlin/test/support/TestBase.kt @@ -0,0 +1,97 @@ +package io.github.mfvanek.spring.boot3.kotlin.test.support + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.tomakehurst.wiremock.client.WireMock +import io.github.mfvanek.spring.boot3.kotlin.test.service.dto.CurrentTime +import io.github.mfvanek.spring.boot3.kotlin.test.service.dto.ParsedDateTime +import org.junit.jupiter.api.BeforeEach +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.reactive.server.WebTestClient +import java.time.Clock +import java.util.* + +@ActiveProfiles("test") +@AutoConfigureObservability +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = [KafkaInitializer::class, JaegerInitializer::class, PostgresInitializer::class]) +@AutoConfigureWireMock(port = 0) +abstract class TestBase { + @Autowired + lateinit var webTestClient: WebTestClient + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Autowired + lateinit var clock: Clock + + @Autowired + protected lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + lateinit var namedParameterJdbcTemplate: NamedParameterJdbcTemplate + + @BeforeEach + fun resetExternalMocks() { + WireMock.resetAllRequests() + } + + protected fun stubOkResponse(parsedDateTime: ParsedDateTime): String { + val zoneName = TimeZone.getDefault().id + stubOkResponse(zoneName, parsedDateTime) + return zoneName + } + + private fun stubOkResponse(zoneName: String, parsedDateTime: ParsedDateTime) { + val currentTime = CurrentTime(parsedDateTime) + WireMock.stubFor( + WireMock.get(WireMock.urlPathMatching("/$zoneName")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withBody(objectMapper.writeValueAsString(currentTime)) + ) + ) + } + + protected fun stubErrorResponse(): String { + val zoneName = TimeZone.getDefault().id + val exception = RuntimeException("Retries exhausted") + stubErrorResponse(zoneName, exception) + return zoneName + } + + protected fun stubBadResponse(): String { + val zoneName = TimeZone.getDefault().id + stubBadResponse(zoneName) + return zoneName + } + + private fun stubErrorResponse(zoneName: String, errorForResponse: RuntimeException) { + WireMock.stubFor( + WireMock.get(WireMock.urlPathMatching("/$zoneName")) + .willReturn( + WireMock.aResponse() + .withStatus(500) + .withBody(objectMapper.writeValueAsString(errorForResponse)) + ) + ) + } + private fun stubBadResponse(zoneName: String) { + WireMock.stubFor( + WireMock.get(WireMock.urlPathMatching("/$zoneName")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withBody(objectMapper.writeValueAsString("Bad response")) + ) + ) + } +} diff --git a/spring-boot-3-demo-app-kotlin/src/test/resources/application-test.yml b/spring-boot-3-demo-app-kotlin/src/test/resources/application-test.yml new file mode 100644 index 00000000..ce32e2b5 --- /dev/null +++ b/spring-boot-3-demo-app-kotlin/src/test/resources/application-test.yml @@ -0,0 +1,11 @@ +app: + external-base-url: "http://localhost:${wiremock.server.port}/" + retries: 1 + +logging: +# appender: +# name: CONSOLE + level: + org.testcontainers: INFO # In order to troubleshoot issues with Testcontainers, increase the logging level to DEBUG + com.github.dockerjava: WARN + com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.wire: OFF diff --git a/spring-boot-3-demo-app/build.gradle.kts b/spring-boot-3-demo-app/build.gradle.kts index f7e2c5b3..2489e253 100644 --- a/spring-boot-3-demo-app/build.gradle.kts +++ b/spring-boot-3-demo-app/build.gradle.kts @@ -8,9 +8,9 @@ plugins { dependencies { implementation(platform(project(":common-internal-bom"))) - implementation(platform("org.springdoc:springdoc-openapi:2.8.6")) + implementation(platform(libs.springdoc.openapi)) implementation(platform(libs.spring.boot.v3.dependencies)) - implementation(platform("org.springframework.cloud:spring-cloud-dependencies:2024.0.1")) + implementation(platform(libs.spring.cloud)) implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-webflux") @@ -27,11 +27,13 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-jdbc") implementation("org.postgresql:postgresql") implementation("com.zaxxer:HikariCP") - implementation(project(":db-migrations")) + implementation(project(":db-migrations")) { + exclude(group = "io.gitlab.arturbosch.detekt") + } implementation("org.liquibase:liquibase-core") implementation("com.github.blagerweij:liquibase-sessionlock") - implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.0") - implementation("net.logstash.logback:logstash-logback-encoder:8.0") + implementation(libs.datasource.micrometer) + implementation(libs.logstash) testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-webflux")