Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ kotest = "5.9.1"
mockk = "1.13.13"
junit = "5.11.4"
ulid = "5.2.3"
micrometer = "1.13.7"
opentelemetry = "1.41.0"
ktlint = "1.5.0"
detekt = "1.23.8"
springdoc = "2.7.0"
Expand Down Expand Up @@ -62,9 +60,11 @@ mapstruct-core = { module = "org.mapstruct:mapstruct", version.ref = "mapstruct"
mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.ref = "mapstruct" }

# Observability
micrometer-core = { module = "io.micrometer:micrometer-core", version.ref = "micrometer" }
micrometer-registry-prometheus = { module = "io.micrometer:micrometer-registry-prometheus", version.ref = "micrometer" }
opentelemetry-api = { module = "io.opentelemetry:opentelemetry-api", version.ref = "opentelemetry" }
micrometer-core = { module = "io.micrometer:micrometer-core" }
micrometer-registry-prometheus = { module = "io.micrometer:micrometer-registry-prometheus" }
micrometer-tracing-bridge-otel = { module = "io.micrometer:micrometer-tracing-bridge-otel" }
opentelemetry-api = { module = "io.opentelemetry:opentelemetry-api" }
opentelemetry-exporter-otlp = { module = "io.opentelemetry:opentelemetry-exporter-otlp" }

# OpenAPI / Springdoc
springdoc-openapi-starter = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" }
Expand Down
2 changes: 2 additions & 0 deletions services/ledger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ dependencies {
runtimeOnly(libs.postgres.jdbc)

implementation(libs.micrometer.registry.prometheus)
implementation(libs.micrometer.tracing.bridge.otel)
implementation(libs.opentelemetry.api)
implementation(libs.opentelemetry.exporter.otlp)

testImplementation(project(":libs:fincore-test-support"))
testImplementation(libs.spring.boot.starter.test)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.api

import com.fincore.core.Currency
import com.fincore.ledger.application.AccountService
import com.fincore.ledger.application.CreateAccountCommand
import com.fincore.ledger.application.EntryLine
import com.fincore.ledger.application.PostTransactionCommand
import com.fincore.ledger.application.TransactionService
import com.fincore.ledger.domain.enum.AccountType
import com.fincore.ledger.domain.enum.EntryDirection
import com.fincore.ledger.infrastructure.persistence.OutboxEventRepository
import com.fincore.test.containers.PostgresContainerExtension
import io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.micrometer.tracing.Tracer
import org.junit.jupiter.api.AfterEach
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.actuate.observability.AutoConfigureObservability
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.core.env.Environment
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import java.math.BigDecimal
import java.time.Instant
import java.util.UUID

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureObservability
@ExtendWith(PostgresContainerExtension::class)
@Import(ObservabilityIT.TestSecurity::class)
class ObservabilityIT(
@Autowired private val rest: TestRestTemplate,
@Autowired private val accountService: AccountService,
@Autowired private val transactionService: TransactionService,
@Autowired private val outboxRepository: OutboxEventRepository,
@Autowired private val tracer: Tracer,
@Autowired private val environment: Environment,
) {
@TestConfiguration
class TestSecurity {
@Bean
fun jwtDecoder(): JwtDecoder =
JwtDecoder { token ->
Jwt
.withTokenValue(token)
.header("alg", "none")
.subject("observability-it")
.claim("scope", "ledger:read ledger:write")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS))
.build()
}
}

@AfterEach
fun cleanOutbox() {
outboxRepository.deleteAll()
}

@Test
fun `should expose the prometheus exposition without a token`() {
val response = rest.getForEntity("/actuator/prometheus", String::class.java)

response.statusCode.value() shouldBe OK
(response.body ?: "") shouldContain "# TYPE"
}

@Test
fun `should report the posted counter after a transaction is posted`() {
postBalanced()

val body = rest.getForEntity("/actuator/prometheus", String::class.java).body ?: ""

body shouldContain "ledger_transactions_posted_total"
postedCount(body) shouldBeGreaterThanOrEqual 1.0
}

