Skip to content
3 changes: 1 addition & 2 deletions systems/observability/observability-adapter-in/deps.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package hs.kr.entrydsm.observability.adapterin.web.controller

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 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,
) {
@GetMapping("/api/monitor/v11/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun stream(httpRequest: HttpServletRequest): SseEmitter {
val clientKey = clientIp(httpRequest)
if (!connectionLimiter.tryAcquire(clientKey)) {
throw MonitorDomainException(ErrorCode.TOO_MANY_CONNECTIONS)
}
val emitter = SseEmitter(TimeUnit.MINUTES.toMillis(EMITTER_TIMEOUT_MINUTES))
emitter.onCompletion { connectionLimiter.release(clientKey) }
emitter.onTimeout { connectionLimiter.release(clientKey) }
emitter.onError { connectionLimiter.release(clientKey) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

file='systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/MonitorStreamController.kt'

printf '%s\n' '--- controller outline ---'
ast-grep outline "$file" || true

printf '%s\n' '--- controller ---'
cat -n "$file"

printf '%s\n' '--- limiter definitions and usages ---'
rg -n -C 4 'connectionLimiter|class .*Limiter|interface .*Limiter|fun release|fun acquire' systems/observability

printf '%s\n' '--- related tests ---'
rg -n -C 3 'MonitorStreamController|connectionLimiter|SseEmitter|onCompletion|onTimeout|onError' systems/observability --glob '*Test*' --glob '*.kt' || true

Repository: EntryDSM/entrydsm-platform

Length of output: 21189


🏁 Script executed:

#!/bin/bash
set -eu

limiter='systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseConnectionLimiter.kt'

printf '%s\n' '--- limiter ---'
cat -n "$limiter"

printf '%s\n' '--- module files and test layout ---'
git ls-files 'systems/observability/observability-adapter-in' | sed -n '1,240p'

printf '%s\n' '--- all limiter references ---'
rg -n -C 5 'SseConnectionLimiter|tryAcquire\(|release\(' systems/observability --glob '*.kt'

printf '%s\n' '--- Spring callback documentation/source references in repository ---'
rg -n -i -C 3 'onCompletion|onTimeout|onError|ResponseBodyEmitter' . --glob '*.md' --glob '*.kt' --glob '*.java' || true

Repository: EntryDSM/entrydsm-platform

Length of output: 30115


🌐 Web query:

Spring Framework ResponseBodyEmitter SseEmitter onCompletion onTimeout onError callback lifecycle timeout error completion

💡 Result:

In the Spring Framework, ResponseBodyEmitter and its subclass SseEmitter provide mechanisms for asynchronous request processing and streaming, where lifecycle events are managed via specific callback methods [1][2][3]. Lifecycle Callbacks The following methods allow applications to register hooks for specific lifecycle events: - onCompletion(Runnable callback): Registers a callback invoked when the async request completes for any reason, including successful completion, timeout, or network error [1][3]. This is primarily used to detect that the emitter instance is no longer usable [1]. - onTimeout(Runnable callback): Registers a callback invoked when the async request times out [1][3]. - onError(Consumer callback): Registers a callback invoked when an error occurs during async request processing [1][3]. These callbacks are executed from a container thread [1][3]. Timeout and Completion Behavior - Request Timeout: You can specify a custom timeout value (in milliseconds) via the constructor when creating a ResponseBodyEmitter or SseEmitter [1][2]. If not set, the framework uses the default configured in the MVC Java Config or the underlying servlet container [2][4]. - Completion: Applications can call complete or completeWithError(Throwable) to signal the end of request processing [1][5]. - Important Implementation Notes: - When an IOException occurs (e.g., the client disconnects), the application should not manually call complete or completeWithError. Instead, the servlet container initiates an AsyncListener notification, and Spring MVC automatically handles the cleanup [6]. - Recent Spring Framework versions (e.g., 6.2.x) have seen internal adjustments regarding when these flags are set and when callbacks are triggered to address issues with state management and callback invocation consistency [7][8][9]. Users should be aware that attempting to trigger completion within a callback (like onTimeout) may behave differently depending on the specific patch version of the framework [9]. For robust streaming, it is recommended to send periodic heartbeats (e.g., empty comments) because the Servlet API does not provide immediate notification when a remote client disappears, and writes will fail if the client has disconnected [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
MAX_CONNECTIONS_PER_KEY = 3

def try_acquire(count):
    count += 1
    if count <= MAX_CONNECTIONS_PER_KEY:
        return count, True
    return count - 1, False

def release(count):
    return count - 1

# Model one successful stream whose timeout/error callback is followed by
# ResponseBodyEmitter's completion callback.
count = 0
count, acquired = try_acquire(count)
assert acquired and count == 1
count = release(count)  # onTimeout or onError
count = release(count)  # onCompletion
assert count == -1

accepted = 0
for _ in range(4):
    count, acquired = try_acquire(count)
    accepted += acquired

print(f"count_after_double_release={-1}")
print(f"additional_acquisitions_accepted={accepted}")
assert accepted == 4
print("PASS: a non-idempotent release permits four additional acquisitions after one double callback.")
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 324


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
MAX_CONNECTIONS_PER_KEY = 3

def try_acquire(count):
    count += 1
    if count <= MAX_CONNECTIONS_PER_KEY:
        return count, True
    return count - 1, False

def release(count):
    return count - 1

count = 0
count, acquired = try_acquire(count)
assert acquired and count == 1
count = release(count)
count = release(count)
assert count == -1

accepted = 0
for _ in range(4):
    count, acquired = try_acquire(count)
    accepted += acquired

print(f"count_after_double_release={-1}")
print(f"additional_acquisitions_accepted={accepted}")
assert accepted == 4
print("PASS: a non-idempotent release permits four additional acquisitions after one double callback.")
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 324


연결 슬롯을 한 번만 해제하십시오.

ResponseBodyEmitter는 timeout 또는 error 뒤에도 onCompletion을 호출할 수 있습니다. 현재 release가 중복 실행되어 카운터가 음수가 되고, IP별 연결 제한 3개를 초과할 수 있습니다. onCompletion에서만 해제하거나 AtomicBoolean으로 중복 실행을 방지하십시오. timeout과 error 종료 시 카운터가 정확히 한 번 감소하는 테스트도 추가하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/MonitorStreamController.kt`
around lines 26 - 28, MonitorStreamController의 ResponseBodyEmitter 종료 처리에서
connectionLimiter.release(clientKey)가 한 번만 실행되도록 수정하십시오. onCompletion만 해제 경로로
사용하거나 AtomicBoolean으로 onCompletion, onTimeout, onError의 중복 호출을 방지하고, timeout 및
error 종료 시 카운터가 정확히 한 번 감소하는 테스트를 추가하십시오.

sseBroadcaster.register(emitter)
return emitter
}

private fun clientIp(request: HttpServletRequest): String =
request.getHeader("X-Forwarded-For")
?.substringBefore(",")
?.trim()
?.takeIf { it.isNotBlank() }
?: request.remoteAddr

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# X-Forwarded-For를 제거·재설정하는 신뢰된 프록시 설정과 Spring forwarded-header 설정을 확인한다.
fd -HI -t f . | rg -i '(ingress|gateway|nginx|traefik|apache|caddy|values.*ya?ml|.*\.conf$)' |
while IFS= read -r file; do
  rg -n -C 3 'X-Forwarded-For|ForwardedHeaderFilter|forward-headers|remoteIpHeader|proxy_set_header' "$file" || true
done

Repository: EntryDSM/entrydsm-platform

Length of output: 164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

files=(
  systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/MonitorStreamController.kt
  systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseConnectionLimiter.kt
)

for file in "${files[@]}"; do
  echo "===== $file ====="
  wc -l "$file"
  cat -n "$file"
done

echo "===== limiter usages ====="
rg -n -C 4 'SseConnectionLimiter|tryAcquire|release|clientIp|X-Forwarded-For|ForwardedHeaderFilter|server\.forward-headers|forward-headers-strategy|remoteAddr' . \
  -g '!build' -g '!dist' -g '!node_modules' || true

echo "===== proxy and application configuration files ====="
git ls-files | rg -i '(^|/)(application[^/]*\.(yml|yaml|properties|conf)|.*(ingress|gateway|nginx|traefik|apache|caddy|values).*|.*\.(conf|yaml|yml))$' |
while IFS= read -r file; do
  matches=$(rg -n -C 3 'X-Forwarded-For|ForwardedHeaderFilter|forward-headers|remoteIpHeader|proxy_set_header|forward-headers-strategy|server\.forward-headers' "$file" || true)
  if [ -n "$matches" ]; then
    echo "===== $file ====="
    printf '%s\n' "$matches"
  fi
done

Repository: EntryDSM/entrydsm-platform

Length of output: 33135


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== observability adapter tests ====="
git ls-files 'systems/observability/observability-adapter-in/src/test/**' || true
rg -n -C 3 'MonitorStreamController|SseConnectionLimiter|X-Forwarded-For|clientIp' \
  systems/observability/observability-adapter-in/src/test \
  systems/observability/observability-adapter-in/src/main \
  2>/dev/null || true

echo "===== all forwarded-header references ====="
rg -n -C 2 'X-Forwarded-For|ForwardedHeaderFilter|forward-headers-strategy|server\.forward-headers|proxy_set_header' . \
  -g '!build' -g '!dist' -g '!node_modules' || true

echo "===== deterministic limiter behavior model ====="
python3 - <<'PY'
MAX_CONNECTIONS_PER_KEY = 3
counts = {}

def try_acquire(key):
    count = counts.setdefault(key, 0)
    count += 1
    counts[key] = count
    if count <= MAX_CONNECTIONS_PER_KEY:
        return True
    counts[key] -= 1
    return False

def release(key):
    if key in counts:
        counts[key] -= 1

for i in range(10_000):
    key = f"attacker-{i}"
    assert try_acquire(key)
    release(key)

print(f"distinct_keys_after_release={len(counts)}")
print(f"nonzero_keys_after_release={sum(v != 0 for v in counts.values())}")
print(f"sample_count={counts['attacker-0']}")
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 14774


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== SseBroadcaster lifecycle ====="
fd -HI -t f 'SseBroadcaster.kt' systems/observability/observability-adapter-in |
while IFS= read -r file; do
  cat -n "$file"
done

echo "===== atomic-removal interleaving model ====="
python3 - <<'PY'
# Model a common proposed fix:
# release decrements, then removes the map entry when the value reaches zero.
# tryAcquire obtains the AtomicInteger reference first, then increments it.
map_entry = {"k": 1}
entry_reference = map_entry

# Existing connection releases after the new request obtained the reference.
map_entry.pop("k")
entry_reference["value"] = 1  # conceptual AtomicInteger held by the new request
print("map_contains_new_acquisition=", "k" in map_entry)
print("new_acquisition_count_is_tracked=", "k" in map_entry and map_entry["k"] is entry_reference)

echo = None
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 3748


신뢰된 프록시가 정규화한 IP만 제한 키로 사용하십시오.

현재 구현은 요청 경로와 관계없이 X-Forwarded-For의 첫 값을 사용합니다. 애플리케이션이 직접 노출되거나 프록시가 헤더를 덮어쓰지 않으면 공격자는 값을 변경하여 SseConnectionLimitersession:·clientlog: rate limit을 우회할 수 있습니다. 신뢰된 프록시 요청에서만 전달 헤더를 사용하고, 그 외에는 request.remoteAddr를 사용하십시오.

SseConnectionLimiter.release는 카운트가 0이 된 키를 counts에서 제거하지 않습니다. ConcurrentHashMap의 키별 원자 연산으로 acquire와 release를 갱신하고, 마지막 연결 해제 시 키를 제거하십시오. SseConnectionLimiter에 초과 연결, 해제 후 제거, 동시 acquire/release 테스트도 추가하십시오.

📍 Affects 2 files
  • systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/MonitorStreamController.kt#L33-L38 (this comment)
  • systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseConnectionLimiter.kt#L13-L24
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/MonitorStreamController.kt`
around lines 33 - 38, Update MonitorStreamController.clientIp to use
X-Forwarded-For only for requests from a trusted proxy; otherwise use
request.remoteAddr, ensuring all limiter keys use the normalized trusted
address. In SseConnectionLimiter.kt lines 13-24, make acquire and release use
atomic ConcurrentHashMap key operations, remove entries when the final
connection is released, and preserve the excess-connection rejection behavior.
Add tests covering connection-limit rejection, removal after release, and
concurrent acquire/release.


companion object {
private const val EMITTER_TIMEOUT_MINUTES = 30L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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.GetMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@RestController
class ReportController(
private val generateReportUseCase: GenerateReportUseCase,
) {
@GetMapping("/api/monitor/v11/reports")
fun generate(
@RequestParam(defaultValue = "xlsx") format: String,
): ApiResponse<ReportGeneratedResponse> {
val parsedFormat = runCatching { ReportFormat.valueOf(format.trim().uppercase()) }
.getOrElse { throw MonitorDomainException(ErrorCode.INVALID_FORMAT) }
val result = generateReportUseCase.generate(parsedFormat)
return ApiResponse(data = result.toResponse())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

변경된 두 웹 흐름에 MVC 테스트를 추가하십시오.

현재 추가된 ReportServiceTest는 애플리케이션 서비스만 검증합니다. 웹 어댑터의 요청 파싱, HTTP 상태, 헤더, 본문 계약은 검증하지 않습니다.

  • systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportController.kt#L18-L25: 기본 XLSX 형식, CSV 형식, 잘못된 format의 오류 응답을 검증하는 MVC 테스트를 추가하십시오.
  • systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportDownloadController.kt#L16-L22: 유효 토큰의 200 응답 및 첨부 헤더와 만료 또는 없는 토큰의 404 응답을 검증하는 MVC 테스트를 추가하십시오.

As per coding guidelines, "**/*.{kt,go}: If production logic is changed in Kotlin or Go files, require corresponding test updates in the same subsystem unless the PR description explicitly justifies why tests are unnecessary." As per path instructions, "**/*.kt: Highlight behavior-changing code that lacks corresponding unit/integration tests."

📍 Affects 2 files
  • systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportController.kt#L18-L25 (this comment)
  • systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportDownloadController.kt#L16-L22
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportController.kt`
around lines 18 - 25, The MVC test suite requires coverage for both changed web
flows. In ReportController.kt lines 18-25, add tests for the default XLSX
request, CSV format, and invalid format error response; in
ReportDownloadController.kt lines 16-22, add tests for a valid token returning
200 with attachment headers and for expired or missing tokens returning 404.
Verify request parsing, HTTP status, headers, and response bodies using the
subsystem’s existing MVC test conventions.

Sources: Coding guidelines, Path instructions


🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

보고서 생성을 GET에서 분리하십시오.

Line 18의 요청은 파일을 저장하고 Redis 다운로드 토큰을 생성합니다. GET 요청은 안전한 조회여야 합니다. 프리페치 또는 재시도가 이 엔드포인트를 호출하면 불필요한 파일과 토큰을 생성할 수 있습니다. 생성 API를 POST로 변경하십시오.

수정 예시
-import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PostMapping
 ...
-    `@GetMapping`("/api/monitor/v11/reports")
+    `@PostMapping`("/api/monitor/v11/reports")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@GetMapping("/api/monitor/v11/reports")
fun generate(
@RequestParam(defaultValue = "xlsx") format: String,
): ApiResponse<ReportGeneratedResponse> {
val parsedFormat = runCatching { ReportFormat.valueOf(format.trim().uppercase()) }
.getOrElse { throw MonitorDomainException(ErrorCode.INVALID_FORMAT) }
val result = generateReportUseCase.generate(parsedFormat)
return ApiResponse(data = result.toResponse())
`@PostMapping`("/api/monitor/v11/reports")
fun generate(
`@RequestParam`(defaultValue = "xlsx") format: String,
): ApiResponse<ReportGeneratedResponse> {
val parsedFormat = runCatching { ReportFormat.valueOf(format.trim().uppercase()) }
.getOrElse { throw MonitorDomainException(ErrorCode.INVALID_FORMAT) }
val result = generateReportUseCase.generate(parsedFormat)
return ApiResponse(data = result.toResponse())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/controller/ReportController.kt`
around lines 18 - 25, Update the generate method mapping in ReportController
from GET to POST while preserving its format parsing and report-generation
logic.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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<ByteArray> {
val downloaded = reportObjectStoragePort.resolve(token) ?: return ResponseEntity.notFound().build()
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"${downloaded.fileName}\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(downloaded.bytes)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package hs.kr.entrydsm.observability.adapterin.web.security

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.stereotype.Component

/**
* identity의 auth.jwt.secret/issuer와 설정 키 이름을 통일해, 정식 인증이 병합되면
* 같은 시크릿을 가리키도록 맞추기만 하면 되게 한다.
*/
@Component
@ConfigurationProperties(prefix = "auth.jwt")
class JwtAuthProperties {
lateinit var secret: String
lateinit var issuer: String
Comment on lines +18 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthProperties.kt --items all
rg -n -C 3 'ConfigurationPropertiesScan|EnableConfigurationProperties|auth\.jwt|auth:|jwt:|starter-validation|jakarta\.validation' .

Repository: EntryDSM/entrydsm-platform

Length of output: 17569


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- JwtAuthProperties.kt ---'
cat -n systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthProperties.kt

echo '--- JwtAuthInterceptor usages and implementation ---'
rg -n -C 5 'class JwtAuthInterceptor|JwtAuthProperties|\.secret|\.issuer' systems/observability

echo '--- configuration registration ---'
rg -n -C 4 'ComponentScan|ConfigurationProperties|JwtAuthInterceptor|WebMvcConfigurer|addInterceptors' systems/observability

echo '--- related tests ---'
rg -n -C 5 'JwtAuthProperties|JwtAuthInterceptor|auth\.jwt|JWT_SECRET|JWT_ISSUER' systems/observability --glob '*Test.kt' --glob '*test.yaml' --glob '*test.yml' --glob 'application*.yaml' --glob 'application*.yml'

Repository: EntryDSM/entrydsm-platform

Length of output: 34168


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

p = Path("systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthProperties.kt")
text = p.read_text()
required = ["lateinit var secret: String", "lateinit var issuer: String"]
print("properties_file_exists:", p.exists())
for item in required:
    print(f"{item!r}:", item in text)

for path in Path("systems/observability").rglob("*.kt"):
    t = path.read_text()
    if "JwtAuthProperties" in t or "JwtAuthInterceptor" in t:
        print(f"reference: {path}")
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 955


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- build and test configuration for observability modules ---'
rg -n -C 5 'spring-boot-starter-validation|spring-boot-starter-test|testImplementation|application-test|`@SpringBootTest`|`@WebMvcTest`' systems/observability kotlin.MODULE.bazel WORKSPACE* MODULE.bazel 2>/dev/null || true

echo '--- all JWT property declarations and configuration files ---'
rg -n -C 3 'auth:[[:space:]]*$|jwt:[[:space:]]*$|secret:|issuer:' systems/observability --glob '*.yaml' --glob '*.yml' --glob '*.kt'

echo '--- interceptor source candidates ---'
fd -i 'JwtAuthInterceptor|JwtAuthProperties' systems/observability

Repository: EntryDSM/entrydsm-platform

Length of output: 4652


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- observability bootstrap entry point and dependencies ---'
fd -i 'application|main|BUILD|MODULE' systems/observability
rg -n -C 5 'SpringBootApplication|observability-adapter-in|resources|application.yaml' systems/observability --glob '*.kt' --glob 'BUILD*' --glob '*.bzl' --glob '*.yaml' --glob '*.yml'

echo '--- interceptor complete relevant flow ---'
cat -n systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthInterceptor.kt | sed -n '19,45p'

echo '--- bootstrap application configuration ---'
cat -n systems/observability/observability-bootstrap/src/main/resources/application.yaml | sed -n '20,32p'

Repository: EntryDSM/entrydsm-platform

Length of output: 8693


🌐 Web query:

Spring Boot 4 ConfigurationProperties JavaBean lateinit property missing binding startup validation @validated documentation

💡 Result:

In Spring Boot, using Kotlin's lateinit properties with @ConfigurationProperties can lead to runtime issues, such as UninitializedPropertyAccessException [1][2]. This occurs because the Spring Boot binder may attempt to access these properties to determine if they have already been initialized before applying values, which is incompatible with the semantics of lateinit [2]. For reliable configuration binding and validation: 1. Constructor Binding: Use constructor-based property binding instead of lateinit properties [3][4]. This is the recommended, idiomatic way to handle immutable configuration in Kotlin [3]. 2. Validation: To use @Validated effectively with constructor parameters, ensure you use the appropriate annotation use-site targets (e.g., @field:NotBlank) [5][6]. Simply annotating the constructor parameters directly is often insufficient because Hibernate Validator looks for constraints on fields or getters, not constructor arguments [6]. By using use-site targets, you instruct the Kotlin compiler to place the validation annotations where the validator can discover them [5][6]. Example of recommended usage: @Validated @ConfigurationProperties("my.app") data class MyProperties( @field:NotBlank val username: String, @field:NotBlank val password: String) If you must use setter-based injection, standard mutable properties (or nullable types) are generally preferred over lateinit properties to avoid the uninitialized access exceptions during the binding process [5]. Spring Boot validation at startup requires the presence of a JSR-303 implementation, such as the spring-boot-starter-validation dependency [5][7].

Citations:


기본 JWT 시크릿으로 애플리케이션이 시작되지 않게 하세요.

application.yamlJWT_SECRET이 없을 때 알려진 개발용 문자열을 사용합니다. 운영 환경에서 환경 변수가 누락되면 애플리케이션이 정상 시작하고, 공격자가 해당 시크릿으로 JWT를 위조할 수 있습니다.

JWT_SECRET의 기본값을 제거하고 auth.jwt.secret을 시작 시 필수 값으로 검증하세요. 누락 또는 공백 값에 대한 설정 바인딩 테스트도 추가하세요.

🧰 Tools
🪛 detekt (1.23.8)

[warning] 13-13: Usages of lateinit should be avoided.

(detekt.potential-bugs.LateinitUsage)


[warning] 14-14: Usages of lateinit should be avoided.

(detekt.potential-bugs.LateinitUsage)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/security/JwtAuthProperties.kt`
around lines 13 - 14, Remove the development fallback for JWT_SECRET in the
application configuration and make auth.jwt.secret mandatory during startup.
Update JwtAuthProperties so missing or blank secret values fail validation
during configuration binding, and add binding tests covering both absent and
whitespace-only values; keep issuer handling unchanged.

Source: Linters/SAST tools

}
Original file line number Diff line number Diff line change
@@ -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",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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로 교체한다.
*/
@Component
class SseBroadcaster(
private val getDashboardSnapshotUseCase: GetDashboardSnapshotUseCase,
) {
private val emitters = CopyOnWriteArrayList<SseEmitter>()
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) }
Comment on lines +68 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target outline ---'
ast-grep outline systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseBroadcaster.kt || true

printf '%s\n' '--- target source ---'
cat -n systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseBroadcaster.kt

printf '%s\n' '--- relevant symbols and scheduler configuration ---'
rg -n -S \
  'SseBroadcaster|broadcastFrequent|broadcastResource|ping|Scheduled|TaskScheduler|ThreadPoolTaskScheduler|SseEmitter|\.send\(' \
  systems gradle* build.gradle* settings.gradle* 2>/dev/null | head -n 300

printf '%s\n' '--- related tests ---'
rg -l -S 'SseBroadcaster|SseEmitter|broadcastFrequent|broadcastResource' . --glob '*Test.kt' --glob '*Tests.kt' --glob '*test*' | sort

Repository: EntryDSM/entrydsm-platform

Length of output: 12866


🌐 Web query:

site:docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html SseEmitter send blocking TaskScheduler default scheduler @Scheduled``

💡 Result:

SseEmitter writes to the HTTP response are blocking operations [1]. When using SseEmitter, these blocking writes are executed on a separate thread, which is managed by a configured AsyncTaskExecutor [1]. It is important to distinguish between the AsyncTaskExecutor used for SseEmitter and the TaskScheduler typically used for @Scheduled tasks. While the AsyncTaskExecutor handles the blocking I/O required for streaming responses [1], using a default, unconfigured executor in production is discouraged as it is not suitable for handling load [1]. Because SseEmitter sends are blocking, they should not be executed directly on the default TaskScheduler thread pool if that pool is also responsible for executing @Scheduled tasks. Doing so could lead to thread starvation, where blocking I/O operations from SseEmitter prevent scheduled tasks from executing on time. To avoid this, you should ensure that your application uses distinct, appropriately sized thread pools for background streaming tasks versus scheduled periodic tasks. Additionally, since the Servlet API does not notify the server when a client disconnects, you should implement periodic heartbeats to detect failed writes [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- scheduling and executor configuration ---'
rg -n -S \
  '`@EnableScheduling`|`@EnableAsync`|SchedulingConfigurer|configureTasks|TaskScheduler|TaskExecutor|AsyncTaskExecutor|ThreadPoolTaskScheduler|ThreadPoolTaskExecutor|spring\.task\.scheduling|scheduling' \
  . --glob '!**/build/**' --glob '!**/node_modules/**' | head -n 400

printf '%s\n' '--- broadcaster call sites ---'
rg -n -S 'sseBroadcaster\.(register|publishLog)|SseLiveLogPublisher|MonitorStreamController' \
  systems --glob '*.kt' | head -n 300

printf '%s\n' '--- stream and publisher implementations ---'
for f in $(fd -t f -e kt . systems/observability/observability-adapter-in/src/main/kotlin | \
  xargs rg -l -S 'class MonitorStreamController|class SseLiveLogPublisher'); do
  echo "--- $f"
  cat -n "$f"
done

printf '%s\n' '--- test files in the observability adapter-in subsystem ---'
git ls-files 'systems/observability/observability-adapter-in' | \
  rg '(^|/)(test|tests)(/|$)|Test\.kt$|Tests\.kt$' || true

Repository: EntryDSM/entrydsm-platform

Length of output: 5359


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

target = Path("systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseBroadcaster.kt")
text = target.read_text()

checks = {
    "scheduled methods call broadcast": bool(re.search(r'`@Scheduled`[\s\S]*?fun broadcastFrequent[\s\S]*?broadcast\(', text)),
    "scheduled methods call emitter.send": bool(re.search(r'`@Scheduled`[\s\S]*?fun ping[\s\S]*?emitter\.send\(', text)),
    "broadcast iterates emitters": bool(re.search(r'emitters\.forEach\s*\{\s*emitter\s*->[\s\S]*?emitter\.send\(', text)),
    "send failures remove emitter": bool(re.search(r'\.onFailure\s*\{\s*emitters\.remove\(emitter\)', text)),
}
for name, result in checks.items():
    print(f"{name}: {result}")

for m in re.finditer(r'(`@Scheduled`[^\n]*\n\s*fun\s+\w+\(\)[\s\S]*?)(?=\n\s*`@Scheduled`|\n\s*private fun|\n\s*companion object)', text):
    block = m.group(1)
    method = re.search(r'fun\s+(\w+)', block).group(1)
    sends = len(re.findall(r'\.send\(', block))
    broadcasts = len(re.findall(r'\bbroadcast\(', block))
    print(f"{method}: send_calls={sends}, broadcast_calls={broadcasts}")
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 454


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- application bootstrap ---'
cat -n systems/observability/observability-bootstrap/src/main/kotlin/hs/kr/entrydsm/ExampleApplication.kt

printf '%s\n' '--- Spring dependency versions ---'
rg -n -S \
  'spring-boot|spring-framework|springBoot|org\.springframework' \
  gradle.properties settings.gradle* build.gradle* systems --glob '*.gradle' --glob '*.gradle.kts' --glob '*.toml' --glob '*.properties' | head -n 250

printf '%s\n' '--- all scheduled methods ---'
rg -n -B 3 -A 12 '`@Scheduled`' . --glob '*.kt' --glob '*.java' --glob '!**/build/**'

Repository: EntryDSM/entrydsm-platform

Length of output: 6422


🌐 Web query:

site:docs.spring.io/spring-framework/reference/integration/scheduling.html @scheduled default TaskScheduler single-threaded

💡 Result:

By default, Spring's @Scheduled annotation uses a single-threaded TaskScheduler [1]. When scheduling tasks without an explicitly configured scheduler bean, Spring defaults to a TaskScheduler with a pool size of one [1]. If you are using XML configuration, the scheduler element defaults to a single-threaded pool if the pool-size attribute is omitted [1]. Similarly, when configuring programmatic scheduling, the default implementation often relies on a scheduler configured with a single thread unless otherwise specified. Consequently, if multiple @Scheduled tasks are configured, they will be executed sequentially by this single thread [1]. To execute tasks in parallel, you must explicitly configure a TaskScheduler bean with a larger pool size [1].

Citations:


🌐 Web query:

site:docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html ResponseBodyEmitter SseEmitter blocking writes AsyncTaskExecutor send

💡 Result:

In Spring MVC, while ResponseBodyEmitter and its subclass SseEmitter support asynchronous streaming, individual writes to the response remain inherently blocking [1]. When you return an SseEmitter from a controller method, Spring MVC executes the writing process on a separate thread provided by the configured AsyncTaskExecutor [1]. This architecture ensures that the main servlet container thread is not blocked, allowing it to return to the thread pool while the SSE stream continues to process data [1]. Key points regarding the interaction between these components include: 1. Blocking Nature of Writes: Although the overall programming model is asynchronous, the actual I/O operations for each write to the response are blocking [1]. This is a fundamental difference between Spring MVC and Spring WebFlux; WebFlux utilizes non-blocking I/O and does not require a dedicated thread per write [1]. 2. Role of AsyncTaskExecutor: The AsyncTaskExecutor is responsible for handling the blocking writes when streaming, as well as executing tasks associated with asynchronous request processing (such as Callable or DeferredResult) [1]. 3. Performance Considerations: Spring explicitly notes that the default AsyncTaskExecutor is often not suitable for production environments under heavy load [1]. It is recommended to configure a custom TaskExecutor (e.g., using a ThreadPoolTaskExecutor) with appropriately tuned parameters to prevent thread exhaustion or performance degradation during streaming operations [1]. By offloading these blocking writes to a separate thread pool managed by the AsyncTaskExecutor, Spring MVC maintains scalability even while using blocking I/O for streaming responses [1].

Citations:


느린 SSE 구독자의 전송을 분리하십시오.

emitters.forEachSseEmitter.send를 순차 실행합니다. SseEmitter.send의 블로킹 쓰기 때문에 느린 연결 하나가 다른 연결과 broadcastFrequent, broadcastResource, ping을 지연시킬 수 있습니다. 별도 TaskScheduler 설정이 없으므로 @Scheduled 작업은 기본 단일 스레드에서 실행됩니다.

전용 bounded executor와 연결별 bounded outbound queue를 사용하십시오. 큐가 가득 차면 해당 emitter를 종료하십시오. 스케줄러 및 API 요청 스레드에서 직접 send를 호출하지 마십시오. 연결별 전송 순서와 큐 포화 동작을 검증하는 테스트도 추가하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseBroadcaster.kt`
around lines 66 - 69, Update SseBroadcaster.broadcast and the emitter lifecycle
so scheduled or API threads only enqueue events, while each emitter drains
through a dedicated bounded executor and per-connection bounded outbound queue.
Preserve event order per connection, remove and complete emitters whose queue is
full, and ensure send failures also clean up the connection; add tests covering
ordered delivery and queue-saturation termination.

}
}

private fun nextId(): String = eventIdSeq.incrementAndGet().toString()

companion object {
private const val RECONNECT_TIME_MS = 5000L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package hs.kr.entrydsm.observability.adapterin.web.sse

import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import org.springframework.stereotype.Component

/**
* ponytail: 단일 인스턴스 가정의 인메모리 카운터. 인증 붙기 전이라 계정 대신 IP 기준으로 제한한다.
* JWT 인증이 연결되면 계정 단위로 교체한다.
*/
@Component
class SseConnectionLimiter {
private val counts = ConcurrentHashMap<String, AtomicInteger>()

fun tryAcquire(key: String): Boolean {
val count = counts.computeIfAbsent(key) { AtomicInteger(0) }
if (count.incrementAndGet() <= MAX_CONNECTIONS_PER_KEY) return true
count.decrementAndGet()
return false
}

fun release(key: String) {
counts[key]?.decrementAndGet()
}

companion object {
private const val MAX_CONNECTIONS_PER_KEY = 3
}
}
Original file line number Diff line number Diff line change
@@ -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,
),
)
}
}
Loading
Loading