Skip to content

Commit ee6de45

Browse files
authored
Merge pull request #161 from SEMOSAN/feat/#144-tracking-monitoring
[Feat] 트래킹 모니터링 시스템 구축 (Actuator + Prometheus + 구조화 로깅)
2 parents cee95de + c3080eb commit ee6de45

7 files changed

Lines changed: 100 additions & 26 deletions

File tree

build.gradle

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ dependencies {
6060
implementation 'org.flywaydb:flyway-core'
6161
implementation 'org.flywaydb:flyway-database-postgresql'
6262

63+
// Actuator + Prometheus
64+
implementation 'org.springframework.boot:spring-boot-starter-actuator'
65+
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
66+
67+
// JSON 로깅 (Loki 연동)
68+
implementation 'net.logstash.logback:logstash-logback-encoder:8.0'
69+
6370
}
6471

6572
jar { enabled = false }

src/main/java/com/semosan/api/common/jwt/JwtFilter.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import jakarta.servlet.http.HttpServletResponse;
1212
import lombok.RequiredArgsConstructor;
1313
import lombok.extern.slf4j.Slf4j;
14+
import org.slf4j.MDC;
1415
import org.springframework.http.MediaType;
1516
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
1617
import org.springframework.security.core.context.SecurityContextHolder;
@@ -68,17 +69,18 @@ protected void doFilterInternal(
6869
throw new GeneralException(ErrorStatus.JWT_BLACKLISTED);
6970
}
7071
Long userId = jwtService.getUserIdFromJwtToken(accessToken);
72+
MDC.put("userId", String.valueOf(userId));
7173

7274
UsernamePasswordAuthenticationToken authentication =
7375
new UsernamePasswordAuthenticationToken(userId, null, new ArrayList<>());
7476
SecurityContextHolder.getContext().setAuthentication(authentication);
7577
}
7678
filterChain.doFilter(request, response);
7779
} catch (GeneralException e) {
78-
// GeneralException을 그대로 ApiResponse 포맷으로 직렬화하여 응답
79-
// Spring Security가 가로채지 않도록 직접 response에 작성
8080
log.warn("[*] JwtFilter GeneralException : {}", e.getMessage());
8181
sendErrorResponse(response, e);
82+
} finally {
83+
MDC.remove("userId");
8284
}
8385
}
8486

src/main/java/com/semosan/api/domain/tracking/service/TrackingGpsPublisher.java

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.semosan.api.domain.tracking.repository.TrackingSessionRepository;
1010
import lombok.RequiredArgsConstructor;
1111
import lombok.extern.slf4j.Slf4j;
12+
import org.slf4j.MDC;
1213
import org.springframework.data.redis.connection.stream.RecordId;
1314
import org.springframework.data.redis.connection.stream.StringRecord;
1415
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -40,26 +41,33 @@ public class TrackingGpsPublisher {
4041

4142
@Transactional(readOnly = true)
4243
public void publish(Long userId, Long sessionId, GpsPointMessage message) {
43-
TrackingSession session = trackingSessionRepository.findById(sessionId)
44-
.orElseThrow(() -> new GeneralException(ErrorStatus.TRACKING_SESSION_NOT_FOUND));
45-
if (!session.isOwnedBy(userId)) {
46-
throw new GeneralException(ErrorStatus.TRACKING_SESSION_FORBIDDEN);
47-
}
48-
if (session.getStatus() != TrackingSessionStatus.IN_PROGRESS) {
49-
log.debug("Dropping GPS point: sessionId={} status={}", sessionId, session.getStatus());
50-
return;
51-
}
44+
MDC.put("sessionId", String.valueOf(sessionId));
45+
MDC.put("userId", String.valueOf(userId));
46+
try {
47+
TrackingSession session = trackingSessionRepository.findById(sessionId)
48+
.orElseThrow(() -> new GeneralException(ErrorStatus.TRACKING_SESSION_NOT_FOUND));
49+
if (!session.isOwnedBy(userId)) {
50+
throw new GeneralException(ErrorStatus.TRACKING_SESSION_FORBIDDEN);
51+
}
52+
if (session.getStatus() != TrackingSessionStatus.IN_PROGRESS) {
53+
log.info("[GPS] 좌표 드롭 | status={}", session.getStatus());
54+
return;
55+
}
5256

53-
Map<String, String> body = Map.of(
54-
FIELD_SESSION_ID, String.valueOf(sessionId),
55-
FIELD_USER_ID, String.valueOf(userId),
56-
FIELD_LAT, String.valueOf(message.lat()),
57-
FIELD_LNG, String.valueOf(message.lng()),
58-
FIELD_ALTITUDE, message.altitude() == null ? "" : String.valueOf(message.altitude()),
59-
FIELD_RECORDED_AT, message.recordedAt().toString()
60-
);
61-
StringRecord record = StringRecord.of(body).withStreamKey(trackingProperties.getStreamKey());
62-
RecordId id = redisTemplate.opsForStream().add(record);
63-
log.trace("Published GPS point: sessionId={} streamId={}", sessionId, id);
57+
Map<String, String> body = Map.of(
58+
FIELD_SESSION_ID, String.valueOf(sessionId),
59+
FIELD_USER_ID, String.valueOf(userId),
60+
FIELD_LAT, String.valueOf(message.lat()),
61+
FIELD_LNG, String.valueOf(message.lng()),
62+
FIELD_ALTITUDE, message.altitude() == null ? "" : String.valueOf(message.altitude()),
63+
FIELD_RECORDED_AT, message.recordedAt().toString()
64+
);
65+
StringRecord record = StringRecord.of(body).withStreamKey(trackingProperties.getStreamKey());
66+
RecordId id = redisTemplate.opsForStream().add(record);
67+
log.info("[GPS] 좌표 발행 | streamId={}", id);
68+
} finally {
69+
MDC.remove("sessionId");
70+
MDC.remove("userId");
71+
}
6472
}
6573
}