@Test
fun `should expose a tracer bean and bind the tracing properties`() {
tracer.shouldNotBeNull()
environment.getProperty("management.tracing.sampling.probability").shouldNotBeNull()
(environment.getProperty("management.otlp.tracing.endpoint") ?: "") shouldContain "/v1/traces"
}

private fun postedCount(exposition: String): Double =
POSTED_PATTERN
.find(exposition)
?.groupValues
?.get(1)
?.toDouble() ?: 0.0

private fun postBalanced() {
val debit = accountService.create(CreateAccountCommand("obs-${UUID.randomUUID()}", AccountType.USER_WALLET, Currency.EUR, ACTOR))
val credit = accountService.create(CreateAccountCommand("obs-${UUID.randomUUID()}", AccountType.USER_WALLET, Currency.EUR, ACTOR))
transactionService.post(
PostTransactionCommand(
reference = "obs-${UUID.randomUUID()}",
description = null,
currency = Currency.EUR,
entries =
listOf(
EntryLine(debit.id, EntryDirection.DEBIT, BigDecimal(AMOUNT)),
EntryLine(credit.id, EntryDirection.CREDIT, BigDecimal(AMOUNT_NEG)),
),
actor = ACTOR,
correlationId = UUID.randomUUID().toString(),
),
)
}

