diff --git a/systems/observability/observability-adapter-in/deps.bzl b/systems/observability/observability-adapter-in/deps.bzl index 02984639..8aec82d1 100644 --- a/systems/observability/observability-adapter-in/deps.bzl +++ b/systems/observability/observability-adapter-in/deps.bzl @@ -4,14 +4,13 @@ KOTLIN_DEPS = [ "@maven//:io_jsonwebtoken_jjwt_api", "@maven//:io_jsonwebtoken_jjwt_impl", "@maven//:io_jsonwebtoken_jjwt_jackson", - "@maven//:org_apache_poi_poi", - "@maven//:org_apache_poi_poi_ooxml", "//systems/observability/observability-application:main", "//systems/observability/observability-domain:main", ] TEST_DEPS = [ "@maven//:junit_junit", + "@maven//:org_springframework_boot_spring_boot_starter_test", ] MODULE_DEPS = KOTLIN_DEPS diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/MonitorStreamController.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/MonitorStreamController.kt new file mode 100644 index 00000000..50a8b310 --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/MonitorStreamController.kt @@ -0,0 +1,42 @@ +package hs.kr.entrydsm.observability.adapterin.web.controller + +import hs.kr.entrydsm.observability.adapterin.web.ClientIpResolver +import hs.kr.entrydsm.observability.adapterin.web.sse.SseBroadcaster +import hs.kr.entrydsm.observability.adapterin.web.sse.SseConnectionLimiter +import hs.kr.entrydsm.observability.domain.enum.ErrorCode +import hs.kr.entrydsm.observability.domain.exception.MonitorDomainException +import jakarta.servlet.http.HttpServletRequest +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +@RestController +class MonitorStreamController( + private val sseBroadcaster: SseBroadcaster, + private val connectionLimiter: SseConnectionLimiter, + private val clientIpResolver: ClientIpResolver, +) { + @GetMapping("/api/monitor/v11/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) + fun stream(httpRequest: HttpServletRequest): SseEmitter { + val clientKey = clientIpResolver.resolve(httpRequest) + if (!connectionLimiter.tryAcquire(clientKey)) { + throw MonitorDomainException(ErrorCode.TOO_MANY_CONNECTIONS) + } + val emitter = SseEmitter(TimeUnit.MINUTES.toMillis(EMITTER_TIMEOUT_MINUTES)) + // timeout/error 뒤에도 onCompletion이 불릴 수 있다. 중복 해제되면 카운터가 음수가 되어 제한이 무의미해진다. + val released = AtomicBoolean(false) + val releaseOnce = Runnable { if (released.compareAndSet(false, true)) connectionLimiter.release(clientKey) } + emitter.onCompletion(releaseOnce) + emitter.onTimeout(releaseOnce) + emitter.onError { releaseOnce.run() } + sseBroadcaster.register(emitter) + return emitter + } + + companion object { + private const val EMITTER_TIMEOUT_MINUTES = 30L + } +} diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportController.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportController.kt new file mode 100644 index 00000000..8dca8252 --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportController.kt @@ -0,0 +1,28 @@ +package hs.kr.entrydsm.observability.adapterin.web.controller + +import hs.kr.entrydsm.observability.adapterin.web.dto.common.ApiResponse +import hs.kr.entrydsm.observability.adapterin.web.dto.common.toResponse +import hs.kr.entrydsm.observability.adapterin.web.dto.response.ReportGeneratedResponse +import hs.kr.entrydsm.observability.application.port.`in`.GenerateReportUseCase +import hs.kr.entrydsm.observability.domain.enum.ErrorCode +import hs.kr.entrydsm.observability.domain.enum.ReportFormat +import hs.kr.entrydsm.observability.domain.exception.MonitorDomainException +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +class ReportController( + private val generateReportUseCase: GenerateReportUseCase, +) { + // 파일과 다운로드 토큰을 만드는 요청이라 GET이 아니다. 프리페치나 재시도가 리포트를 새로 만들지 않게 한다. + @PostMapping("/api/monitor/v11/reports") + fun generate( + @RequestParam(defaultValue = "xlsx") format: String, + ): ApiResponse { + val parsedFormat = runCatching { ReportFormat.valueOf(format.trim().uppercase()) } + .getOrElse { throw MonitorDomainException(ErrorCode.INVALID_FORMAT) } + val result = generateReportUseCase.generate(parsedFormat) + return ApiResponse(data = result.toResponse()) + } +} diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportDownloadController.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportDownloadController.kt new file mode 100644 index 00000000..503c2806 --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportDownloadController.kt @@ -0,0 +1,25 @@ +package hs.kr.entrydsm.observability.adapterin.web.controller + +import hs.kr.entrydsm.observability.application.port.out.ReportObjectStoragePort +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +/** 리포트 다운로드 URL이 가리키는 실제 파일 서빙. S3 presigned URL의 로컬 대체 구현. */ +@RestController +class ReportDownloadController( + private val reportObjectStoragePort: ReportObjectStoragePort, +) { + @GetMapping("/api/monitor/v11/reports/download") + fun download(@RequestParam token: String): ResponseEntity { + val downloaded = reportObjectStoragePort.resolve(token) ?: return ResponseEntity.notFound().build() + val safeFileName = downloaded.fileName.filterNot { it == '"' || it == '\n' || it == '\r' } + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$safeFileName\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(downloaded.bytes) + } +} diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/dto/common/ResponseMapper.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/dto/common/ResponseMapper.kt index ea0e445b..45300cce 100644 --- a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/dto/common/ResponseMapper.kt +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/dto/common/ResponseMapper.kt @@ -20,6 +20,7 @@ import hs.kr.entrydsm.observability.adapterin.web.dto.response.ServerLogItemResp import hs.kr.entrydsm.observability.adapterin.web.dto.response.ServerLogPageResponse import hs.kr.entrydsm.observability.adapterin.web.dto.response.ServiceActivityItemResponse import hs.kr.entrydsm.observability.adapterin.web.dto.response.ServiceActivityResponse +import hs.kr.entrydsm.observability.adapterin.web.dto.response.ReportGeneratedResponse import hs.kr.entrydsm.observability.adapterin.web.dto.response.ServiceHealthItemResponse import hs.kr.entrydsm.observability.adapterin.web.dto.response.ServiceHealthResponse import hs.kr.entrydsm.observability.adapterin.web.dto.response.SessionEventResponse @@ -48,6 +49,7 @@ import hs.kr.entrydsm.observability.application.port.`in`.result.ResourceUsageBr import hs.kr.entrydsm.observability.application.port.`in`.result.ServiceActivityItemResult import hs.kr.entrydsm.observability.application.port.`in`.result.ServiceActivityResult import hs.kr.entrydsm.observability.application.port.`in`.result.ServiceHealthItemResult +import hs.kr.entrydsm.observability.application.port.`in`.result.ReportResult import hs.kr.entrydsm.observability.application.port.`in`.result.ServiceHealthResult import hs.kr.entrydsm.observability.application.port.`in`.result.SessionEventResult import hs.kr.entrydsm.observability.application.port.`in`.result.TrafficResult @@ -194,3 +196,12 @@ fun DatabaseUsageResult.toResponse(): DatabaseUsageResponse = fun BucketUsageResult.toResponse(): BucketUsageResponse = BucketUsageResponse(usedBytes = usedBytes, objectCount = objectCount, measuredAt = measuredAt) + +fun ReportResult.toResponse(): ReportGeneratedResponse = + ReportGeneratedResponse( + status = "READY", + downloadUrl = downloadUrl, + fileName = fileName, + sizeBytes = sizeBytes, + expiresAt = expiresAt, + ) diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/dto/response/LiveLogEventResponse.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/dto/response/LiveLogEventResponse.kt new file mode 100644 index 00000000..89b9182d --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/dto/response/LiveLogEventResponse.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.observability.adapterin.web.dto.response + +import java.time.Instant + +data class LiveLogEventResponse( + val kind: String, + val level: String, + val source: String, + val message: String, + val pageUrl: String, + val occurredAt: Instant, +) diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/dto/response/ReportGeneratedResponse.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/dto/response/ReportGeneratedResponse.kt new file mode 100644 index 00000000..44a25494 --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/dto/response/ReportGeneratedResponse.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.observability.adapterin.web.dto.response + +import java.time.Instant + +data class ReportGeneratedResponse( + val status: String, + val downloadUrl: String, + val fileName: String, + val sizeBytes: Long, + val expiresAt: Instant, +) diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthInterceptor.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthInterceptor.kt new file mode 100644 index 00000000..795d3d7d --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthInterceptor.kt @@ -0,0 +1,55 @@ +package hs.kr.entrydsm.observability.adapterin.web.security + +import hs.kr.entrydsm.observability.domain.enum.ErrorCode +import hs.kr.entrydsm.observability.domain.exception.MonitorDomainException +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import java.nio.charset.StandardCharsets +import org.springframework.stereotype.Component +import org.springframework.web.servlet.HandlerInterceptor + +/** + * ponytail: 이 저장소엔 아직 Spring Security/JWT 발급 체계가 병합되어 있지 않아, + * 전체 Security 스택 대신 Bearer 토큰 서명·발급자·role 클레임만 확인하는 경량 인터셉터로 둔다. + * identity의 정식 인증이 병합되면 같은 시크릿을 공유하도록 설정만 맞추면 된다. + */ +@Component +class JwtAuthInterceptor( + private val properties: JwtAuthProperties, +) : HandlerInterceptor { + private val key by lazy { Keys.hmacShaKeyFor(properties.secret.toByteArray(StandardCharsets.UTF_8)) } + + override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { + val token = bearerToken(request) ?: throw MonitorDomainException(ErrorCode.UNAUTHORIZED) + val claims = try { + Jwts.parser() + .verifyWith(key) + .requireIssuer(properties.issuer) + .build() + .parseSignedClaims(token) + .payload + } catch (exception: JwtException) { + throw MonitorDomainException(ErrorCode.UNAUTHORIZED) + } catch (exception: IllegalArgumentException) { + throw MonitorDomainException(ErrorCode.UNAUTHORIZED) + } + if (claims["role"] as? String != ADMIN_ROLE) { + throw MonitorDomainException(ErrorCode.FORBIDDEN) + } + return true + } + + private fun bearerToken(request: HttpServletRequest): String? { + val header = request.getHeader("Authorization") ?: return null + if (!header.startsWith(BEARER_PREFIX)) return null + return header.removePrefix(BEARER_PREFIX).trim().takeIf { it.isNotEmpty() } + } + + companion object { + private const val BEARER_PREFIX = "Bearer " + private const val ADMIN_ROLE = "ADMIN" + } +} diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthProperties.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthProperties.kt new file mode 100644 index 00000000..5add96b1 --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthProperties.kt @@ -0,0 +1,22 @@ +package hs.kr.entrydsm.observability.adapterin.web.security + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component +import org.springframework.validation.annotation.Validated + +/** + * identity의 auth.jwt.secret/issuer와 설정 키 이름을 통일해, 정식 인증이 병합되면 + * 같은 시크릿을 가리키도록 맞추기만 하면 되게 한다. + */ +@Component +@ConfigurationProperties(prefix = "auth.jwt") +@Validated +class JwtAuthProperties { + /** 비어 있으면 기동에 실패한다. 알려진 기본 시크릿으로 토큰이 검증되는 상태를 만들지 않기 위한 것. */ + @field:NotBlank + lateinit var secret: String + + @field:NotBlank + lateinit var issuer: String +} diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/WebMvcConfig.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/WebMvcConfig.kt new file mode 100644 index 00000000..e688f059 --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/WebMvcConfig.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.observability.adapterin.web.security + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig( + private val jwtAuthInterceptor: JwtAuthInterceptor, +) : WebMvcConfigurer { + override fun addInterceptors(registry: InterceptorRegistry) { + registry.addInterceptor(jwtAuthInterceptor) + .addPathPatterns("/api/monitor/v11/**") + .excludePathPatterns( + "/api/monitor/v11/collect/session", + "/api/monitor/v11/collect/client-log", + ) + } +} diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseBroadcaster.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseBroadcaster.kt new file mode 100644 index 00000000..30b7e47b --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseBroadcaster.kt @@ -0,0 +1,80 @@ +package hs.kr.entrydsm.observability.adapterin.web.sse + +import hs.kr.entrydsm.observability.adapterin.web.dto.common.toResponse +import hs.kr.entrydsm.observability.application.port.`in`.GetDashboardSnapshotUseCase +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicLong +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +/** + * ponytail: 커넥션 목록을 인메모리로만 들고 있어 다중 인스턴스에서는 인스턴스별로 브로드캐스트가 갈린다. + * 수평 확장이 필요해지면 Redis Pub/Sub로 교체한다. + * ponytail: 구독자에게 순차 전송한다. 느린 구독자 하나가 나머지 전송을 지연시킬 수 있다(관리자 대시보드라 동시 구독자가 적다). + * 구독자가 늘면 커넥션별 bounded 큐와 전용 executor로 분리한다. + */ +@Component +class SseBroadcaster( + private val getDashboardSnapshotUseCase: GetDashboardSnapshotUseCase, +) { + private val emitters = CopyOnWriteArrayList() + private val eventIdSeq = AtomicLong(System.currentTimeMillis()) + + fun register(emitter: SseEmitter) { + emitters.add(emitter) + emitter.onCompletion { emitters.remove(emitter) } + emitter.onTimeout { emitters.remove(emitter) } + emitter.onError { emitters.remove(emitter) } + sendInitialSnapshot(emitter) + } + + fun publishLog(payload: Any) { + broadcast("log", payload) + } + + @Scheduled(fixedRate = 5000) + fun broadcastFrequent() { + if (emitters.isEmpty()) return + val snapshot = getDashboardSnapshotUseCase.getSnapshot(null) + broadcast("traffic", snapshot.traffic.toResponse()) + broadcast("api", snapshot.api.toResponse()) + broadcast("business", snapshot.business.toResponse()) + broadcast("service", snapshot.services.toResponse()) + } + + @Scheduled(fixedRate = 300000) + fun broadcastResource() { + if (emitters.isEmpty()) return + broadcast("resource", getDashboardSnapshotUseCase.getSnapshot(null).resource.toResponse()) + } + + @Scheduled(fixedRate = 15000) + fun ping() { + emitters.forEach { emitter -> + runCatching { emitter.send(SseEmitter.event().comment("ping")) } + .onFailure { emitters.remove(emitter) } + } + } + + private fun sendInitialSnapshot(emitter: SseEmitter) { + runCatching { + emitter.send(SseEmitter.event().reconnectTime(RECONNECT_TIME_MS)) + val snapshot = getDashboardSnapshotUseCase.getSnapshot(null) + emitter.send(SseEmitter.event().id(nextId()).name("snapshot").data(snapshot.toResponse())) + }.onFailure { emitters.remove(emitter) } + } + + private fun broadcast(eventName: String, data: Any) { + emitters.forEach { emitter -> + runCatching { emitter.send(SseEmitter.event().id(nextId()).name(eventName).data(data)) } + .onFailure { emitters.remove(emitter) } + } + } + + private fun nextId(): String = eventIdSeq.incrementAndGet().toString() + + companion object { + private const val RECONNECT_TIME_MS = 5000L + } +} diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseConnectionLimiter.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseConnectionLimiter.kt new file mode 100644 index 00000000..dca7c43d --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseConnectionLimiter.kt @@ -0,0 +1,37 @@ +package hs.kr.entrydsm.observability.adapterin.web.sse + +import java.util.concurrent.ConcurrentHashMap +import org.springframework.stereotype.Component + +/** + * ponytail: 단일 인스턴스 가정의 인메모리 카운터. 인증 붙기 전이라 계정 대신 IP 기준으로 제한한다. + * JWT 인증이 연결되면 계정 단위로 교체한다. + */ +@Component +class SseConnectionLimiter { + private val counts = ConcurrentHashMap() + + fun tryAcquire(key: String): Boolean { + // compute는 키 단위로 원자적이다. 읽고 쓰는 사이에 다른 요청이 끼어들어 제한을 넘기지 않는다. + var acquired = false + counts.compute(key) { _, current -> + val count = current ?: 0 + if (count < MAX_CONNECTIONS_PER_KEY) { + acquired = true + count + 1 + } else { + count + } + } + return acquired + } + + fun release(key: String) { + // 마지막 연결이 끊기면 키까지 지운다. 남겨두면 IP 수만큼 맵이 계속 커진다. + counts.computeIfPresent(key) { _, count -> (count - 1).takeIf { it > 0 } } + } + + companion object { + private const val MAX_CONNECTIONS_PER_KEY = 3 + } +} diff --git a/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseLiveLogPublisher.kt b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseLiveLogPublisher.kt new file mode 100644 index 00000000..9f1d29d2 --- /dev/null +++ b/systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseLiveLogPublisher.kt @@ -0,0 +1,24 @@ +package hs.kr.entrydsm.observability.adapterin.web.sse + +import hs.kr.entrydsm.observability.adapterin.web.dto.response.LiveLogEventResponse +import hs.kr.entrydsm.observability.application.port.out.ClientLogInput +import hs.kr.entrydsm.observability.application.port.out.LiveLogPublisherPort +import org.springframework.stereotype.Component + +@Component +class SseLiveLogPublisher( + private val sseBroadcaster: SseBroadcaster, +) : LiveLogPublisherPort { + override fun publishClientLog(input: ClientLogInput) { + sseBroadcaster.publishLog( + LiveLogEventResponse( + kind = "CLIENT", + level = input.level.name, + source = input.source.name, + message = input.message, + pageUrl = input.pageUrl, + occurredAt = input.occurredAt, + ), + ) + } +} diff --git a/systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/TestMain.kt b/systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/TestMain.kt index 28c2d1c4..c7b9483a 100644 --- a/systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/TestMain.kt +++ b/systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/TestMain.kt @@ -2,6 +2,8 @@ package hs.kr.entrydsm.observability.adapterin import hs.kr.entrydsm.observability.adapterin.web.ClientIpResolverTest import hs.kr.entrydsm.observability.adapterin.web.exception.GlobalExceptionHandlerTest +import hs.kr.entrydsm.observability.adapterin.web.security.JwtAuthInterceptorTest +import hs.kr.entrydsm.observability.adapterin.web.sse.SseConnectionLimiterTest import org.junit.runner.RunWith import org.junit.runners.Suite @@ -9,5 +11,7 @@ import org.junit.runners.Suite @Suite.SuiteClasses( ClientIpResolverTest::class, GlobalExceptionHandlerTest::class, + JwtAuthInterceptorTest::class, + SseConnectionLimiterTest::class, ) class ObservabilityAdapterInModuleTest diff --git a/systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthInterceptorTest.kt b/systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthInterceptorTest.kt new file mode 100644 index 00000000..0380dd06 --- /dev/null +++ b/systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthInterceptorTest.kt @@ -0,0 +1,75 @@ +package hs.kr.entrydsm.observability.adapterin.web.security + +import hs.kr.entrydsm.observability.domain.enum.ErrorCode +import hs.kr.entrydsm.observability.domain.exception.MonitorDomainException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import java.nio.charset.StandardCharsets +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse + +class JwtAuthInterceptorTest { + private val secret = "test-secret-key-at-least-32-bytes-long!!" + private val issuer = "entrydsm-test" + private val key = Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8)) + private val properties = JwtAuthProperties().apply { + this.secret = this@JwtAuthInterceptorTest.secret + this.issuer = this@JwtAuthInterceptorTest.issuer + } + private val interceptor = JwtAuthInterceptor(properties) + + private fun token(role: String, expiresInMillis: Long = 60_000, issuer: String = this.issuer): String = + Jwts.builder() + .issuer(issuer) + .claim("role", role) + .expiration(Date(System.currentTimeMillis() + expiresInMillis)) + .signWith(key) + .compact() + + private fun requestWithToken(token: String?): MockHttpServletRequest = + MockHttpServletRequest().apply { token?.let { addHeader("Authorization", "Bearer $it") } } + + @Test + fun allowsAdminToken() { + val allowed = interceptor.preHandle(requestWithToken(token("ADMIN")), MockHttpServletResponse(), Any()) + assertTrue(allowed) + } + + private fun rejectionCode(token: String?): ErrorCode = + assertThrows(MonitorDomainException::class.java) { + interceptor.preHandle(requestWithToken(token), MockHttpServletResponse(), Any()) + }.errorCode + + @Test + fun rejectsMissingTokenWithUnauthorized() { + assertEquals(ErrorCode.UNAUTHORIZED, rejectionCode(null)) + } + + @Test + fun rejectsNonAdminRoleWithForbidden() { + assertEquals(ErrorCode.FORBIDDEN, rejectionCode(token("USER"))) + } + + @Test + fun rejectsExpiredTokenWithUnauthorized() { + assertEquals(ErrorCode.UNAUTHORIZED, rejectionCode(token("ADMIN", expiresInMillis = -1000))) + } + + @Test + fun rejectsWrongIssuerWithUnauthorized() { + assertEquals(ErrorCode.UNAUTHORIZED, rejectionCode(token("ADMIN", issuer = "someone-else"))) + } + + @Test + fun rejectsWrongSignatureWithUnauthorized() { + val otherKey = Keys.hmacShaKeyFor("another-secret-key-at-least-32-bytes!!".toByteArray(StandardCharsets.UTF_8)) + val forged = Jwts.builder().issuer(issuer).claim("role", "ADMIN").signWith(otherKey).compact() + + assertEquals(ErrorCode.UNAUTHORIZED, rejectionCode(forged)) + } +} diff --git a/systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseConnectionLimiterTest.kt b/systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseConnectionLimiterTest.kt new file mode 100644 index 00000000..ce04e6ed --- /dev/null +++ b/systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseConnectionLimiterTest.kt @@ -0,0 +1,70 @@ +package hs.kr.entrydsm.observability.adapterin.web.sse + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SseConnectionLimiterTest { + + @Test + fun rejectsConnectionsOverTheLimit() { + val limiter = SseConnectionLimiter() + + repeat(MAX_CONNECTIONS) { assertTrue(limiter.tryAcquire("1.2.3.4")) } + + assertFalse(limiter.tryAcquire("1.2.3.4")) + } + + @Test + fun releasedSlotCanBeAcquiredAgain() { + val limiter = SseConnectionLimiter() + repeat(MAX_CONNECTIONS) { limiter.tryAcquire("1.2.3.4") } + + limiter.release("1.2.3.4") + + assertTrue(limiter.tryAcquire("1.2.3.4")) + assertFalse(limiter.tryAcquire("1.2.3.4")) + } + + @Test + fun releaseBeyondZeroDoesNotCreateExtraSlots() { + val limiter = SseConnectionLimiter() + + repeat(MAX_CONNECTIONS + 2) { limiter.release("1.2.3.4") } + + repeat(MAX_CONNECTIONS) { assertTrue(limiter.tryAcquire("1.2.3.4")) } + assertFalse(limiter.tryAcquire("1.2.3.4")) + } + + @Test + fun concurrentAcquiresNeverExceedTheLimit() { + val limiter = SseConnectionLimiter() + val acquired = AtomicInteger(0) + val start = CountDownLatch(1) + val done = CountDownLatch(THREADS) + val pool = Executors.newFixedThreadPool(THREADS) + repeat(THREADS) { + pool.execute { + start.await() + if (limiter.tryAcquire("1.2.3.4")) acquired.incrementAndGet() + done.countDown() + } + } + + start.countDown() + assertTrue(done.await(10, TimeUnit.SECONDS)) + pool.shutdown() + + assertEquals(MAX_CONNECTIONS, acquired.get()) + } + + companion object { + private const val MAX_CONNECTIONS = 3 + private const val THREADS = 16 + } +} diff --git a/systems/observability/observability-adapter-out/deps.bzl b/systems/observability/observability-adapter-out/deps.bzl index 9cd0cad0..f6543567 100644 --- a/systems/observability/observability-adapter-out/deps.bzl +++ b/systems/observability/observability-adapter-out/deps.bzl @@ -1,6 +1,8 @@ KOTLIN_DEPS = [ "@maven//:org_springframework_boot_spring_boot_starter_data_redis", "@maven//:com_fasterxml_jackson_core_jackson_databind", + "@maven//:org_apache_poi_poi", + "@maven//:org_apache_poi_poi_ooxml", "//systems/observability/observability-application:main", "//systems/observability/observability-domain:main", ] diff --git a/systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/report/LocalFileReportObjectStorageAdapter.kt b/systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/report/LocalFileReportObjectStorageAdapter.kt new file mode 100644 index 00000000..b8893f99 --- /dev/null +++ b/systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/report/LocalFileReportObjectStorageAdapter.kt @@ -0,0 +1,53 @@ +package hs.kr.entrydsm.observability.adapterout.report + +import hs.kr.entrydsm.observability.application.port.out.DownloadedReport +import hs.kr.entrydsm.observability.application.port.out.ReportObjectStoragePort +import hs.kr.entrydsm.observability.application.port.out.StoredReport +import java.io.File +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.UUID +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Component + +/** + * S3 presigned URL 자리를 로컬 디스크 + Redis 만료 토큰으로 대체한다. + * 나중에 실제 S3로 교체할 때는 이 어댑터만 바꾸면 된다(포트는 그대로). + */ +@Component +class LocalFileReportObjectStorageAdapter( + @Value("\${monitor.report.storage-dir}") private val storageDir: String, + private val redis: StringRedisTemplate, + private val clock: Clock, +) : ReportObjectStoragePort { + + /** 저장 객체는 토큰 이름으로만 만든다. 같은 회차·날짜·형식의 리포트가 서로를 덮어써 이전 토큰이 다른 내용을 내려주는 일을 막고, fileName이 경로로 해석될 여지도 없앤다. */ + override fun store(fileName: String, bytes: ByteArray): StoredReport { + val dir = File(storageDir).apply { mkdirs() } + val token = UUID.randomUUID().toString() + val objectFile = File(dir, token) + objectFile.writeBytes(bytes) + + redis.opsForValue().set(tokenKey(token), "$fileName\n${objectFile.absolutePath}", TTL) + return StoredReport( + downloadUrl = "/api/monitor/v11/reports/download?token=$token", + expiresAt = Instant.now(clock).plus(TTL), + ) + } + + override fun resolve(token: String): DownloadedReport? { + val stored = redis.opsForValue().get(tokenKey(token)) ?: return null + val (fileName, path) = stored.split("\n", limit = 2).takeIf { it.size == 2 } ?: return null + val file = File(path) + if (!file.exists()) return null + return DownloadedReport(fileName = fileName, bytes = file.readBytes()) + } + + private fun tokenKey(token: String) = "monitor:report:token:$token" + + companion object { + private val TTL: Duration = Duration.ofMinutes(5) + } +} diff --git a/systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/report/XlsxCsvReportGenerator.kt b/systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/report/XlsxCsvReportGenerator.kt new file mode 100644 index 00000000..93d1ac7c --- /dev/null +++ b/systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/report/XlsxCsvReportGenerator.kt @@ -0,0 +1,68 @@ +package hs.kr.entrydsm.observability.adapterout.report + +import hs.kr.entrydsm.observability.application.port.`in`.result.DashboardSnapshotResult +import hs.kr.entrydsm.observability.application.port.out.ReportGeneratorPort +import hs.kr.entrydsm.observability.domain.enum.ReportFormat +import java.io.ByteArrayOutputStream +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.springframework.stereotype.Component + +@Component +class XlsxCsvReportGenerator : ReportGeneratorPort { + + override fun generate(format: ReportFormat, snapshot: DashboardSnapshotResult): ByteArray = + when (format) { + ReportFormat.XLSX -> toXlsx(snapshot) + ReportFormat.CSV -> toCsv(snapshot).toByteArray() + } + + private fun rows(snapshot: DashboardSnapshotResult): List> = + listOf( + "generatedAt" to snapshot.generatedAt.toString(), + "round" to snapshot.period.round, + "totalVisitors" to snapshot.traffic.totalVisitors.toString(), + "concurrentCurrent" to snapshot.traffic.concurrent.current.toString(), + "concurrentMax" to snapshot.traffic.concurrent.max.toString(), + "concurrentAvg" to snapshot.traffic.concurrent.avg.toString(), + "avgSessionDurationSeconds" to snapshot.traffic.avgSessionDurationSeconds.toString(), + "apiTotalRequests" to snapshot.api.totalRequests.toString(), + "apiSuccessCount" to snapshot.api.successCount.toString(), + "apiFailureCount" to snapshot.api.failureCount.toString(), + "applicationSubmitSuccess" to snapshot.business.applicationSubmit.success.toString(), + "applicationSubmitFailure" to snapshot.business.applicationSubmit.failure.toString(), + "pdfDownloadSuccess" to snapshot.business.pdfDownload.success.toString(), + "pdfDownloadFailure" to snapshot.business.pdfDownload.failure.toString(), + "clientLogErrorCount" to snapshot.clientLog.errorCount.toString(), + "clientLogWarnCount" to snapshot.clientLog.warnCount.toString(), + "dbUsedBytes" to snapshot.resource.dbUsedBytes.toString(), + "bucketUsedBytes" to snapshot.resource.bucketUsedBytes.toString(), + ) + snapshot.services.items.map { "activeUsers_${it.service}" to it.activeUsers.toString() } + + private fun toCsv(snapshot: DashboardSnapshotResult): String = + buildString { + appendLine("metric,value") + rows(snapshot).forEach { (key, value) -> appendLine("${escapeCsv(key)},${escapeCsv(value)}") } + } + + /** RFC 4180: 쉼표·큰따옴표·줄바꿈이 들어가도 열 구조가 깨지지 않게 감싸고 내부 큰따옴표는 두 번 쓴다. */ + private fun escapeCsv(field: String): String = "\"" + field.replace("\"", "\"\"") + "\"" + + private fun toXlsx(snapshot: DashboardSnapshotResult): ByteArray { + XSSFWorkbook().use { workbook -> + val sheet = workbook.createSheet("monitor") + sheet.createRow(0).apply { + createCell(0).setCellValue("metric") + createCell(1).setCellValue("value") + } + rows(snapshot).forEachIndexed { index, (key, value) -> + sheet.createRow(index + 1).apply { + createCell(0).setCellValue(key) + createCell(1).setCellValue(value) + } + } + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } + } +} diff --git a/systems/observability/observability-adapter-out/src/test/kotlin/hs/kr/entrydsm/TestMain.kt b/systems/observability/observability-adapter-out/src/test/kotlin/hs/kr/entrydsm/TestMain.kt index ab095b97..ef4f7ec4 100644 --- a/systems/observability/observability-adapter-out/src/test/kotlin/hs/kr/entrydsm/TestMain.kt +++ b/systems/observability/observability-adapter-out/src/test/kotlin/hs/kr/entrydsm/TestMain.kt @@ -1,11 +1,11 @@ package hs.kr.entrydsm.observability.adapterout -import org.junit.Assert.assertTrue -import org.junit.Test +import hs.kr.entrydsm.observability.adapterout.report.XlsxCsvReportGeneratorTest +import org.junit.runner.RunWith +import org.junit.runners.Suite -class ObservabilityAdapterOutModuleTest { - @Test - fun moduleLoads() { - assertTrue(true) - } -} +@RunWith(Suite::class) +@Suite.SuiteClasses( + XlsxCsvReportGeneratorTest::class, +) +class ObservabilityAdapterOutModuleTest diff --git a/systems/observability/observability-adapter-out/src/test/kotlin/hs/kr/entrydsm/observability/adapterout/report/XlsxCsvReportGeneratorTest.kt b/systems/observability/observability-adapter-out/src/test/kotlin/hs/kr/entrydsm/observability/adapterout/report/XlsxCsvReportGeneratorTest.kt new file mode 100644 index 00000000..cfb3dc83 --- /dev/null +++ b/systems/observability/observability-adapter-out/src/test/kotlin/hs/kr/entrydsm/observability/adapterout/report/XlsxCsvReportGeneratorTest.kt @@ -0,0 +1,65 @@ +package hs.kr.entrydsm.observability.adapterout.report + +import hs.kr.entrydsm.observability.application.port.`in`.result.ApiStatsResult +import hs.kr.entrydsm.observability.application.port.`in`.result.BusinessStatsResult +import hs.kr.entrydsm.observability.application.port.`in`.result.ClientLogCountResult +import hs.kr.entrydsm.observability.application.port.`in`.result.ConcurrentResult +import hs.kr.entrydsm.observability.application.port.`in`.result.DashboardSnapshotResult +import hs.kr.entrydsm.observability.application.port.`in`.result.OutcomeCountResult +import hs.kr.entrydsm.observability.application.port.`in`.result.PeriodResult +import hs.kr.entrydsm.observability.application.port.`in`.result.ResourceUsageBriefResult +import hs.kr.entrydsm.observability.application.port.`in`.result.ServiceActivityResult +import hs.kr.entrydsm.observability.application.port.`in`.result.TrafficResult +import hs.kr.entrydsm.observability.domain.enum.ReportFormat +import java.io.ByteArrayInputStream +import java.time.Instant +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class XlsxCsvReportGeneratorTest { + private val generator = XlsxCsvReportGenerator() + + private fun snapshot(round: String) = DashboardSnapshotResult( + generatedAt = Instant.parse("2026-07-28T14:03:11Z"), + period = PeriodResult("ADMISSION", round, Instant.EPOCH, Instant.EPOCH), + traffic = TrafficResult(7, ConcurrentResult(1, 2, 3), 4, emptyList()), + api = ApiStatsResult(0, 0, 0, 0.0), + business = BusinessStatsResult(OutcomeCountResult(0, 0), OutcomeCountResult(0, 0)), + services = ServiceActivityResult(30, emptyList()), + clientLog = ClientLogCountResult(0, 0), + resource = ResourceUsageBriefResult(0, 0, Instant.parse("2026-07-28T14:03:11Z")), + ) + + private fun roundLine(csv: String): String = csv.lineSequence().first { it.startsWith("\"round\"") } + + @Test + fun csvQuotesEveryFieldAndDoublesInnerQuotes() { + val csv = String(generator.generate(ReportFormat.CSV, snapshot("""2026-1 "특별""""))) + + assertEquals("metric,value", csv.lineSequence().first()) + assertEquals(""""round","2026-1 ""특별"""""", roundLine(csv)) + } + + @Test + fun csvKeepsOneValueColumnWhenValueContainsComma() { + val csv = String(generator.generate(ReportFormat.CSV, snapshot("a,b"))) + + assertEquals(""""round","a,b"""", roundLine(csv)) + } + + @Test + fun xlsxWritesMetricValueRows() { + val bytes = generator.generate(ReportFormat.XLSX, snapshot("2026-1")) + + XSSFWorkbook(ByteArrayInputStream(bytes)).use { workbook -> + val sheet = workbook.getSheet("monitor") + assertEquals("metric", sheet.getRow(0).getCell(0).stringCellValue) + assertEquals("value", sheet.getRow(0).getCell(1).stringCellValue) + assertEquals("generatedAt", sheet.getRow(1).getCell(0).stringCellValue) + assertEquals("2026-1", sheet.getRow(2).getCell(1).stringCellValue) + assertTrue(sheet.getRow(3).getCell(1).stringCellValue == "7") + } + } +} diff --git a/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/ClientLogCollectionService.kt b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/ClientLogCollectionService.kt index f3f81990..0c250b28 100644 --- a/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/ClientLogCollectionService.kt +++ b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/ClientLogCollectionService.kt @@ -5,6 +5,7 @@ import hs.kr.entrydsm.observability.application.port.`in`.RecordClientLogUseCase import hs.kr.entrydsm.observability.application.port.`in`.result.ClientLogAcceptResult import hs.kr.entrydsm.observability.application.port.out.ClientLogInput import hs.kr.entrydsm.observability.application.port.out.ClientLogStorePort +import hs.kr.entrydsm.observability.application.port.out.LiveLogPublisherPort import hs.kr.entrydsm.observability.application.port.out.RateLimitPort import hs.kr.entrydsm.observability.domain.enum.ErrorCode import hs.kr.entrydsm.observability.domain.exception.MonitorDomainException @@ -13,6 +14,7 @@ import hs.kr.entrydsm.observability.domain.service.UserAgentParser class ClientLogCollectionService( private val clientLogStorePort: ClientLogStorePort, private val rateLimitPort: RateLimitPort, + private val liveLogPublisherPort: LiveLogPublisherPort, ) : RecordClientLogUseCase { override fun record(sessionId: String, logs: List, userAgent: String?, clientIp: String): ClientLogAcceptResult { @@ -25,17 +27,17 @@ class ClientLogCollectionService( val browser = UserAgentParser.browser(userAgent) val os = UserAgentParser.os(userAgent) logs.forEach { item -> - clientLogStorePort.record( - ClientLogInput( - level = item.level, - source = item.source, - message = item.message.take(MAX_MESSAGE_LENGTH), - pageUrl = item.pageUrl, - browser = browser, - os = os, - occurredAt = item.occurredAt, - ), + val input = ClientLogInput( + level = item.level, + source = item.source, + message = item.message.take(MAX_MESSAGE_LENGTH), + pageUrl = item.pageUrl, + browser = browser, + os = os, + occurredAt = item.occurredAt, ) + clientLogStorePort.record(input) + liveLogPublisherPort.publishClientLog(input) } return ClientLogAcceptResult(accepted = logs.size, rejected = 0) } diff --git a/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/ReportService.kt b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/ReportService.kt new file mode 100644 index 00000000..a372e9c7 --- /dev/null +++ b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/ReportService.kt @@ -0,0 +1,43 @@ +package hs.kr.entrydsm.observability.application + +import hs.kr.entrydsm.observability.application.port.`in`.GenerateReportUseCase +import hs.kr.entrydsm.observability.application.port.`in`.GetDashboardSnapshotUseCase +import hs.kr.entrydsm.observability.application.port.`in`.result.ReportResult +import hs.kr.entrydsm.observability.application.port.out.ReportGeneratorPort +import hs.kr.entrydsm.observability.application.port.out.ReportObjectStoragePort +import hs.kr.entrydsm.observability.application.port.out.RoundPort +import hs.kr.entrydsm.observability.domain.enum.ReportFormat +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** ponytail: 데이터량이 적어 동기 생성만 지원한다(202 GENERATING/폴링 큐 없음). 느려지면 잡 큐로 교체. */ +class ReportService( + private val getDashboardSnapshotUseCase: GetDashboardSnapshotUseCase, + private val reportGeneratorPort: ReportGeneratorPort, + private val reportObjectStoragePort: ReportObjectStoragePort, + private val roundPort: RoundPort, + private val clock: Clock, +) : GenerateReportUseCase { + + override fun generate(format: ReportFormat): ReportResult { + val snapshot = getDashboardSnapshotUseCase.getSnapshot(null) + val bytes = reportGeneratorPort.generate(format, snapshot) + val round = roundPort.current() + val dateStamp = DATE_FORMATTER.format(Instant.now(clock).atZone(ZONE)) + val fileName = "entrymonitor_${round.name}_$dateStamp.${format.name.lowercase()}" + val stored = reportObjectStoragePort.store(fileName, bytes) + return ReportResult( + downloadUrl = stored.downloadUrl, + fileName = fileName, + sizeBytes = bytes.size.toLong(), + expiresAt = stored.expiresAt, + ) + } + + companion object { + private val ZONE: ZoneId = ZoneId.of("Asia/Seoul") + private val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") + } +} diff --git a/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/in/GenerateReportUseCase.kt b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/in/GenerateReportUseCase.kt new file mode 100644 index 00000000..ce922d6c --- /dev/null +++ b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/in/GenerateReportUseCase.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.observability.application.port.`in` + +import hs.kr.entrydsm.observability.application.port.`in`.result.ReportResult +import hs.kr.entrydsm.observability.domain.enum.ReportFormat + +interface GenerateReportUseCase { + fun generate(format: ReportFormat): ReportResult +} diff --git a/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/in/GetDashboardSnapshotUseCase.kt b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/in/GetDashboardSnapshotUseCase.kt index 77e6f0bd..1a7e245b 100644 --- a/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/in/GetDashboardSnapshotUseCase.kt +++ b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/in/GetDashboardSnapshotUseCase.kt @@ -2,7 +2,7 @@ package hs.kr.entrydsm.observability.application.port.`in` import hs.kr.entrydsm.observability.application.port.`in`.result.DashboardSnapshotResult -interface GetDashboardSnapshotUseCase { +fun interface GetDashboardSnapshotUseCase { /** @param round 생략 시 진행 중인 회차 */ fun getSnapshot(round: String?): DashboardSnapshotResult } diff --git a/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/in/result/ReportResult.kt b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/in/result/ReportResult.kt new file mode 100644 index 00000000..e5f2e4d1 --- /dev/null +++ b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/in/result/ReportResult.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.observability.application.port.`in`.result + +import java.time.Instant + +data class ReportResult( + val downloadUrl: String, + val fileName: String, + val sizeBytes: Long, + val expiresAt: Instant, +) diff --git a/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/LiveLogPublisherPort.kt b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/LiveLogPublisherPort.kt new file mode 100644 index 00000000..f8a04225 --- /dev/null +++ b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/LiveLogPublisherPort.kt @@ -0,0 +1,6 @@ +package hs.kr.entrydsm.observability.application.port.out + +fun interface LiveLogPublisherPort { + /** SSE log 이벤트로 즉시 내보낸다. 구독자가 없으면 아무 일도 하지 않는다. */ + fun publishClientLog(input: ClientLogInput) +} diff --git a/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/ReportGeneratorPort.kt b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/ReportGeneratorPort.kt new file mode 100644 index 00000000..b3259d65 --- /dev/null +++ b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/ReportGeneratorPort.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.observability.application.port.out + +import hs.kr.entrydsm.observability.application.port.`in`.result.DashboardSnapshotResult +import hs.kr.entrydsm.observability.domain.enum.ReportFormat + +fun interface ReportGeneratorPort { + fun generate(format: ReportFormat, snapshot: DashboardSnapshotResult): ByteArray +} diff --git a/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/ReportObjectStoragePort.kt b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/ReportObjectStoragePort.kt new file mode 100644 index 00000000..b03939df --- /dev/null +++ b/systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/ReportObjectStoragePort.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.observability.application.port.out + +import java.time.Instant + +interface ReportObjectStoragePort { + fun store(fileName: String, bytes: ByteArray): StoredReport + + fun resolve(token: String): DownloadedReport? +} + +data class StoredReport(val downloadUrl: String, val expiresAt: Instant) + +data class DownloadedReport(val fileName: String, val bytes: ByteArray) diff --git a/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/TestMain.kt b/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/TestMain.kt index 824195fb..25a9ad14 100644 --- a/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/TestMain.kt +++ b/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/TestMain.kt @@ -13,5 +13,6 @@ import org.junit.runners.Suite ClientLogQueryServiceTest::class, ServerLogQueryServiceTest::class, StorageUsageQueryServiceTest::class, + ReportServiceTest::class, ) class ObservabilityApplicationModuleTest diff --git a/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/observability/application/ClientLogCollectionServiceTest.kt b/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/observability/application/ClientLogCollectionServiceTest.kt index 30616efe..01830b07 100644 --- a/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/observability/application/ClientLogCollectionServiceTest.kt +++ b/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/observability/application/ClientLogCollectionServiceTest.kt @@ -5,6 +5,7 @@ import hs.kr.entrydsm.observability.application.port.out.ClientLogEntry import hs.kr.entrydsm.observability.application.port.out.ClientLogInput import hs.kr.entrydsm.observability.application.port.out.ClientLogPage import hs.kr.entrydsm.observability.application.port.out.ClientLogStorePort +import hs.kr.entrydsm.observability.application.port.out.LiveLogPublisherPort import hs.kr.entrydsm.observability.application.port.out.RateLimitPort import hs.kr.entrydsm.observability.domain.enum.ErrorCode import hs.kr.entrydsm.observability.domain.enum.LogLevel @@ -18,6 +19,8 @@ import org.junit.Test class ClientLogCollectionServiceTest { private val store = FakeClientLogStorePort() + private val published = mutableListOf() + private val publisher = LiveLogPublisherPort { published.add(it) } private fun item() = ClientLogItem( level = LogLevel.ERROR, @@ -30,7 +33,7 @@ class ClientLogCollectionServiceTest { @Test fun truncatesOverlongMessageAndRecordsEachItem() { - val service = ClientLogCollectionService(store, FakeRateLimitPort(true)) + val service = ClientLogCollectionService(store, FakeRateLimitPort(true), publisher) val result = service.record("sess_1", listOf(item()), "Mozilla/5.0 (Windows NT 10.0) Chrome/138.0.0.0", "127.0.0.1") @@ -38,11 +41,12 @@ class ClientLogCollectionServiceTest { assertEquals(0, result.rejected) assertEquals(500, store.recorded.single().message.length) assertEquals("Chrome 138", store.recorded.single().browser) + assertEquals(store.recorded, published) } @Test fun rejectsEmptyOrOversizedBatch() { - val service = ClientLogCollectionService(store, FakeRateLimitPort(true)) + val service = ClientLogCollectionService(store, FakeRateLimitPort(true), publisher) val empty = assertThrows(MonitorDomainException::class.java) { service.record("sess_1", emptyList(), null, "127.0.0.1") @@ -54,11 +58,12 @@ class ClientLogCollectionServiceTest { assertEquals(ErrorCode.INVALID_PAYLOAD, empty.errorCode) assertEquals(ErrorCode.INVALID_PAYLOAD, oversized.errorCode) assertEquals(0, store.recorded.size) + assertEquals(0, published.size) } @Test fun rateLimitExceededThrowsTooManyRequests() { - val service = ClientLogCollectionService(store, FakeRateLimitPort(false)) + val service = ClientLogCollectionService(store, FakeRateLimitPort(false), publisher) val exception = assertThrows(MonitorDomainException::class.java) { service.record("sess_1", listOf(item()), null, "127.0.0.1") @@ -66,6 +71,7 @@ class ClientLogCollectionServiceTest { assertEquals(ErrorCode.TOO_MANY_REQUESTS, exception.errorCode) assertEquals(0, store.recorded.size) + assertEquals(0, published.size) } private class FakeClientLogStorePort : ClientLogStorePort { diff --git a/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/observability/application/ReportServiceTest.kt b/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/observability/application/ReportServiceTest.kt new file mode 100644 index 00000000..f3759829 --- /dev/null +++ b/systems/observability/observability-application/src/test/kotlin/hs/kr/entrydsm/observability/application/ReportServiceTest.kt @@ -0,0 +1,57 @@ +package hs.kr.entrydsm.observability.application + +import hs.kr.entrydsm.observability.application.port.`in`.GetDashboardSnapshotUseCase +import hs.kr.entrydsm.observability.application.port.`in`.result.ApiStatsResult +import hs.kr.entrydsm.observability.application.port.`in`.result.BusinessStatsResult +import hs.kr.entrydsm.observability.application.port.`in`.result.ClientLogCountResult +import hs.kr.entrydsm.observability.application.port.`in`.result.ConcurrentResult +import hs.kr.entrydsm.observability.application.port.`in`.result.DashboardSnapshotResult +import hs.kr.entrydsm.observability.application.port.`in`.result.OutcomeCountResult +import hs.kr.entrydsm.observability.application.port.`in`.result.PeriodResult +import hs.kr.entrydsm.observability.application.port.`in`.result.ResourceUsageBriefResult +import hs.kr.entrydsm.observability.application.port.`in`.result.ServiceActivityResult +import hs.kr.entrydsm.observability.application.port.`in`.result.TrafficResult +import hs.kr.entrydsm.observability.application.port.out.Round +import hs.kr.entrydsm.observability.application.port.out.StoredReport +import hs.kr.entrydsm.observability.domain.enum.ReportFormat +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReportServiceTest { + private val clock = Clock.fixed(Instant.parse("2026-07-28T14:03:11Z"), ZoneOffset.UTC) + private val snapshot = DashboardSnapshotResult( + generatedAt = Instant.now(clock), + period = PeriodResult("ADMISSION", "2026-1", Instant.EPOCH, Instant.EPOCH), + traffic = TrafficResult(0, ConcurrentResult(0, 0, 0), 0, emptyList()), + api = ApiStatsResult(0, 0, 0, 0.0), + business = BusinessStatsResult(OutcomeCountResult(0, 0), OutcomeCountResult(0, 0)), + services = ServiceActivityResult(30, emptyList()), + clientLog = ClientLogCountResult(0, 0), + resource = ResourceUsageBriefResult(0, 0, Instant.now(clock)), + ) + + @Test + fun buildsFileNameFromRoundAndDate() { + val service = ReportService( + getDashboardSnapshotUseCase = GetDashboardSnapshotUseCase { snapshot }, + reportGeneratorPort = { _, _ -> byteArrayOf(1, 2, 3) }, + reportObjectStoragePort = object : hs.kr.entrydsm.observability.application.port.out.ReportObjectStoragePort { + override fun store(fileName: String, bytes: ByteArray) = + StoredReport(downloadUrl = "/api/monitor/v11/reports/download?token=t", expiresAt = Instant.now(clock).plusSeconds(300)) + override fun resolve(token: String) = null + }, + roundPort = { Round("2026-1", Instant.EPOCH, Instant.EPOCH) }, + clock = clock, + ) + + val result = service.generate(ReportFormat.XLSX) + + assertEquals("entrymonitor_2026-1_20260728.xlsx", result.fileName) + assertEquals(3L, result.sizeBytes) + assertTrue(result.downloadUrl.startsWith("/api/monitor/v11/reports/download")) + } +} diff --git a/systems/observability/observability-bootstrap/src/main/kotlin/hs/kr/entrydsm/ExampleApplication.kt b/systems/observability/observability-bootstrap/src/main/kotlin/hs/kr/entrydsm/ExampleApplication.kt index 1986be16..b628a93f 100644 --- a/systems/observability/observability-bootstrap/src/main/kotlin/hs/kr/entrydsm/ExampleApplication.kt +++ b/systems/observability/observability-bootstrap/src/main/kotlin/hs/kr/entrydsm/ExampleApplication.kt @@ -2,7 +2,9 @@ package hs.kr.entrydsm.observability import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling +@EnableScheduling @SpringBootApplication class ObservabilityBootstrapApplication diff --git a/systems/observability/observability-bootstrap/src/main/kotlin/hs/kr/entrydsm/observability/config/UseCaseConfig.kt b/systems/observability/observability-bootstrap/src/main/kotlin/hs/kr/entrydsm/observability/config/UseCaseConfig.kt index 064649e3..5c6d8a92 100644 --- a/systems/observability/observability-bootstrap/src/main/kotlin/hs/kr/entrydsm/observability/config/UseCaseConfig.kt +++ b/systems/observability/observability-bootstrap/src/main/kotlin/hs/kr/entrydsm/observability/config/UseCaseConfig.kt @@ -5,13 +5,18 @@ import hs.kr.entrydsm.observability.application.ClientLogQueryService import hs.kr.entrydsm.observability.application.MetricsSeriesService import hs.kr.entrydsm.observability.application.MonitorDashboardService import hs.kr.entrydsm.observability.application.MonitorHealthService +import hs.kr.entrydsm.observability.application.ReportService import hs.kr.entrydsm.observability.application.ServerLogQueryService import hs.kr.entrydsm.observability.application.SessionCollectionService import hs.kr.entrydsm.observability.application.StorageUsageQueryService +import hs.kr.entrydsm.observability.application.port.`in`.GetDashboardSnapshotUseCase import hs.kr.entrydsm.observability.application.port.out.ClientLogStorePort import hs.kr.entrydsm.observability.application.port.out.HealthCheckPort import hs.kr.entrydsm.observability.application.port.out.MetricsStorePort +import hs.kr.entrydsm.observability.application.port.out.LiveLogPublisherPort import hs.kr.entrydsm.observability.application.port.out.RateLimitPort +import hs.kr.entrydsm.observability.application.port.out.ReportGeneratorPort +import hs.kr.entrydsm.observability.application.port.out.ReportObjectStoragePort import hs.kr.entrydsm.observability.application.port.out.RoundPort import hs.kr.entrydsm.observability.application.port.out.ServerLogStorePort import hs.kr.entrydsm.observability.application.port.out.SessionStorePort @@ -58,7 +63,8 @@ class UseCaseConfig { fun clientLogCollectionService( clientLogStorePort: ClientLogStorePort, rateLimitPort: RateLimitPort, - ) = ClientLogCollectionService(clientLogStorePort, rateLimitPort) + liveLogPublisherPort: LiveLogPublisherPort, + ) = ClientLogCollectionService(clientLogStorePort, rateLimitPort, liveLogPublisherPort) @Bean fun clientLogQueryService( @@ -74,4 +80,13 @@ class UseCaseConfig { @Bean fun storageUsageQueryService(storageUsagePort: StorageUsagePort) = StorageUsageQueryService(storageUsagePort) + + @Bean + fun reportService( + getDashboardSnapshotUseCase: GetDashboardSnapshotUseCase, + reportGeneratorPort: ReportGeneratorPort, + reportObjectStoragePort: ReportObjectStoragePort, + roundPort: RoundPort, + clock: Clock, + ) = ReportService(getDashboardSnapshotUseCase, reportGeneratorPort, reportObjectStoragePort, roundPort, clock) }