src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest
6868

6969
TrackingSession session = TrackingSession.create(user, mountain, course, request.isFreeRecording());
7070
TrackingSession saved = saveSession(session);
71+
log.info("[SESSION] 세션 생성 | sessionId={} | userId={} | mountainId={}", saved.getId(), userId, request.mountainId());
7172

7273
// 세션 생성과 동시에 사진 마일스톤 거리 리스트(코스 4컷 / 자유 6컷)를 Redis 에 적재한다.
7374
// 이후 GPS Consumer 가 매 점마다 evaluate 호출 시 이 키를 읽어 마일스톤 도달을 판정하므로,
@@ -91,13 +92,15 @@ public TrackingSessionResponse get(Long userId, Long sessionId) {
9192
public TrackingSessionResponse pause(Long userId, Long sessionId) {
9293
TrackingSession session = findOwnedSession(userId, sessionId);
9394
session.pause();
95+
log.info("[SESSION] 세션 일시정지 | sessionId={} | userId={}", sessionId, userId);
9496
return TrackingSessionResponse.from(session);
9597
}
9698

9799
@Transactional
98100
public TrackingSessionResponse resume(Long userId, Long sessionId) {
99101
TrackingSession session = findOwnedSession(userId, sessionId);
100102
session.resume();
103+
log.info("[SESSION] 세션 재개 | sessionId={} | userId={}", sessionId, userId);
101104
return TrackingSessionResponse.from(session);
102105
}
103106

@@ -131,6 +134,8 @@ public TrackingSessionResponse complete(Long userId, Long sessionId) {
131134
hikingMemberRepository.save(member);
132135

133136
eventPublisher.publishEvent(new TrackingSessionTerminatedEvent(sessionId));
137+
log.info("[SESSION] 세션 완료 | sessionId={} | userId={} | distance={}m | altitude={}m",
138+
sessionId, userId, stats.distanceMeters(), stats.maxAltitudeMeters());
134139

135140
return TrackingSessionResponse.from(session, savedRecord.getId());
136141
}
@@ -140,6 +145,7 @@ public TrackingSessionResponse abandon(Long userId, Long sessionId) {
140145
TrackingSession session = findOwnedSession(userId, sessionId);
141146
session.abandon();
142147
eventPublisher.publishEvent(new TrackingSessionTerminatedEvent(sessionId));
148+
log.info("[SESSION] 세션 포기 | sessionId={} | userId={}", sessionId, userId);
143149
return TrackingSessionResponse.from(session);
144150
}
145151

src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.semosan.api.domain.tracking.event.TrackingSessionTerminatedEvent;
44
import lombok.RequiredArgsConstructor;
55
import lombok.extern.slf4j.Slf4j;
6+
import org.slf4j.MDC;
67
import org.springframework.data.redis.connection.stream.MapRecord;
78
import org.springframework.data.redis.stream.StreamListener;
89
import org.springframework.scheduling.annotation.Scheduled;
@@ -58,6 +59,9 @@ public void onMessage(MapRecord<String, String, String> message) {
5859
try {
5960
Long sessionId = Long.parseLong(body.get(F_SESSION_ID));
6061
Long userId = Long.parseLong(body.get(F_USER_ID));
62+
MDC.put("sessionId", String.valueOf(sessionId));
63+
MDC.put("userId", String.valueOf(userId));
64+
6165
double lat = Double.parseDouble(body.get(F_LAT));
6266
double lng = Double.parseDouble(body.get(F_LNG));
6367
Double altitude = parseNullableDouble(body.get(F_ALTITUDE));
@@ -75,7 +79,10 @@ public void onMessage(MapRecord<String, String, String> message) {
7579
flushSession(sessionId);
7680
}
7781
} catch (RuntimeException e) {
78-
log.warn("Failed to process GPS stream message: id={} body={}", message.getId(), body, e);
82+
log.warn("[GPS] 메시지 처리 실패 | streamId={} | sessionId={}", message.getId(), body.get(F_SESSION_ID), e);
83+
} finally {
84+
MDC.remove("sessionId");
85+
MDC.remove("userId");
7986
}
8087
}
8188

@@ -106,7 +113,7 @@ private void flushSession(Long sessionId) {
106113
try {
107114
int saved = flushService.flush(sessionId, batch);
108115
if (saved > 0) {
109-
log.debug("Flushed {} GPS points for session {}", saved, sessionId);
116+
log.info("[FLUSH] DB 저장 | {}건 | sessionId={}", saved, sessionId);
110117
}
111118
} catch (RuntimeException e) {
112119
// DB flush 실패 시 batch 를 큐로 되돌려 다음 주기에 재시도.
@@ -139,8 +146,7 @@ public void onSessionTerminated(TrackingSessionTerminatedEvent event) {
139146
sessionId, chunk.size(), e);
140147
}
141148
}
142-
log.debug("Cleaned up tracking buffer for terminated session {} ({} points)",
143-
sessionId, remaining.size());
149+
log.info("[FLUSH] 세션 종료 버퍼 정리 | {}건 | sessionId={}", remaining.size(), sessionId);
144150
}
145151

