add Film observability with trace id and metrics #44
@@ -3,6 +3,8 @@ package com.project.movienight.adapters.web
|
|||||||
import com.project.movienight.domain.exception.BlockedValueException
|
import com.project.movienight.domain.exception.BlockedValueException
|
||||||
import com.project.movienight.domain.exception.DomainException
|
import com.project.movienight.domain.exception.DomainException
|
||||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.slf4j.MDC
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
@@ -10,22 +12,60 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
|
|||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
class ApiExceptionHandler {
|
class ApiExceptionHandler {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
@ExceptionHandler(EntityNotFoundException::class)
|
@ExceptionHandler(EntityNotFoundException::class)
|
||||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||||
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse =
|
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Entity not found")
|
val traceId = currentTraceId()
|
||||||
|
log.warn("Entity not found: traceId='{}', message='{}'", traceId, exception.message)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = exception.message ?: "Entity not found",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(BlockedValueException::class)
|
@ExceptionHandler(BlockedValueException::class)
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse =
|
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Blocked value")
|
val traceId = currentTraceId()
|
||||||
|
log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = exception.message ?: "Blocked value",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(DomainException::class)
|
@ExceptionHandler(DomainException::class)
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
fun handleDomainException(exception: DomainException): ErrorResponse =
|
fun handleDomainException(exception: DomainException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Domain error")
|
val traceId = currentTraceId()
|
||||||
|
log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = exception.message ?: "Domain error",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception::class)
|
||||||
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
fun handleUnexpectedException(exception: Exception): ErrorResponse {
|
||||||
|
val traceId = currentTraceId()
|
||||||
|
log.error("Unexpected error: traceId='{}'", traceId, exception)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = "Internal server error",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ErrorResponse(
|
data class ErrorResponse(
|
||||||
val message: String,
|
val message: String,
|
||||||
|
val traceId: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import org.slf4j.MDC
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class TraceIdFilter : OncePerRequestFilter() {
|
||||||
|
override fun doFilterInternal(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
filterChain: FilterChain,
|
||||||
|
) {
|
||||||
|
val traceId = UUID.randomUUID().toString()
|
||||||
|
MDC.put("traceId", traceId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
} finally {
|
||||||
|
MDC.remove("traceId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ import com.project.movienight.config.FilmServiceProperties
|
|||||||
import com.project.movienight.domain.exception.BlockedValueException
|
import com.project.movienight.domain.exception.BlockedValueException
|
||||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
import com.project.movienight.domain.model.Film
|
import com.project.movienight.domain.model.Film
|
||||||
|
import io.micrometer.core.instrument.Counter
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry
|
||||||
|
import io.micrometer.core.instrument.Timer
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -22,48 +26,117 @@ class FilmService(
|
|||||||
private val filmRepository: FilmRepositoryPort,
|
private val filmRepository: FilmRepositoryPort,
|
||||||
private val idGenerator: IdGenerator,
|
private val idGenerator: IdGenerator,
|
||||||
private val filmConfig: FilmServiceProperties,
|
private val filmConfig: FilmServiceProperties,
|
||||||
|
private val meterRegistry: MeterRegistry,
|
||||||
) : CreateFilmUseCase,
|
) : CreateFilmUseCase,
|
||||||
EditFilmUseCase,
|
EditFilmUseCase,
|
||||||
DeleteFilmUseCase,
|
DeleteFilmUseCase,
|
||||||
GetFilmByIdUseCase,
|
GetFilmByIdUseCase,
|
||||||
GetAllFilmsUseCase,
|
GetAllFilmsUseCase,
|
||||||
SearchFilmByTitleUseCase {
|
SearchFilmByTitleUseCase {
|
||||||
override fun create(command: CreateFilmCommand): Film {
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
if (filmConfig.isBlocked(command.title)) {
|
|
||||||
throw BlockedValueException(target = "Film", field = "title")
|
|
||||||
}
|
|
||||||
if (filmConfig.isBlocked(command.description)) {
|
|
||||||
throw BlockedValueException(target = "Film", field = "description")
|
|
||||||
}
|
|
||||||
|
|
||||||
val film =
|
override fun create(command: CreateFilmCommand): Film {
|
||||||
Film(
|
val sample = Timer.start(meterRegistry)
|
||||||
id = idGenerator.generateId(),
|
|
||||||
title = command.title,
|
try {
|
||||||
description = command.description,
|
log.debug(
|
||||||
|
"Create film request received: title='{}', descriptionLength={}",
|
||||||
|
command.title,
|
||||||
|
command.description.length,
|
||||||
)
|
)
|
||||||
return filmRepository.save(film)
|
|
||||||
|
if (filmConfig.isBlocked(command.title)) {
|
||||||
|
log.debug("Create film blocked by title policy: title='{}'", command.title)
|
||||||
|
filmBlockedCounter.increment()
|
||||||
|
throw BlockedValueException(target = "Film", field = "title")
|
||||||
|
}
|
||||||
|
if (filmConfig.isBlocked(command.description)) {
|
||||||
|
log.debug("Create film blocked by description policy")
|
||||||
|
filmBlockedCounter.increment()
|
||||||
|
throw BlockedValueException(target = "Film", field = "description")
|
||||||
|
}
|
||||||
|
|
||||||
|
val film =
|
||||||
|
Film(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
title = command.title,
|
||||||
|
description = command.description,
|
||||||
|
)
|
||||||
|
val saved = filmRepository.save(film)
|
||||||
|
|
||||||
|
filmCreatedCounter.increment()
|
||||||
|
|
||||||
|
log.info("Film created: id='{}', title='{}'", saved.id, saved.title)
|
||||||
|
return saved
|
||||||
|
} finally {
|
||||||
|
sample.stop(createFilmTimer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun edit(
|
override fun edit(
|
||||||
id: UUID,
|
id: UUID,
|
||||||
command: EditFilmCommand,
|
command: EditFilmCommand,
|
||||||
): Film {
|
): Film {
|
||||||
if (filmConfig.isBlocked(command.title)) {
|
val sample = Timer.start(meterRegistry)
|
||||||
throw BlockedValueException(target = "Film", field = "title")
|
|
||||||
}
|
|
||||||
if (filmConfig.isBlocked(command.description)) {
|
|
||||||
throw BlockedValueException(target = "Film", field = "description")
|
|
||||||
}
|
|
||||||
|
|
||||||
var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
|
try {
|
||||||
film = film.copy(title = command.title, description = command.description)
|
log.debug("Edit film with id: {}", id)
|
||||||
return filmRepository.save(film)
|
|
||||||
|
if (filmConfig.isBlocked(command.title)) {
|
||||||
|
log.debug("Edit film blocked by title policy: title='{}'", command.title)
|
||||||
|
filmBlockedCounter.increment()
|
||||||
|
throw BlockedValueException(target = "Film", field = "title")
|
||||||
|
}
|
||||||
|
if (filmConfig.isBlocked(command.description)) {
|
||||||
|
log.debug("Edit film blocked by description policy")
|
||||||
|
filmBlockedCounter.increment()
|
||||||
|
throw BlockedValueException(target = "Film", field = "description")
|
||||||
|
}
|
||||||
|
|
||||||
|
val film = filmRepository.findById(id)
|
||||||
|
|
||||||
|
if (film == null) {
|
||||||
|
log.debug("Film not found for edit: id='{}'", id)
|
||||||
|
throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
val updatedFilm =
|
||||||
|
film.copy(
|
||||||
|
title = command.title,
|
||||||
|
description = command.description,
|
||||||
|
)
|
||||||
|
val saved = filmRepository.save(updatedFilm)
|
||||||
|
|
||||||
|
filmEditedCounter.increment()
|
||||||
|
|
||||||
|
log.info("Film edited: id='{}'", saved.id)
|
||||||
|
return saved
|
||||||
|
} finally {
|
||||||
|
sample.stop(editFilmTimer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(id: UUID) {
|
override fun delete(id: UUID) {
|
||||||
filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
|
val sample = Timer.start(meterRegistry)
|
||||||
filmRepository.deleteById(id)
|
|
||||||
|
try {
|
||||||
|
log.debug("Delete film with id: {}", id)
|
||||||
|
|
||||||
|
val film = filmRepository.findById(id)
|
||||||
|
|
||||||
|
if (film == null) {
|
||||||
|
log.debug("Film not found for delete: id='{}'", id)
|
||||||
|
throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
filmRepository.deleteById(id)
|
||||||
|
|
||||||
|
filmDeletedCounter.increment()
|
||||||
|
|
||||||
|
log.info("Film deleted: id='{}'", id)
|
||||||
|
} finally {
|
||||||
|
sample.stop(deleteFilmTimer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getById(id: UUID): Film =
|
override fun getById(id: UUID): Film =
|
||||||
@@ -72,4 +145,46 @@ class FilmService(
|
|||||||
override fun getAll(): List<Film> = filmRepository.findAll()
|
override fun getAll(): List<Film> = filmRepository.findAll()
|
||||||
|
|
||||||
override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title)
|
override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title)
|
||||||
|
|
||||||
|
private val filmCreatedCounter =
|
||||||
|
Counter
|
||||||
|
.builder("film_created_total")
|
||||||
|
.description("Total number of created films")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val filmEditedCounter =
|
||||||
|
Counter
|
||||||
|
.builder("film_edited_total")
|
||||||
|
.description("Total number of successfully edited films")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val filmDeletedCounter =
|
||||||
|
Counter
|
||||||
|
.builder("film_deleted_total")
|
||||||
|
.description("Total number of successfully deleted films")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val filmBlockedCounter =
|
||||||
|
Counter
|
||||||
|
.builder("films.blocked")
|
||||||
|
.description("Total blocked film operations")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val createFilmTimer =
|
||||||
|
Timer
|
||||||
|
.builder("films.create.duration")
|
||||||
|
.description("Film creation duration")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val editFilmTimer =
|
||||||
|
Timer
|
||||||
|
.builder("films.edit.duration")
|
||||||
|
.description("Film edit duration")
|
||||||
|
.register(meterRegistry)
|
||||||
|
|
||||||
|
private val deleteFilmTimer =
|
||||||
|
Timer
|
||||||
|
.builder("films.delete.duration")
|
||||||
|
.description("Film deletion duration")
|
||||||
|
.register(meterRegistry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,3 +74,6 @@ services:
|
|||||||
- censored
|
- censored
|
||||||
- epstein
|
- epstein
|
||||||
- python
|
- python
|
||||||
|
logging:
|
||||||
|
pattern:
|
||||||
|
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.project.movienight.config.FilmServiceProperties
|
|||||||
import com.project.movienight.domain.exception.BlockedValueException
|
import com.project.movienight.domain.exception.BlockedValueException
|
||||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
import com.project.movienight.domain.model.Film
|
import com.project.movienight.domain.model.Film
|
||||||
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.justRun
|
import io.mockk.justRun
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
@@ -23,6 +24,7 @@ class FilmServiceTest {
|
|||||||
private lateinit var filmRepository: FilmRepositoryPort
|
private lateinit var filmRepository: FilmRepositoryPort
|
||||||
private lateinit var idGenerator: IdGenerator
|
private lateinit var idGenerator: IdGenerator
|
||||||
private lateinit var filmConfig: FilmServiceProperties
|
private lateinit var filmConfig: FilmServiceProperties
|
||||||
|
private lateinit var meterRegistry: SimpleMeterRegistry
|
||||||
private lateinit var filmService: FilmService
|
private lateinit var filmService: FilmService
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -30,7 +32,8 @@ class FilmServiceTest {
|
|||||||
filmRepository = mockk()
|
filmRepository = mockk()
|
||||||
idGenerator = mockk()
|
idGenerator = mockk()
|
||||||
filmConfig = mockk()
|
filmConfig = mockk()
|
||||||
filmService = FilmService(filmRepository, idGenerator, filmConfig)
|
meterRegistry = SimpleMeterRegistry()
|
||||||
|
filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user