feat(api): publish business metrics

This commit is contained in:
Gauthier Roebroeck 2022-02-10 16:21:23 +08:00
parent a8340e816b
commit 78174db6fb
15 changed files with 180 additions and 0 deletions

View file

@ -1,5 +1,6 @@
package org.gotson.komga.application.tasks package org.gotson.komga.application.tasks
import io.micrometer.core.instrument.MeterRegistry
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.LibraryRepository
@ -16,10 +17,13 @@ import org.gotson.komga.domain.service.SeriesMetadataLifecycle
import org.gotson.komga.infrastructure.jms.QUEUE_FACTORY import org.gotson.komga.infrastructure.jms.QUEUE_FACTORY
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS import org.gotson.komga.infrastructure.jms.QUEUE_TASKS
import org.gotson.komga.infrastructure.search.SearchIndexLifecycle import org.gotson.komga.infrastructure.search.SearchIndexLifecycle
import org.gotson.komga.interfaces.scheduler.METER_TASKS_EXECUTION
import org.gotson.komga.interfaces.scheduler.METER_TASKS_FAILURE
import org.springframework.jms.annotation.JmsListener import org.springframework.jms.annotation.JmsListener
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.nio.file.Paths import java.nio.file.Paths
import kotlin.time.measureTime import kotlin.time.measureTime
import kotlin.time.toJavaDuration
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -39,6 +43,7 @@ class TaskHandler(
private val bookConverter: BookConverter, private val bookConverter: BookConverter,
private val bookPageEditor: BookPageEditor, private val bookPageEditor: BookPageEditor,
private val searchIndexLifecycle: SearchIndexLifecycle, private val searchIndexLifecycle: SearchIndexLifecycle,
private val meterRegistry: MeterRegistry,
) { ) {
@JmsListener(destination = QUEUE_TASKS, containerFactory = QUEUE_FACTORY, concurrency = "#{@komgaProperties.taskConsumers}-#{@komgaProperties.taskConsumersMax}") @JmsListener(destination = QUEUE_TASKS, containerFactory = QUEUE_FACTORY, concurrency = "#{@komgaProperties.taskConsumers}-#{@komgaProperties.taskConsumersMax}")
@ -151,9 +156,11 @@ class TaskHandler(
} }
}.also { }.also {
logger.info { "Task $task executed in $it" } logger.info { "Task $task executed in $it" }
meterRegistry.timer(METER_TASKS_EXECUTION, "type", task.javaClass.simpleName).record(it.toJavaDuration())
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { "Task $task execution failed" } logger.error(e) { "Task $task execution failed" }
meterRegistry.counter(METER_TASKS_FAILURE, "type", task.javaClass.simpleName).increment()
} }
} }
} }

View file

@ -8,6 +8,7 @@ sealed class DomainEvent : Serializable {
data class LibraryAdded(val library: Library) : DomainEvent() data class LibraryAdded(val library: Library) : DomainEvent()
data class LibraryUpdated(val library: Library) : DomainEvent() data class LibraryUpdated(val library: Library) : DomainEvent()
data class LibraryDeleted(val library: Library) : DomainEvent() data class LibraryDeleted(val library: Library) : DomainEvent()
data class LibraryScanned(val library: Library) : DomainEvent()
data class SeriesAdded(val series: Series) : DomainEvent() data class SeriesAdded(val series: Series) : DomainEvent()
data class SeriesUpdated(val series: Series) : DomainEvent() data class SeriesUpdated(val series: Series) : DomainEvent()

View file

@ -5,6 +5,7 @@ import org.gotson.komga.domain.model.BookSearch
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import java.math.BigDecimal
import java.net.URL import java.net.URL
interface BookRepository { interface BookRepository {
@ -43,4 +44,7 @@ interface BookRepository {
fun deleteAll() fun deleteAll()
fun count(): Long fun count(): Long
fun countGroupedByLibraryName(): Map<String, Int>
fun getFilesizeGroupedByLibraryName(): Map<String, BigDecimal>
} }

View file

@ -42,4 +42,6 @@ interface ReadListRepository {
fun deleteAll() fun deleteAll()
fun existsByName(name: String): Boolean fun existsByName(name: String): Boolean
fun count(): Long
} }

View file

@ -42,4 +42,6 @@ interface SeriesCollectionRepository {
fun deleteAll() fun deleteAll()
fun existsByName(name: String): Boolean fun existsByName(name: String): Boolean
fun count(): Long
} }

