Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
97c7c41
feat(observability): 모니터링 API 공용 기반 구축 #29
tlgms Aug 9, 2026
b0d675f
feat(observability): Redis 세션 저장소 및 rate limit 어댑터 추가 #29
tlgms Aug 9, 2026
d633e54
feat(observability): 세션 이벤트 수집 API 구현 #29
tlgms Aug 9, 2026
37cc502
feat(observability): 서비스 헬스체크 API 구현 #29
tlgms Aug 9, 2026
fc6beee
feat(observability): 대시보드 스냅샷 API 구현 #29
tlgms Aug 9, 2026
4e1b95b
feat(observability): 시간대별 지표 조회 API 구현 #29
tlgms Aug 9, 2026
7dd3477
feat(observability): 클라이언트 로그 수집 API 구현 #29
tlgms Aug 9, 2026
2ef5c6a
feat(observability): 클라이언트 오류/경고 목록 조회 API 구현 #29
tlgms Aug 9, 2026
0dd9447
feat(observability): 서버 API 오류 목록 조회 API 구현 #29
tlgms Aug 9, 2026
5ce0b76
feat(observability): 스토리지 사용량 조회 API 구현 #29
tlgms Aug 9, 2026
2ab89bd
feat(observability): 모니터링 리포트 다운로드 API 구현 #29
tlgms Aug 9, 2026
0bcd346
feat(observability): 실시간 지표 SSE 구독 API 구현 #29
tlgms Aug 9, 2026
4ccbb43
feat(observability): JWT 인증 인터셉터 배선 #29
tlgms Aug 9, 2026
4fb7802
fix(observability): Redis 갱신 원자성 확보 및 유스케이스 Spring 의존성 제거 #29
tlgms Aug 20, 2026
c6915ee
Merge branch 'feature/29-observability-01-foundation' into feature/29…
tlgms Aug 20, 2026
2ca5e1f
fix(observability): 클라이언트 IP 신뢰 경계와 로그 저장소 원자성·보존 정책 보완 #29
tlgms Aug 20, 2026
4272ae9
Merge branch 'feature/29-observability-02-core-apis' into feature/29-…
tlgms Aug 20, 2026
b9f2db8
fix(observability): 로그 수집·조회의 입력 검증과 신뢰 경계 보완 #29
tlgms Aug 20, 2026
19e0aea
Merge branch 'feature/29-observability-03-logs-resources' into featur…
tlgms Aug 20, 2026
deceb01
fix(observability): SSE 커넥션 제한·리포트 저장·JWT 설정 검증 보완 #29
tlgms Aug 20, 2026
655dcd9
Merge remote-tracking branch 'origin/develop' into feature/29-observa…
tlgms Aug 20, 2026
fc3cab3
Merge branch 'feature/29-observability-01-foundation' into feature/29…
tlgms Aug 20, 2026
6f473e0
Merge branch 'feature/29-observability-02-core-apis' into feature/29-…
tlgms Aug 20, 2026
d54d514
Merge branch 'feature/29-observability-03-logs-resources' into featur…
tlgms Aug 20, 2026
08a5e39
fix(observability): Redis 갱신 원자성 확보 및 유스케이스 Spring 의존성 제거 #29
tlgms Aug 20, 2026
0233a78
Merge remote-tracking branch 'origin/develop' into feature/29-observa…
tlgms Aug 20, 2026
2ee2e68
Merge branch 'feature/29-observability-01-foundation' into feature/29…
tlgms Aug 20, 2026
005a406
Merge branch 'feature/29-observability-02-core-apis' into feature/29-…
tlgms Aug 20, 2026
bc55d78
Merge branch 'feature/29-observability-03-logs-resources' into featur…
tlgms Aug 20, 2026
6c4230f
Merge pull request #70 from EntryDSM/feature/29-observability-02-core…
tlgms Aug 27, 2026
9eb8aa8
Merge pull request #72 from EntryDSM/feature/29-observability-도메인-개발
tlgms Aug 27, 2026
19005cb
Merge pull request #71 from EntryDSM/feature/29-observability-03-logs…
tlgms Aug 27, 2026
7364143
Merge remote-tracking branch 'origin/develop' into feature/29-observa…
tlgms Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions kotlin.MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,16 @@ maven.artifact(artifact = "protobuf-java", group = "com.google.protobuf", versio
maven.artifact(artifact = "protobuf-kotlin", group = "com.google.protobuf", version = "4.30.2")
maven.artifact(artifact = "javax.annotation-api", group = "javax.annotation", version = "1.3.2")

# Redis
maven.artifact(artifact = "spring-boot-starter-data-redis", group = "org.springframework.boot", version = "4.0.1")

# JWT
maven.artifact(artifact = "jjwt-api", group = "io.jsonwebtoken", version = "0.13.0")
maven.artifact(artifact = "jjwt-impl", group = "io.jsonwebtoken", version = "0.13.0")
maven.artifact(artifact = "jjwt-jackson", group = "io.jsonwebtoken", version = "0.13.0")

# 리포트 생성 (xlsx)
maven.artifact(artifact = "poi", group = "org.apache.poi", version = "5.4.0")
maven.artifact(artifact = "poi-ooxml", group = "org.apache.poi", version = "5.4.0")

use_repo(maven, "maven")
2 changes: 1 addition & 1 deletion systems/observability/observability-adapter-in/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ kt_jvm_test(
javac_opts = "//:javac_options",
kotlinc_opts = "//:kotlinc_options",
test_class = "hs.kr.entrydsm.observability.adapterin.ObservabilityAdapterInModuleTest",
deps = MODULE_DEPS + TEST_DEPS,
deps = [":main"] + MODULE_DEPS + TEST_DEPS,
)
12 changes: 11 additions & 1 deletion systems/observability/observability-adapter-in/deps.bzl
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
KOTLIN_DEPS = []
KOTLIN_DEPS = [
"@maven//:org_springframework_boot_spring_boot_starter_web",
"@maven//:org_springframework_boot_spring_boot_starter_validation",
"@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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package hs.kr.entrydsm.observability.adapterin.web.dto.common

data class ApiResponse<T>(
val success: Boolean = true,
val data: T?,
val error: ErrorDetail? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package hs.kr.entrydsm.observability.adapterin.web.dto.common

import hs.kr.entrydsm.observability.domain.enum.ErrorCode

data class ErrorDetail(
val code: String,
val message: String,
val status: Int,
) {
companion object {
fun from(errorCode: ErrorCode) = ErrorDetail(
errorCode.name,
errorCode.message,
errorCode.status,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package hs.kr.entrydsm.observability.adapterin.web.dto.common

import java.time.Instant

data class ErrorResponse(
val success: Boolean = false,
val error: ErrorDetail,
val timestamp: Instant = Instant.now(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package hs.kr.entrydsm.observability.adapterin.web.exception

import hs.kr.entrydsm.observability.adapterin.web.dto.common.ErrorDetail
import hs.kr.entrydsm.observability.adapterin.web.dto.common.ErrorResponse
import hs.kr.entrydsm.observability.domain.enum.ErrorCode
import hs.kr.entrydsm.observability.domain.exception.MonitorException
import jakarta.validation.ConstraintViolationException
import org.slf4j.LoggerFactory
import org.slf4j.MDC
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.validation.BindException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.MissingPathVariableException
import org.springframework.web.bind.MissingRequestHeaderException
import org.springframework.web.bind.MissingServletRequestParameterException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import org.springframework.web.multipart.support.MissingServletRequestPartException

@RestControllerAdvice
class GlobalExceptionHandler {
private val logger = LoggerFactory.getLogger(javaClass)

@ExceptionHandler(MonitorException::class)
fun handleMonitorException(exception: MonitorException): ResponseEntity<ErrorResponse> =
response(exception.errorCode)

@ExceptionHandler(
HttpMessageNotReadableException::class,
BindException::class,
ConstraintViolationException::class,
MethodArgumentNotValidException::class,
MissingPathVariableException::class,
MissingRequestHeaderException::class,
MissingServletRequestParameterException::class,
MissingServletRequestPartException::class,
MethodArgumentTypeMismatchException::class,
)
fun handleInvalidRequest(exception: Exception): ResponseEntity<ErrorResponse> =
response(ErrorCode.INVALID_PAYLOAD)

@ExceptionHandler(Exception::class)
fun handleUnhandledException(exception: Exception): ResponseEntity<ErrorResponse> =
response(ErrorCode.INTERNAL_SERVER_ERROR).also {
logger.error(
"Unhandled exception [X-trace-Id={}]",
MDC.get("X-trace-Id") ?: "unknown",
exception,
)
}

private fun response(errorCode: ErrorCode): ResponseEntity<ErrorResponse> =
ResponseEntity
.status(errorCode.status)
.body(
ErrorResponse(
error = ErrorDetail.from(errorCode)
),
)
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package hs.kr.entrydsm.observability.adapterin

import org.junit.Assert.assertTrue
import org.junit.Test
import hs.kr.entrydsm.observability.adapterin.web.exception.GlobalExceptionHandlerTest
import org.junit.runner.RunWith
import org.junit.runners.Suite

class ObservabilityAdapterInModuleTest {
@Test
fun moduleLoads() {
assertTrue(true)
}
}
@RunWith(Suite::class)
@Suite.SuiteClasses(
GlobalExceptionHandlerTest::class,
)
class ObservabilityAdapterInModuleTest
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package hs.kr.entrydsm.observability.adapterin.web.exception

import hs.kr.entrydsm.observability.domain.enum.ErrorCode
import hs.kr.entrydsm.observability.domain.exception.MonitorDomainException
import jakarta.validation.ConstraintViolationException
import org.junit.Assert.assertEquals
import org.junit.Test
import org.slf4j.MDC
import org.springframework.validation.BindException

class GlobalExceptionHandlerTest {
private val handler = GlobalExceptionHandler()

@Test
fun mapsMonitorExceptionToItsErrorResponse() {
val response = handler.handleMonitorException(MonitorDomainException(ErrorCode.SESSION_NOT_FOUND))

assertEquals(404, response.statusCode.value())
assertEquals("SESSION_NOT_FOUND", response.body?.error?.code)
assertEquals(ErrorCode.SESSION_NOT_FOUND.message, response.body?.error?.message)
}

@Test
fun mapsInvalidRequestToBadRequestResponse() {
val response = handler.handleInvalidRequest(IllegalArgumentException())

assertEquals(400, response.statusCode.value())
assertEquals("INVALID_PAYLOAD", response.body?.error?.code)
}

@Test
fun mapsValidationExceptionsToBadRequestResponse() {
val responses = listOf(
handler.handleInvalidRequest(BindException(this, "request")),
handler.handleInvalidRequest(ConstraintViolationException(emptySet())),
)

responses.forEach { response ->
assertEquals(400, response.statusCode.value())
assertEquals("INVALID_PAYLOAD", response.body?.error?.code)
}
}

@Test
fun keepsGenericResponseAndLogsCorrelationContextForUnhandledException() {
MDC.put("X-trace-Id", "test-trace-id")
try {
val response = handler.handleUnhandledException(IllegalStateException("internal detail"))

assertEquals(500, response.statusCode.value())
assertEquals("INTERNAL_SERVER_ERROR", response.body?.error?.code)
assertEquals(ErrorCode.INTERNAL_SERVER_ERROR.message, response.body?.error?.message)
} finally {
MDC.remove("X-trace-Id")
}
Comment on lines +45 to +55

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 | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 3 --glob '*.kt' 'ListAppender|OutputCapture|MDC|LoggerFactory' systems/observability
rg -n -C 3 --glob 'BUILD.bazel' --glob '*.bzl' 'logback|slf4j|spring_boot_starter_test' systems/observability

Repository: EntryDSM/entrydsm-platform

Length of output: 8638


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- test outline ---'
ast-grep outline systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/observability/adapterin/web/exception/GlobalExceptionHandlerTest.kt
printf '%s\n' '--- implementation ---'
cat -n systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/exception/GlobalExceptionHandler.kt
printf '%s\n' '--- test ---'
cat -n systems/observability/observability-adapter-in/src/test/kotlin/hs/kr/entrydsm/observability/adapterin/web/exception/GlobalExceptionHandlerTest.kt
printf '%s\n' '--- module/build files ---'
fd -i 'BUILD|BUILD.bazel|deps.bzl|.*logback.*|.*logging.*' systems/observability
printf '%s\n' '--- relevant test patterns ---'
rg -n -C 4 --glob '*.kt' --glob '*.java' \
  'ListAppender|OutputCapture|Appender|ILoggingEvent|assert.*log|LoggingEvent|TestAppender|logCaptor|LogCaptor' .

Repository: EntryDSM/entrydsm-platform

Length of output: 6967


로그 상관관계를 단언하세요.

keepsGenericResponseAndLogsCorrelationContextForUnhandledException는 응답만 검증합니다. 로그 캡처 도구를 사용해 logger.error 이벤트와 X-trace-Id=test-trace-id를 단언하세요.

🤖 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/test/kotlin/hs/kr/entrydsm/observability/adapterin/web/exception/GlobalExceptionHandlerTest.kt`
around lines 45 - 55, Update
keepsGenericResponseAndLogsCorrelationContextForUnhandledException to capture
logger.error output while invoking handleUnhandledException, then assert an
error event was emitted containing the correlation context
X-trace-Id=test-trace-id. Preserve the existing generic response assertions and
MDC cleanup.

Source: Path instructions

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ kt_jvm_test(
javac_opts = "//:javac_options",
kotlinc_opts = "//:kotlinc_options",
test_class = "hs.kr.entrydsm.observability.adapterout.ObservabilityAdapterOutModuleTest",
deps = MODULE_DEPS + TEST_DEPS,
deps = [":main"] + MODULE_DEPS + TEST_DEPS,
)
6 changes: 5 additions & 1 deletion systems/observability/observability-adapter-out/deps.bzl
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
KOTLIN_DEPS = []
KOTLIN_DEPS = [
"@maven//:org_springframework_boot_spring_boot_starter_data_redis",
"//systems/observability/observability-application:main",
"//systems/observability/observability-domain:main",
]

TEST_DEPS = [
"@maven//:junit_junit",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package hs.kr.entrydsm.observability.adapterout.redis

import hs.kr.entrydsm.observability.application.port.out.RateLimitPort
import java.time.Duration
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.stereotype.Component

/** IP 기준 고정 윈도우 카운터. 이미 Redis를 쓰므로 별도 rate-limit 라이브러리 없이 INCR+EXPIRE로 구현한다. */
@Component
class RedisRateLimitAdapter(
private val redis: StringRedisTemplate,
) : RateLimitPort {
override fun tryAcquire(key: String, limit: Long, windowSeconds: Long): Boolean {
val bucket = System.currentTimeMillis() / (windowSeconds * 1000)
val redisKey = "monitor:ratelimit:$key:$bucket"
val count = redis.opsForValue().increment(redisKey) ?: 1L
if (count == 1L) {
redis.expire(redisKey, Duration.ofSeconds(windowSeconds))
}

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

printf '%s\n' '--- candidate file ---'
ast-grep outline systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisRateLimitAdapter.kt
cat -n systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisRateLimitAdapter.kt

printf '%s\n' '--- related symbols and tests ---'
rg -n --glob '*.kt' 'RedisRateLimitAdapter|tryAcquire|RateLimit|expire\\(|opsForValue\\(\\)\\.increment' systems

Repository: EntryDSM/entrydsm-platform

Length of output: 1567


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- related symbols ---'
rg -n --glob '*.kt' 'RedisRateLimitAdapter|tryAcquire|RateLimit|expire\(|opsForValue\(\)\.increment' systems || true

printf '%s\n' '--- candidate-module files ---'
git ls-files 'systems/observability/observability-adapter-out' | sed -n '1,160p'

printf '%s\n' '--- build declarations mentioning Redis ---'
rg -n --glob 'build.gradle*' --glob '*.gradle.kts' --glob '*.gradle' 'redis|spring-data' . || true

printf '%s\n' '--- Redis adapter tests ---'
rg -n --glob '*Test.kt' --glob '*Tests.kt' 'RateLimit|RedisRateLimit|tryAcquire|ratelimit' . || true

Repository: EntryDSM/entrydsm-platform

Length of output: 7383


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- rate-limit callers and constants ---'
cat -n systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/SessionCollectionService.kt
cat -n systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/RateLimitPort.kt
cat -n systems/observability/observability-adapter-out/BUILD.bazel
cat -n systems/observability/observability-adapter-out/src/test/kotlin/hs/kr/entrydsm/TestMain.kt

printf '%s\n' '--- read-only behavioral model of the two-command sequence ---'
python3 - <<'PY'
class RedisModel:
    def __init__(self):
        self.values = {}
        self.expiry = {}

    def incr(self, key):
        self.values[key] = self.values.get(key, 0) + 1
        return self.values[key]

    def expire(self, key, seconds):
        self.expiry[key] = seconds

def try_acquire_with_failure(redis, key, window_seconds, fail_before_expire=False):
    count = redis.incr(key)
    if count == 1:
        if fail_before_expire:
            raise ConnectionError("failure between INCR and EXPIRE")
        redis.expire(key, window_seconds)
    return count

redis = RedisModel()
try:
    try_acquire_with_failure(redis, "monitor:ratelimit:client:1", 60, True)
except ConnectionError:
    pass

key = "monitor:ratelimit:client:1"
assert redis.values[key] == 1
assert key not in redis.expiry
print("INCR committed while EXPIRE was skipped: key exists=%s, ttl=%s" %
      (key in redis.values, redis.expiry.get(key)))
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 4943


🌐 Web query:

Redis INCR EXPIRE separate commands key without expiration Lua script atomic official documentation

💡 Result:

To atomically increment a key and set an expiration in Redis, you have two primary options: using the native INCREX command or a Lua script [1][2][3]. 1. Using INCREX (Recommended) The INCREX command is the preferred, native way to perform an atomic increment with an optional expiration [2][3]. It collapses the two-step pattern into a single O(1) command, ensuring atomicity and reducing round-trips [3]. Example usage: INCREX mykey BY 1 EX 60 2. Using a Lua Script If you are using an older version of Redis or have custom requirements, you can use a Lua script executed via the EVAL command [1][4]. Redis guarantees that scripts execute atomically, blocking all other server activities until the script completes [5][6]. The standard pattern to ensure the key is only expired on its first creation (or specific condition) is: local current = redis.call("incr", KEYS[1]) if current == 1 then redis.call("expire", KEYS[1], ARGV[1]) end return current You would call this via: EVAL "local current = redis.call('incr', KEYS[1]); if current == 1 then redis.call('expire', KEYS[1], ARGV[1]) end; return current" 1 mykey 60 Why not just use separate commands? Sending INCR and EXPIRE as separate commands is not atomic [1][4]. If a client fails between the two commands, the key will exist without an expiration, leading to potential "key leakage" where the key persists indefinitely [1][4]. While Redis Transactions (MULTI/EXEC) can group commands, they do not provide the ability to use the result of one command (e.g., the new counter value) to conditionally decide whether to execute the next (e.g., setting the expiration) within the same transaction [7][8]. Lua scripts or native commands like INCREX are necessary for such conditional logic [2][3][8].

Citations:


INCR과 TTL 설정을 원자적으로 실행하세요.

increment()expire()가 별도 명령이므로, 첫 INCR 후 실패하면 키가 TTL 없이 남습니다. Lua 스크립트에서 INCR 결과가 1일 때 EXPIRE를 함께 실행하세요. 실패 경로를 검증하는 테스트도 추가하세요.

🤖 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-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisRateLimitAdapter.kt`
around lines 16 - 19, RedisRateLimitAdapter의 increment 및 expire 호출을 Lua 스크립트로
통합해 INCR 결과가 1일 때 EXPIRE가 동일한 원자적 작업으로 실행되도록 수정하세요. 기존 windowSeconds와 redisKey
값을 스크립트 인자로 전달하고, 실패 시 TTL 없는 키가 남지 않는 경로를 검증하는 테스트를 추가하세요.

return count <= limit
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package hs.kr.entrydsm.observability.adapterout.redis

import hs.kr.entrydsm.observability.application.port.out.SessionStorePort
import hs.kr.entrydsm.observability.domain.enum.DeviceType
import hs.kr.entrydsm.observability.domain.enum.ServiceName
import java.time.Duration
import java.time.Instant
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.stereotype.Component

/**
* 세션·트래픽 집계를 Redis에 저장한다.
* ponytail: 다중 인스턴스에서도 Redis가 단일 소스라 별도 확장 작업 없이 그대로 동작한다.
*/
@Component
class RedisSessionStoreAdapter(
private val redis: StringRedisTemplate,
) : SessionStorePort {

override fun enter(sessionId: String, service: ServiceName, deviceType: DeviceType, now: Instant) {
val nowMillis = now.toEpochMilli()
val hashOps = redis.opsForHash<String, String>()
hashOps.putAll(
metaKey(sessionId),
mapOf(
FIELD_SERVICE to service.name,
FIELD_ENTERED_AT to nowMillis.toString(),
FIELD_LAST_HEARTBEAT_AT to nowMillis.toString(),
),
)
redis.expire(metaKey(sessionId), SESSION_TTL)
touchWindow(sessionId, service, nowMillis)
redis.opsForSet().add(VISITORS_KEY, sessionId)
hashOps.increment(DEVICE_COUNTS_KEY, deviceType.name, 1)
}

override fun heartbeat(sessionId: String, service: ServiceName, now: Instant): Boolean {
if (!redis.hasKey(metaKey(sessionId))) return false
val nowMillis = now.toEpochMilli()
val hashOps = redis.opsForHash<String, String>()
hashOps.put(metaKey(sessionId), FIELD_SERVICE, service.name)
hashOps.put(metaKey(sessionId), FIELD_LAST_HEARTBEAT_AT, nowMillis.toString())
redis.expire(metaKey(sessionId), SESSION_TTL)
touchWindow(sessionId, service, nowMillis)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
git ls-files | rg 'RedisSessionStoreAdapter|SessionStore|observability.*redis|redis' | head -200
printf '%s\n' '--- outline ---'
ast-grep outline systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt
printf '%s\n' '--- implementation ---'
cat -n systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt
printf '%s\n' '--- symbols/usages ---'
rg -n -C 3 'concurrentUsers|heartbeat\\(|touchWindow|FIELD_SERVICE|SESSION_TTL|RedisSessionStoreAdapter' systems/observability

Repository: EntryDSM/entrydsm-platform

Length of output: 7846


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- files ---'
git ls-files | rg 'RedisSessionStoreAdapter|SessionStore|observability.*redis|redis' | head -200
printf '%s\n' '--- implementation ---'
cat -n systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt
printf '%s\n' '--- usages ---'
rg -n -C 3 'concurrentUsers|heartbeat|touchWindow|FIELD_SERVICE|SESSION_TTL|RedisSessionStoreAdapter' systems/observability

Repository: EntryDSM/entrydsm-platform

Length of output: 28979


🏁 Script executed:

#!/bin/bash
set -eu
cat -n systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt
rg -n -C 5 'concurrentUsers|heartbeat|touchWindow|FIELD_SERVICE|SESSION_TTL|RedisSessionStoreAdapter' systems/observability

Repository: EntryDSM/entrydsm-platform

Length of output: 36364


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- adapter ---'
sed -n '1,180p' systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt
printf '%s\n' '--- related test files ---'
git ls-files | rg '(^|/)(test|tests)/|Test\\.kt$|Spec\\.kt$' | rg 'observ|redis|session' | head -200
printf '%s\n' '--- all relevant declarations ---'
rg -n -C 8 'fun (heartbeat|leave|concurrentUsers|touchWindow)|FIELD_SERVICE|ZADD|zadd|zrem|remove.*window|concurrentUsers' .

Repository: EntryDSM/entrydsm-platform

Length of output: 39421


서비스 소속 변경을 차단하세요.

heartbeat()는 세션의 FIELD_SERVICE를 덮어쓰고 새 서비스 ZSET에만 추가합니다. 기존 서비스 ZSET의 멤버가 남아 동일 세션이 여러 서비스의 concurrentUsers()에 집계됩니다. 저장된 서비스와 요청 service가 다르면 heartbeat를 실패시키고, 일치할 때만 저장된 서비스 window를 갱신하세요. 이 동작을 검증하는 Kotlin 테스트도 추가하세요.

🤖 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-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt`
around lines 41 - 44, RedisSessionStoreAdapter의 heartbeat()에서 저장된 FIELD_SERVICE를
먼저 조회해 요청 service와 다르면 실패시키고 기존 서비스 값이나 ZSET을 변경하지 않도록 하세요. 서비스가 일치할 때만
FIELD_LAST_HEARTBEAT_AT, TTL, touchWindow()를 갱신하고 저장된 서비스 기준으로 window를 처리하세요. 이
서비스 소속 변경 차단 동작을 검증하는 Kotlin 테스트를 추가하세요.

return true
Comment on lines +39 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
file="systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt"
printf '%s\n' '--- file outline ---'
ast-grep outline "$file" || true
printf '%s\n' '--- relevant source ---'
sed -n '1,220p' "$file"
printf '%s\n' '--- related symbols and tests ---'
rg -n --glob '*.kt' 'RedisSessionStoreAdapter|heartbeat\(|leave\(|touchWindow|SESSION_TTL|DURATION_SUM_KEY|DURATION_COUNT_KEY' systems

Repository: EntryDSM/entrydsm-platform

Length of output: 11466


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- adapter module files ---'
git ls-files 'systems/observability/observability-adapter-out'
printf '%s\n' '--- adapter tests and Redis configuration ---'
rg -n --glob '*.kt' --glob '*.gradle*' --glob '*.kts' \
  'RedisSessionStoreAdapter|StringRedisTemplate|RedisTemplate|`@DataRedisTest`|RedisConnectionFactory|observability-adapter-out' \
  systems build.gradle* settings.gradle* gradle* 2>/dev/null || true
printf '%s\n' '--- application call sites ---'
sed -n '1,130p' systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/SessionCollectionService.kt
printf '%s\n' '--- port contract ---'
sed -n '1,100p' systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/SessionStorePort.kt

Repository: EntryDSM/entrydsm-platform

Length of output: 5557


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from itertools import permutations

heartbeat = ["H.hasKey", "H.put(service)", "H.put(heartbeat)", "H.expire", "H.window"]
leave = ["L.entries", "L.duration", "L.delete", "L.removeWindows"]

bad = []
for order in permutations("H" * len(heartbeat) + "L" * len(leave)):
    hi = li = 0
    state = "present"
    recreated = False
    deleted_after_read = False
    for actor in order:
        if actor == "H":
            cmd = heartbeat[hi]
            hi += 1
            if cmd == "H.hasKey":
                heartbeat_seen = state == "present"
            elif cmd == "H.put(service)" and heartbeat_seen and state == "deleted":
                recreated = True
            elif cmd == "H.put(heartbeat)" and heartbeat_seen and state == "deleted":
                recreated = True
        else:
            cmd = leave[li]
            li += 1
            if cmd == "L.entries":
                leave_seen = state == "present"
            elif cmd == "L.delete" and leave_seen:
                state = "deleted"
        if recreated:
            bad.append(order)
            break

print("interleaving reproduces heartbeat-after-leave recreation:", bool(bad))
if bad:
    print("example:", " ".join(bad[0]))
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 254


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from itertools import permutations

# Model the command-level behavior in RedisSessionStoreAdapter.kt.
# A leave call reads the metadata before it increments duration and deletes the key.
commands = {
    "A": ["entries", "increment_sum", "increment_count", "delete", "remove_windows"],
    "B": ["entries", "increment_sum", "increment_count", "delete", "remove_windows"],
}
order = ["A"] * len(commands["A"]) + ["B"] * len(commands["B"])

duplicate_counted = False
example = None
for schedule in permutations(order):
    positions = {"A": 0, "B": 0}
    observed = {"A": False, "B": False}
    increments = 0

    for actor in schedule:
        command = commands[actor][positions[actor]]
        positions[actor] += 1

        if command == "entries":
            observed[actor] = True
        elif command == "increment_sum" and observed[actor]:
            increments += 1

    if increments == 2:
        duplicate_counted = True
        example = schedule
        break

print("two concurrent leave calls can both count one session:", duplicate_counted)
if example:
    print("example:", " ".join(example))
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 253


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from itertools import product

heartbeat = ["hasKey", "put_service", "put_heartbeat", "expire_meta", "window_updates"]
leave = ["entries", "duration_sum", "duration_count", "delete_meta", "window_removals"]

# Search all actor-level interleavings while preserving each method's command order.
def interleavings(a, b):
    if not a:
        yield b
    elif not b:
        yield a
    else:
        for rest in interleavings(a[1:], b):
            yield ["H"] + rest
        for rest in interleavings(a, b[1:]):
            yield ["L"] + rest

recreate = False
duplicate_leave_count = False
recreate_example = None
duplicate_example = None

for schedule in interleavings(heartbeat, leave):
    hi = li = 0
    meta_exists = True
    h_observed_exists = False
    l_observed_meta = False
    leave_duration_updates = 0
    heartbeat_recreated = False

    for actor in schedule:
        if actor == "H":
            command = heartbeat[hi]
            hi += 1
            if command == "hasKey":
                h_observed_exists = meta_exists
            elif command.startswith("put_") and h_observed_exists and not meta_exists:
                meta_exists = True
                heartbeat_recreated = True
        else:
            command = leave[li]
            li += 1
            if command == "entries":
                l_observed_meta = meta_exists
            elif command == "duration_sum" and l_observed_meta:
                leave_duration_updates += 1
            elif command == "delete_meta" and l_observed_meta:
                meta_exists = False

    if heartbeat_recreated and not recreate:
        recreate = True
        recreate_example = schedule
    if leave_duration_updates == 2:
        duplicate_leave_count = True
        duplicate_example = schedule

print("heartbeat can recreate metadata after leave:", recreate)
print("example:", " ".join(recreate_example or []))
print("two leave calls can both count one session:", duplicate_leave_count)
print("example:", " ".join(duplicate_example or []))
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 274


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
def schedules(h, l, prefix=""):
    if h == 0:
        yield prefix + "L" * l
    elif l == 0:
        yield prefix + "H" * h
    else:
        yield from schedules(h - 1, l, prefix + "H")
        yield from schedules(h, l - 1, prefix + "L")

heartbeat = ["hasKey", "put_service", "put_heartbeat", "expire_meta", "window_updates"]
leave = ["entries", "duration_sum", "duration_count", "delete_meta", "window_removals"]

for schedule in schedules(len(heartbeat), len(leave)):
    hi = li = 0
    exists = True
    heartbeat_saw_exists = False
    leave_saw_meta = False
    recreated = False

    for actor in schedule:
        if actor == "H":
            command = heartbeat[hi]
            hi += 1
            if command == "hasKey":
                heartbeat_saw_exists = exists
            elif command.startswith("put_") and heartbeat_saw_exists and not exists:
                exists = True
                recreated = True
        else:
            command = leave[li]
            li += 1
            if command == "entries":
                leave_saw_meta = exists
            elif command == "delete_meta" and leave_saw_meta:
                exists = False

    if recreated:
        print("heartbeat can recreate metadata after leave: True")
        print("example:", schedule)
        break
else:
    print("heartbeat can recreate metadata after leave: False")

for schedule in schedules(len(leave), len(leave)):
    ai = bi = 0
    a_saw = b_saw = False
    duration_updates = 0

    for actor in schedule:
        if actor == "H":
            command = leave[ai]
            ai += 1
            if command == "entries":
                a_saw = True
            elif command == "duration_sum" and a_saw:
                duration_updates += 1
        else:
            command = leave[bi]
            bi += 1
            if command == "entries":
                b_saw = True
            elif command == "duration_sum" and b_saw:
                duration_updates += 1

    if duration_updates == 2:
        print("two leave calls can both count one session: True")
        print("example:", schedule)
        break
else:
    print("two leave calls can both count one session: False")
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 303


세션 lifecycle 전환을 원자적으로 처리하세요.

heartbeat()leave()의 Redis 명령이 분리되어 있습니다. leave() 이후 heartbeat()가 메타데이터와 window 멤버를 다시 생성할 수 있습니다. 두 leave() 요청이 같은 메타데이터를 읽으면 세션 시간이 중복 집계될 수 있습니다.

heartbeat()leave()를 Lua 스크립트 또는 WATCH/MULTI/EXEC 경계에서 조건부로 처리하세요. 동일 서브시스템에 두 경쟁 조건을 검증하는 결정적 동시성 테스트도 추가하세요.

📍 Affects 1 file
  • systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt#L37-L45 (this comment)
  • systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt#L48-L59
🤖 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-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt`
around lines 37 - 45, Make the session lifecycle transition atomic across
RedisSessionStoreAdapter. In heartbeat(), conditionally update metadata, TTL,
and the activity window only if the session still exists; in leave(), atomically
verify and remove the session while preventing concurrent heartbeats or
duplicate leaves from recreating or double-counting it, using Lua or
WATCH/MULTI/EXEC. Add deterministic concurrency tests in the same subsystem
covering heartbeat-after-leave and two simultaneous leave requests; update the
anchor file at lines 37-45 and sibling location at lines 48-59 as required.

}

override fun leave(sessionId: String, service: ServiceName, now: Instant): Boolean {
val meta = redis.opsForHash<String, String>().entries(metaKey(sessionId))
if (meta.isEmpty()) return false
meta[FIELD_ENTERED_AT]?.toLongOrNull()?.let { enteredAt ->
val durationSeconds = (now.toEpochMilli() - enteredAt) / 1000
redis.opsForValue().increment(DURATION_SUM_KEY, durationSeconds)
redis.opsForValue().increment(DURATION_COUNT_KEY)
}
redis.delete(metaKey(sessionId))
ServiceName.entries.forEach { redis.opsForZSet().remove(windowKey(it), sessionId) }
redis.opsForZSet().remove(ALL_WINDOW_KEY, sessionId)
return true
}

override fun concurrentUsers(service: ServiceName?, now: Instant, windowSeconds: Long): Int {
val key = service?.let { windowKey(it) } ?: ALL_WINDOW_KEY
val min = (now.toEpochMilli() - windowSeconds * 1000).toDouble()
return (redis.opsForZSet().count(key, min, Double.MAX_VALUE) ?: 0L).toInt()
}

override fun totalVisitors(): Long = redis.opsForSet().size(VISITORS_KEY) ?: 0L

override fun avgSessionDurationSeconds(): Long {
val sum = redis.opsForValue().get(DURATION_SUM_KEY)?.toLongOrNull() ?: 0L
val count = redis.opsForValue().get(DURATION_COUNT_KEY)?.toLongOrNull() ?: 0L
return if (count == 0L) 0L else sum / count
}

override fun deviceBreakdown(): Map<DeviceType, Long> {
val entries = redis.opsForHash<String, String>().entries(DEVICE_COUNTS_KEY)
return DeviceType.entries.associateWith { entries[it.name]?.toLongOrNull() ?: 0L }
}

override fun sampleConcurrency(now: Instant, windowSeconds: Long) {
val current = concurrentUsers(null, now, windowSeconds)
val currentMax = redis.opsForValue().get(CONCURRENT_MAX_KEY)?.toIntOrNull() ?: 0
if (current > currentMax) {
redis.opsForValue().set(CONCURRENT_MAX_KEY, current.toString())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
file='systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt'
printf '%s\n' '--- outline ---'
ast-grep outline "$file" || true
printf '%s\n' '--- implementation ---'
cat -n "$file"
printf '%s\n' '--- related files ---'
rg -l --glob '*.kt' 'RedisSessionStoreAdapter|CONCURRENT_MAX_KEY|sampleConcurrency|concurrentMax' . | head -80
printf '%s\n' '--- Redis atomic API usage ---'
rg -n --glob '*.kt' 'increment\(|decrement\(|eval\(|compareAndSet|opsForValue\(\)\.(get|set)' . | head -160
printf '%s\n' '--- build dependencies ---'
rg -n 'spring-data-redis|lettuce|jedis|redis' --glob 'build.gradle*' --glob 'gradle.properties' --glob 'libs.versions.toml' --glob 'pom.xml' .

Repository: EntryDSM/entrydsm-platform

Length of output: 10613


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path

path = Path("systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt")
source = path.read_text()

assert "val currentMax = redis.opsForValue().get(CONCURRENT_MAX_KEY)?.toIntOrNull() ?: 0" in source
assert "if (current > currentMax)" in source
assert 'redis.opsForValue().set(CONCURRENT_MAX_KEY, current.toString())' in source

# Model two concurrent sampleConcurrency calls:
# A observes 10, B observes 20, and both read the old Redis maximum 5.
initial_max = 5
a_current, b_current = 10, 20
a_observed, b_observed = initial_max, initial_max

# B completes its conditional SET first; A completes afterward.
redis_max = initial_max
if b_current > b_observed:
    redis_max = b_current
if a_current > a_observed:
    redis_max = a_current

assert redis_max == 10
assert redis_max != max(a_current, b_current)
print("The separate GET/conditional SET sequence can leave CONCURRENT_MAX_KEY at 10 instead of 20.")
PY

Repository: EntryDSM/entrydsm-platform

Length of output: 256


CONCURRENT_MAX_KEY 갱신을 원자적으로 처리하세요.

sampleConcurrency 호출이 같은 값을 읽으면 더 큰 값이 저장된 뒤 더 작은 값이 이를 덮어쓸 수 있습니다. 비교와 갱신을 Lua 스크립트 등 하나의 Redis 원자 작업으로 묶으세요.

🤖 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-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt`
around lines 83 - 86, Update the CONCURRENT_MAX_KEY handling in
sampleConcurrency to perform the read, comparison, and conditional write as one
atomic Redis operation, such as a Lua script, so concurrent calls cannot
overwrite a larger maximum with a smaller value. Preserve the existing behavior
of storing only when current exceeds the recorded maximum.

redis.opsForValue().increment(CONCURRENT_SUM_KEY, current.toLong())
redis.opsForValue().increment(CONCURRENT_SAMPLES_KEY)
}

override fun concurrentMax(): Int = redis.opsForValue().get(CONCURRENT_MAX_KEY)?.toIntOrNull() ?: 0

override fun concurrentAvg(): Int {
val sum = redis.opsForValue().get(CONCURRENT_SUM_KEY)?.toLongOrNull() ?: 0L
val samples = redis.opsForValue().get(CONCURRENT_SAMPLES_KEY)?.toLongOrNull() ?: 0L
return if (samples == 0L) 0 else (sum / samples).toInt()
}

private fun touchWindow(sessionId: String, service: ServiceName, nowMillis: Long) {
redis.opsForZSet().add(windowKey(service), sessionId, nowMillis.toDouble())
redis.opsForZSet().add(ALL_WINDOW_KEY, sessionId, nowMillis.toDouble())
redis.expire(windowKey(service), SESSION_TTL)
redis.expire(ALL_WINDOW_KEY, SESSION_TTL)
}

private fun metaKey(sessionId: String) = "monitor:session:meta:$sessionId"

private fun windowKey(service: ServiceName) = "monitor:session:window:${service.name}"

companion object {
private const val VISITORS_KEY = "monitor:visitors:all"
private const val DEVICE_COUNTS_KEY = "monitor:device:counts"
private const val DURATION_SUM_KEY = "monitor:duration:sum"
private const val DURATION_COUNT_KEY = "monitor:duration:count"
private const val CONCURRENT_MAX_KEY = "monitor:concurrent:max"
private const val CONCURRENT_SUM_KEY = "monitor:concurrent:sum"
private const val CONCURRENT_SAMPLES_KEY = "monitor:concurrent:samples"
private const val ALL_WINDOW_KEY = "monitor:session:window:ALL"
private const val FIELD_SERVICE = "service"
private const val FIELD_ENTERED_AT = "enteredAt"
private const val FIELD_LAST_HEARTBEAT_AT = "lastHeartbeatAt"
private val SESSION_TTL: Duration = Duration.ofHours(6)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ kt_jvm_test(
javac_opts = "//:javac_options",
kotlinc_opts = "//:kotlinc_options",
test_class = "hs.kr.entrydsm.observability.application.ObservabilityApplicationModuleTest",
deps = MODULE_DEPS + TEST_DEPS,
deps = [":main"] + MODULE_DEPS + TEST_DEPS,
)
5 changes: 4 additions & 1 deletion systems/observability/observability-application/deps.bzl
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
KOTLIN_DEPS = []
KOTLIN_DEPS = [
"@maven//:org_springframework_boot_spring_boot_starter",
"//systems/observability/observability-domain:main",
]
Comment on lines +1 to +3

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 | 🟠 Major | 🏗️ Heavy lift

Spring 의존성을 application 계층에서 제거하세요.

Line 2는 application module에 Spring Boot starter를 직접 추가합니다. 관련 SessionCollectionService@Service를 사용합니다. 이 결합은 application use case를 Spring에 종속시킵니다.

SessionCollectionService에서 Spring annotation을 제거하세요. bean 등록은 observability-bootstrap의 configuration으로 이동하세요. 그 후 application KOTLIN_DEPS에서 Spring Boot starter를 제거하세요.

As per coding guidelines, "For files under *-application modules, flag direct dependency on infrastructure-specific framework classes unless justified."

🤖 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-application/deps.bzl` around lines 1 - 4,
Remove the Spring `@Service` annotation from SessionCollectionService and move its
bean registration into the observability-bootstrap configuration. Then remove
`@maven//`:org_springframework_boot_spring_boot_starter from the application
KOTLIN_DEPS, leaving the domain dependency intact.

Source: Coding guidelines


TEST_DEPS = [
"@maven//:junit_junit",
Expand Down
Loading
Loading