private companion object {
const val EXPIRY_SECONDS = 3600L
const val OK = 200
const val ACTOR = "observability-it"
const val AMOUNT = "100.00"
const val AMOUNT_NEG = "-100.00"
val POSTED_PATTERN = Regex("""ledger_transactions_posted_total\{[^}]*type="post"[^}]*}\s+([0-9.eE+]+)""")

@JvmStatic
@DynamicPropertySource
fun datasourceProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { PostgresContainerExtension.jdbcUrl }
registry.add("spring.datasource.username") { PostgresContainerExtension.username }
registry.add("spring.datasource.password") { PostgresContainerExtension.password }
registry.add("spring.datasource.hikari.maximum-pool-size") { "2" }
registry.add("spring.jpa.hibernate.ddl-auto") { "none" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import java.time.Instant
AccountEntriesIT.JacksonConfig::class,
OutboxEventPublisherImpl::class,
AuditTrailWriterImpl::class,
MetricsTestConfig::class,
)
class AccountEntriesIT(
@Autowired private val entryQueryService: EntryQueryService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import java.util.concurrent.atomic.AtomicInteger
AuditTrailWriterImpl::class,
OutboxEventPublisherImpl::class,
AuditRetryTopologyIT.JacksonConfig::class,
MetricsTestConfig::class,
)
class AuditRetryTopologyIT(
@Autowired private val accountService: AccountService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import java.security.MessageDigest
AuditTrailWriterImpl::class,
OutboxEventPublisherImpl::class,
AuditWritePathIT.JacksonConfig::class,
MetricsTestConfig::class,
)
class AuditWritePathIT(
@Autowired private val accountService: AccountService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application

import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.simple.SimpleMeterRegistry
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean

@TestConfiguration
class MetricsTestConfig {
@Bean
fun meterRegistry(): MeterRegistry = SimpleMeterRegistry()

@Bean
fun ledgerMetrics(registry: MeterRegistry): LedgerMetrics = LedgerMetrics(registry)
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import java.time.Instant
TransactionBalanceServiceIT.JacksonConfig::class,
OutboxEventPublisherImpl::class,
AuditTrailWriterImpl::class,
MetricsTestConfig::class,
)
class TransactionBalanceServiceIT(
@Autowired private val transactionService: TransactionService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import java.math.BigDecimal
TransactionReversalIT.JacksonConfig::class,
OutboxEventPublisherImpl::class,
AuditTrailWriterImpl::class,
MetricsTestConfig::class,
)
class TransactionReversalIT(
@Autowired private val transactionService: TransactionService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import java.time.Instant
class BalanceServiceImpl(
private val balanceRepository: AccountBalanceRepository,
private val entryRepository: EntryRepository,
private val ledgerMetrics: LedgerMetrics,
) : BalanceService {
@Transactional(readOnly = true)
override fun current(
accountId: AccountId,
currency: Currency,
): AccountBalance {
val row = balanceRepository.findById(AccountBalanceKey(accountId.value, currency.code)).orElse(null)
ledgerMetrics.recordBalanceRead()
return if (row == null) {
AccountBalance(accountId, Money.zero(currency), null)
} else {
Expand All @@ -38,6 +40,7 @@ class BalanceServiceImpl(
instant: Instant,
): AccountBalance {
val sum = entryRepository.sumAmount(accountId.value, currency.code, instant)
ledgerMetrics.recordBalanceRead()
return AccountBalance(accountId, Money.of(sum, currency), null)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application

import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.MeterRegistry
import org.springframework.stereotype.Component

@Component
class LedgerMetrics(
registry: MeterRegistry,
) {
private val postedCounter: Counter =
Counter.builder(METRIC_POSTED).tag(TAG_TYPE, TYPE_POST).register(registry)
private val reversalCounter: Counter =
Counter.builder(METRIC_POSTED).tag(TAG_TYPE, TYPE_REVERSAL).register(registry)
private val balanceReadsCounter: Counter =
Counter.builder(METRIC_BALANCE_READS).register(registry)

fun recordPost() = postedCounter.increment()

fun recordReversal() = reversalCounter.increment()

fun recordBalanceRead() = balanceReadsCounter.increment()

private companion object {
const val METRIC_POSTED = "ledger.transactions.posted"
const val METRIC_BALANCE_READS = "ledger.balance.reads"
const val TAG_TYPE = "type"
const val TYPE_POST = "post"
const val TYPE_REVERSAL = "reversal"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import java.time.Instant
import java.util.UUID

@Component
@Suppress("LongParameterList")
class TransactionPoster(
private val accountRepository: AccountRepository,
private val transactionRepository: TransactionRepository,
Expand All @@ -47,6 +48,7 @@ class TransactionPoster(
private val outboxEventPublisher: OutboxEventPublisher,
private val auditWriter: AuditTrailWriter,
private val adapter: TransactionPersistenceAdapter,
private val ledgerMetrics: LedgerMetrics,
) {
@Transactional
fun post(command: PostTransactionCommand): PostedTransaction {
Expand All @@ -70,6 +72,7 @@ class TransactionPoster(
requestHash = command.requestHash,
),
)
ledgerMetrics.recordPost()
return PostedTransaction(transaction.id, transaction.reference, transaction.status, postedAt)
}

Expand All @@ -96,6 +99,7 @@ class TransactionPoster(
throw TransactionAlreadyReversedException(originalId, conflict)
}
recordReversalAudit(originalId, actor, requestHash, reason, compensating.id.toString())
ledgerMetrics.recordReversal()
return PostedTransaction(compensating.id, compensating.reference, compensating.status, postedAt)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class SecurityConfig {
"/swagger-ui.html",
"/actuator/health",
"/actuator/health/**",
"/actuator/prometheus",
)
}
}
8 changes: 8 additions & 0 deletions services/ledger/src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ spring:
resourceserver:
jwt:
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8081/realms/fincore}
logging:
structured:
format:
console: ""
management:
tracing:
sampling:
probability: 1.0
4 changes: 4 additions & 0 deletions services/ledger/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ spring:
resourceserver:
jwt:
issuer-uri: ${KEYCLOAK_ISSUER_URI}
management:
tracing:
sampling:
probability: ${TRACING_SAMPLING_PROBABILITY:0.1}
3 changes: 3 additions & 0 deletions services/ledger/src/main/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ spring:
resourceserver:
jwt:
issuer-uri: http://localhost/realms/test
management:
tracing:
enabled: false
14 changes: 14 additions & 0 deletions services/ledger/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,22 @@ springdoc:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
logging:
structured:
format:
console: logstash
management:
endpoints:
web:
exposure:
include: health,info,prometheus
metrics:
distribution:
percentiles-histogram:
http.server.requests: true
tracing:
sampling:
probability: 0.1
otlp:
tracing:
endpoint: ${OTLP_TRACING_ENDPOINT:http://localhost:4318/v1/traces}
Loading
Loading