View file

@ -26,4 +26,5 @@ interface SeriesRepository {
fun deleteAll() fun deleteAll()
fun count(): Long fun count(): Long
fun countGroupedByLibraryName(): Map<String, Int>
} }

View file

@ -11,4 +11,6 @@ interface SidecarRepository {
fun deleteByLibraryIdAndUrls(libraryId: String, urls: Collection<URL>) fun deleteByLibraryIdAndUrls(libraryId: String, urls: Collection<URL>)
fun deleteByLibraryId(libraryId: String) fun deleteByLibraryId(libraryId: String)
fun countGroupedByLibraryName(): Map<String, Int>
} }

View file

@ -231,6 +231,8 @@ class LibraryContentLifecycle(
if (library.emptyTrashAfterScan) emptyTrash(library) if (library.emptyTrashAfterScan) emptyTrash(library)
else cleanupEmptySets() else cleanupEmptySets()
}.also { logger.info { "Library updated in $it" } } }.also { logger.info { "Library updated in $it" } }
eventPublisher.publishEvent(DomainEvent.LibraryScanned(library))
} }
/** /**

View file

@ -16,6 +16,7 @@ import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.net.URL import java.net.URL
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@ -29,6 +30,7 @@ class BookDao(
private val m = Tables.MEDIA private val m = Tables.MEDIA
private val d = Tables.BOOK_METADATA private val d = Tables.BOOK_METADATA
private val r = Tables.READ_PROGRESS private val r = Tables.READ_PROGRESS
private val l = Tables.LIBRARY
private val sorts = mapOf( private val sorts = mapOf(
"createdDate" to b.CREATED_DATE, "createdDate" to b.CREATED_DATE,
@ -314,6 +316,20 @@ class BookDao(
override fun count(): Long = dsl.fetchCount(b).toLong() override fun count(): Long = dsl.fetchCount(b).toLong()
override fun countGroupedByLibraryName(): Map<String, Int> =
dsl.select(l.NAME, DSL.count(b.ID))
.from(l)
.leftJoin(b).on(l.ID.eq(b.LIBRARY_ID))
.groupBy(l.NAME)
.fetchMap(l.NAME, DSL.count(b.ID))
override fun getFilesizeGroupedByLibraryName(): Map<String, BigDecimal> =
dsl.select(l.NAME, DSL.sum(b.FILE_SIZE))
.from(l)
.leftJoin(b).on(l.ID.eq(b.LIBRARY_ID))
.groupBy(l.NAME)
.fetchMap(l.NAME, DSL.sum(b.FILE_SIZE))
private fun BookSearch.toCondition(): Condition { private fun BookSearch.toCondition(): Condition {
var c: Condition = DSL.trueCondition() var c: Condition = DSL.trueCondition()

View file

@ -247,6 +247,8 @@ class ReadListDao(
.where(rl.NAME.equalIgnoreCase(name)), .where(rl.NAME.equalIgnoreCase(name)),
) )
override fun count(): Long = dsl.fetchCount(rl).toLong()
private fun ReadlistRecord.toDomain(bookIds: SortedMap<Int, String>) = private fun ReadlistRecord.toDomain(bookIds: SortedMap<Int, String>) =
ReadList( ReadList(
name = name, name = name,

View file

@ -247,6 +247,8 @@ class SeriesCollectionDao(
.where(c.NAME.equalIgnoreCase(name)), .where(c.NAME.equalIgnoreCase(name)),
) )
override fun count(): Long = dsl.fetchCount(c).toLong()
private fun CollectionRecord.toDomain(seriesIds: List<String>) = private fun CollectionRecord.toDomain(seriesIds: List<String>) =
SeriesCollection( SeriesCollection(
name = name, name = name,

View file

@ -24,6 +24,7 @@ class SeriesDao(
private val s = Tables.SERIES private val s = Tables.SERIES
private val d = Tables.SERIES_METADATA private val d = Tables.SERIES_METADATA
private val cs = Tables.COLLECTION_SERIES private val cs = Tables.COLLECTION_SERIES
private val l = Tables.LIBRARY
override fun findAll(): Collection<Series> = override fun findAll(): Collection<Series> =
dsl.selectFrom(s) dsl.selectFrom(s)
@ -136,6 +137,13 @@ class SeriesDao(
override fun count(): Long = dsl.fetchCount(s).toLong() override fun count(): Long = dsl.fetchCount(s).toLong()
override fun countGroupedByLibraryName(): Map<String, Int> =
dsl.select(l.NAME, DSL.count(s.ID))
.from(l)
.leftJoin(s).on(l.ID.eq(s.LIBRARY_ID))
.groupBy(l.NAME)
.fetchMap(l.NAME, DSL.count(s.ID))
private fun SeriesSearch.toCondition(): Condition { private fun SeriesSearch.toCondition(): Condition {
var c: Condition = DSL.trueCondition() var c: Condition = DSL.trueCondition()

View file

@ -6,6 +6,7 @@ import org.gotson.komga.domain.persistence.SidecarRepository
import org.gotson.komga.jooq.Tables import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.SidecarRecord import org.gotson.komga.jooq.tables.records.SidecarRecord
import org.jooq.DSLContext import org.jooq.DSLContext
import org.jooq.impl.DSL
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@ -17,6 +18,7 @@ class SidecarDao(
@Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, @Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int,
) : SidecarRepository { ) : SidecarRepository {
private val sc = Tables.SIDECAR private val sc = Tables.SIDECAR
private val l = Tables.LIBRARY
override fun findAll(): Collection<SidecarStored> = override fun findAll(): Collection<SidecarStored> =
dsl.selectFrom(sc).fetch().map { it.toDomain() } dsl.selectFrom(sc).fetch().map { it.toDomain() }
@ -52,6 +54,13 @@ class SidecarDao(
.execute() .execute()
} }
override fun countGroupedByLibraryName(): Map<String, Int> =
dsl.select(l.NAME, DSL.count(sc.URL))
.from(l)
.leftJoin(sc).on(l.ID.eq(sc.LIBRARY_ID))
.groupBy(l.NAME)
.fetchMap(l.NAME, DSL.count(sc.URL))
private fun SidecarRecord.toDomain() = private fun SidecarRecord.toDomain() =
SidecarStored( SidecarStored(
url = URL(url), url = URL(url),

View file

@ -0,0 +1,121 @@
package org.gotson.komga.interfaces.scheduler
import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.Gauge
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.MultiGauge
import io.micrometer.core.instrument.MultiGauge.Row
import io.micrometer.core.instrument.Tags
import io.micrometer.core.instrument.Timer
import mu.KotlinLogging
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.persistence.SidecarRepository
import org.gotson.komga.infrastructure.jms.TOPIC_EVENTS
import org.gotson.komga.infrastructure.jms.TOPIC_FACTORY
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.jms.annotation.JmsListener
import org.springframework.stereotype.Component
import java.util.concurrent.atomic.AtomicLong
private val logger = KotlinLogging.logger {}
private const val LIBRARIES = "libraries"
private const val SERIES = "series"
private const val BOOKS = "books"
private const val BOOKS_FILESIZE = "books.filesize"
private const val COLLECTIONS = "collections"
private const val READLISTS = "readlists"
private const val SIDECARS = "sidecars"
const val METER_TASKS_EXECUTION = "komga.tasks.execution"
const val METER_TASKS_FAILURE = "komga.tasks.failure"
@Component
class MetricsPublisherController(
private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository,
private val seriesRepository: SeriesRepository,
private val collectionRepository: SeriesCollectionRepository,
private val readListRepository: ReadListRepository,
private val sidecarRepository: SidecarRepository,
private val meterRegistry: MeterRegistry,
) {
init {
Timer.builder(METER_TASKS_EXECUTION)
.description("Task execution time")
.register(meterRegistry)
Counter.builder(METER_TASKS_FAILURE)
.description("Count of failed tasks")
.register(meterRegistry)
}
private final val entitiesMultiTag = listOf(SERIES, BOOKS, BOOKS_FILESIZE, SIDECARS)
private final val entitiesNoTags = listOf(LIBRARIES, COLLECTIONS, READLISTS)
private final val allEntities = entitiesMultiTag + entitiesNoTags
val multiGauges = entitiesMultiTag.associateWith { entity ->
MultiGauge.builder("komga.$entity")
.description("The number of $entity")
.baseUnit("count")
.register(meterRegistry)
}
val noTagGauges = entitiesNoTags.associateWith { entity ->
AtomicLong(0).also { value ->
Gauge.builder("komga.$entity", value) { value.get().toDouble() }
.description("The number of $entity")
.baseUnit("count")
.register(meterRegistry)
}
}
val bookFileSizeGauge = MultiGauge.builder("komga.$BOOKS_FILESIZE")
.description("The cumulated filesize of books")
.baseUnit("bytes")
.register(meterRegistry)
@JmsListener(destination = TOPIC_EVENTS, containerFactory = TOPIC_FACTORY)
private fun pushMetricsOnEvent(event: DomainEvent) {
when (event) {
is DomainEvent.LibraryScanned -> entitiesMultiTag.forEach { pushMetricsCount(it) }
is DomainEvent.LibraryAdded -> noTagGauges[LIBRARIES]?.incrementAndGet()
is DomainEvent.LibraryDeleted -> {
noTagGauges[LIBRARIES]?.decrementAndGet()
entitiesMultiTag.forEach { pushMetricsCount(it) }
}
is DomainEvent.CollectionAdded -> noTagGauges[COLLECTIONS]?.incrementAndGet()
is DomainEvent.CollectionDeleted -> noTagGauges[COLLECTIONS]?.decrementAndGet()
is DomainEvent.ReadListAdded -> noTagGauges[READLISTS]?.incrementAndGet()
is DomainEvent.ReadListDeleted -> noTagGauges[READLISTS]?.decrementAndGet()
else -> Unit
}
}
@EventListener(ApplicationReadyEvent::class)
fun pushAllMetrics() {
allEntities.forEach { pushMetricsCount(it) }
}
private fun pushMetricsCount(entity: String) {
when (entity) {
LIBRARIES -> noTagGauges[LIBRARIES]?.set(libraryRepository.count())
COLLECTIONS -> noTagGauges[COLLECTIONS]?.set(collectionRepository.count())
READLISTS -> noTagGauges[READLISTS]?.set(readListRepository.count())
SERIES -> multiGauges[SERIES]?.register(seriesRepository.countGroupedByLibraryName().map { Row.of(Tags.of("library", it.key), it.value) })
BOOKS -> multiGauges[BOOKS]?.register(bookRepository.countGroupedByLibraryName().map { Row.of(Tags.of("library", it.key), it.value) })
BOOKS_FILESIZE -> bookFileSizeGauge.register(bookRepository.getFilesizeGroupedByLibraryName().map { Row.of(Tags.of("library", it.key), it.value) })
SIDECARS -> multiGauges[SIDECARS]?.register(sidecarRepository.countGroupedByLibraryName().map { Row.of(Tags.of("library", it.key), it.value) })
}
}
}

View file

@ -77,6 +77,7 @@ class SseController(
is DomainEvent.LibraryAdded -> emitSse("LibraryAdded", LibrarySseDto(event.library.id)) is DomainEvent.LibraryAdded -> emitSse("LibraryAdded", LibrarySseDto(event.library.id))
is DomainEvent.LibraryUpdated -> emitSse("LibraryChanged", LibrarySseDto(event.library.id)) is DomainEvent.LibraryUpdated -> emitSse("LibraryChanged", LibrarySseDto(event.library.id))
is DomainEvent.LibraryDeleted -> emitSse("LibraryDeleted", LibrarySseDto(event.library.id)) is DomainEvent.LibraryDeleted -> emitSse("LibraryDeleted", LibrarySseDto(event.library.id))
is DomainEvent.LibraryScanned -> Unit
is DomainEvent.SeriesAdded -> emitSse("SeriesAdded", SeriesSseDto(event.series.id, event.series.libraryId)) is DomainEvent.SeriesAdded -> emitSse("SeriesAdded", SeriesSseDto(event.series.id, event.series.libraryId))
is DomainEvent.SeriesUpdated -> emitSse("SeriesChanged", SeriesSseDto(event.series.id, event.series.libraryId)) is DomainEvent.SeriesUpdated -> emitSse("SeriesChanged", SeriesSseDto(event.series.id, event.series.libraryId))