146152
private static Double parseNullableDouble(String value) {

src/main/resources/application.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ minio:
6363
demo:
6464
photo-filenames: ${DEMO_PHOTO_FILENAMES}
6565

66+
management:
67+
endpoints:
68+
web:
69+
exposure:
70+
include: health,prometheus
71+
base-path: /actuator
72+
endpoint:
73+
health:
74+
show-details: never
75+
server:
76+
port: 9090
77+
prometheus:
78+
metrics:
79+
export:
80+
enabled: true
81+
6682
discord:
6783
alert:
6884
enabled: ${DISCORD_ALERT_ENABLED}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<configuration>
3+
4+
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
5+
<encoder>
6+
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{sessionId}] - %msg%n</pattern>
7+
</encoder>
8+
</appender>
9+
10+
<springProfile name="!prod">
11+
<root level="INFO">
12+
<appender-ref ref="CONSOLE"/>
13+
</root>
14+
</springProfile>
15+
16+
<springProfile name="prod">
17+
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
18+
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
19+
<includeMdcKeyName>sessionId</includeMdcKeyName>
20+
<includeMdcKeyName>userId</includeMdcKeyName>
21+
<timeZone>Asia/Seoul</timeZone>
22+
</encoder>
23+
</appender>
24+
<root level="INFO">
25+
<appender-ref ref="JSON"/>
26+
</root>
27+
</springProfile>
28+
29+
</configuration>

0 commit comments

Comments
 (0)