Skip to content

Commit 219bdad

Browse files
authored
Merge pull request #233 from JECT-Study/feature/analytics-event-logging
Feature/analytics event logging
2 parents 27149a4 + 38d9a41 commit 219bdad

40 files changed

Lines changed: 824 additions & 46 deletions
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.ject.vs.analytics;
2+
3+
import java.util.LinkedHashMap;
4+
import java.util.Map;
5+
6+
/**
7+
* 행동 로그 한 건을 표현하는 값 객체. 이벤트 이름과 이벤트별 로그 변수를 담는다.
8+
*
9+
* <p>공통 로그 변수(user_id / anonymous_id / is_member / platform / occurred_at)는
10+
* {@link AnalyticsEventLogger#log(AnalyticsEvent)} 시점에 채워지므로 여기서는 다루지 않는다.
11+
*
12+
* <p>로거에 의존하지 않는 순수 값 객체이므로, 로거를 목(mock)으로 주입한 테스트에서도
13+
* {@code log(...)} 가 no-op이 되어 NPE 없이 안전하다.
14+
*
15+
* <pre>{@code
16+
* analytics.log(AnalyticsEvent.of("vote_detail_viewed")
17+
* .put("vote_id", voteId)
18+
* .put("vote_status", status));
19+
* }</pre>
20+
*/
21+
public final class AnalyticsEvent {
22+
23+
private final String name;
24+
private final Map<String, Object> properties = new LinkedHashMap<>();
25+
private Long userId;
26+
private boolean userIdOverridden;
27+
private String anonymousId;
28+
private boolean anonymousIdOverridden;
29+
30+
private AnalyticsEvent(String name) {
31+
this.name = name;
32+
}
33+
34+
public static AnalyticsEvent of(String name) {
35+
return new AnalyticsEvent(name);
36+
}
37+
38+
/** 이벤트별 로그 변수 추가. null 값도 그대로 기록한다. */
39+
public AnalyticsEvent put(String key, Object value) {
40+
properties.put(key, value);
41+
return this;
42+
}
43+
44+
/** SecurityContext에서 user_id를 얻을 수 없는 흐름(예: OAuth 로그인 성공 핸들러)에서 직접 지정. */
45+
public AnalyticsEvent userId(Long userId) {
46+
this.userId = userId;
47+
this.userIdOverridden = true;
48+
return this;
49+
}
50+
51+
/** 컨트롤러가 이미 보유한 anonymous_id를 직접 지정(쿠키 재파싱 생략). */
52+
public AnalyticsEvent anonymousId(String anonymousId) {
53+
this.anonymousId = anonymousId;
54+
this.anonymousIdOverridden = true;
55+
return this;
56+
}
57+
58+
String name() {
59+
return name;
60+
}
61+
62+
Map<String, Object> properties() {
63+
return properties;
64+
}
65+
66+
boolean userIdOverridden() {
67+
return userIdOverridden;
68+
}
69+
70+
Long userId() {
71+
return userId;
72+
}
73+
74+
boolean anonymousIdOverridden() {
75+
return anonymousIdOverridden;
76+
}
77+
78+
String anonymousId() {
79+
return anonymousId;
80+
}
81+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package com.ject.vs.analytics;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import jakarta.servlet.http.Cookie;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import lombok.RequiredArgsConstructor;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.security.core.Authentication;
10+
import org.springframework.security.core.context.SecurityContextHolder;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.web.context.request.RequestContextHolder;
13+
import org.springframework.web.context.request.ServletRequestAttributes;
14+
15+
import java.time.Clock;
16+
import java.time.Instant;
17+
import java.util.Arrays;
18+
import java.util.LinkedHashMap;
19+
import java.util.Map;
20+
21+
/**
22+
* 행동 로그(analytics event)를 구조화된 JSON 한 줄로 남기는 컴포넌트.
23+
*
24+
* <p>공통 로그 변수(user_id / anonymous_id / is_member / platform / occurred_at)는
25+
* 현재 HTTP 요청 컨텍스트에서 자동으로 채워진다. 이벤트별 변수는 {@link AnalyticsEvent}로 전달한다.
26+
*
27+
* <pre>{@code
28+
* analytics.log(AnalyticsEvent.of("vote_detail_viewed")
29+
* .put("vote_id", voteId)
30+
* .put("vote_status", status));
31+
* }</pre>
32+
*
33+
* <p>유일한 진입점이 void {@code log(AnalyticsEvent)}이므로 테스트에서 목(mock)으로 주입해도
34+
* 부수효과 없이 동작한다. 로그 적재 실패가 비즈니스 로직에 영향을 주지 않도록 모든 예외를 삼킨다.
35+
*/
36+
@Component
37+
@RequiredArgsConstructor
38+
public class AnalyticsEventLogger {
39+
40+
/** 별도 로거 이름으로 분리하여 수집/필터링이 쉽도록 한다. */
41+
private static final Logger log = LoggerFactory.getLogger("analytics");
42+
43+
private static final String ANONYMOUS_COOKIE = "anonymous_id";
44+
45+
private final ObjectMapper objectMapper;
46+
private final Clock clock;
47+
48+
public void log(AnalyticsEvent event) {
49+
try {
50+
HttpServletRequest request = currentRequest();
51+
52+
Long userId = event.userIdOverridden() ? event.userId() : resolveUserId();
53+
String anonymousId = event.anonymousIdOverridden()
54+
? event.anonymousId()
55+
: resolveAnonymousId(request);
56+
57+
Map<String, Object> payload = new LinkedHashMap<>();
58+
payload.put("event", event.name());
59+
payload.put("user_id", userId);
60+
payload.put("anonymous_id", anonymousId);
61+
payload.put("is_member", userId != null);
62+
payload.put("platform", resolvePlatform(request));
63+
payload.put("occurred_at", Instant.now(clock).toString());
64+
payload.putAll(event.properties());
65+
66+
AnalyticsEventLogger.log.info(objectMapper.writeValueAsString(payload));
67+
} catch (Exception e) {
68+
// 로깅 실패가 요청 처리에 영향을 주지 않도록 흡수
69+
AnalyticsEventLogger.log.warn("analytics event logging failed for '{}': {}", event.name(), e.getMessage());
70+
}
71+
}
72+
73+
private HttpServletRequest currentRequest() {
74+
var attributes = RequestContextHolder.getRequestAttributes();
75+
if (attributes instanceof ServletRequestAttributes servletAttributes) {
76+
return servletAttributes.getRequest();
77+
}
78+
return null;
79+
}
80+
81+
private Long resolveUserId() {
82+
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
83+
if (authentication == null) return null;
84+
Object principal = authentication.getPrincipal();
85+
return (principal instanceof Long userId) ? userId : null;
86+
}
87+
88+
private String resolveAnonymousId(HttpServletRequest request) {
89+
if (request == null || request.getCookies() == null) return null;
90+
return Arrays.stream(request.getCookies())
91+
.filter(c -> ANONYMOUS_COOKIE.equals(c.getName()))
92+
.map(Cookie::getValue)
93+
.findFirst()
94+
.orElse(null);
95+
}
96+
97+
/** User-Agent 기반 플랫폼 분류: ios / android / web. */
98+
private String resolvePlatform(HttpServletRequest request) {
99+
if (request == null) return "unknown";
100+
String ua = request.getHeader("User-Agent");
101+
if (ua == null || ua.isBlank()) return "unknown";
102+
String lower = ua.toLowerCase();
103+
if (lower.contains("android")) return "android";
104+
if (lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios")
105+
|| lower.contains("cfnetwork") || lower.contains("darwin")) {
106+
return "ios";
107+
}
108+
return "web";
109+
}
110+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.ject.vs.analytics;
2+
3+
import com.ject.vs.config.UtmCookie;
4+
import com.ject.vs.user.domain.UtmAttribution;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.web.bind.annotation.GetMapping;
10+
import org.springframework.web.bind.annotation.RequestMapping;
11+
import org.springframework.web.bind.annotation.RequestParam;
12+
import org.springframework.web.bind.annotation.RestController;
13+
14+
/**
15+
* 유입 추적 엔드포인트.
16+
*
17+
* <p>UTM 파라미터는 프론트 SPA URL(예: {@code /vote/123?utm_source=instagram})에 붙어 들어오므로
18+
* 백엔드는 직접 볼 수 없다. 프론트가 랜딩 시 이 엔드포인트를 1회 호출해 UTM을 넘겨주면,
19+
* 백엔드는 first-touch 출처를 쿠키에 박제하고 이후 가입 완료 시 users 테이블에 기록한다.
20+
*/
21+
@RestController
22+
@RequestMapping("/api/track")
23+
@RequiredArgsConstructor
24+
public class TrackingController {
25+
26+
private final UtmCookie utmCookie;
27+
28+
@GetMapping("/visit")
29+
public ResponseEntity<Void> visit(
30+
@RequestParam(value = "utm_source", required = false) String source,
31+
@RequestParam(value = "utm_medium", required = false) String medium,
32+
@RequestParam(value = "utm_campaign", required = false) String campaign,
33+
@RequestParam(value = "utm_content", required = false) String content,
34+
HttpServletRequest request,
35+
HttpServletResponse response) {
36+
37+
utmCookie.writeFirstTouch(request, response, UtmAttribution.of(source, medium, campaign, content));
38+
return ResponseEntity.noContent().build();
39+
}
40+
}

src/main/java/com/ject/vs/auth/port/AuthService.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.ject.vs.common.exception.BusinessException;
1212
import com.ject.vs.user.domain.User;
1313
import com.ject.vs.user.domain.UserRepository;
14+
import com.ject.vs.user.domain.UtmAttribution;
1415
import com.ject.vs.user.exception.UserErrorCode;
1516
import com.ject.vs.user.port.UserService;
1617
import com.ject.vs.util.JwtProvider;
@@ -32,7 +33,11 @@ public class AuthService {
3233
private final JwtProvider jwtProvider;
3334

3435
public LoginTokenResponse socialLogin(String email) {
35-
User user = userService.findOrCreate(email);
36+
return socialLogin(email, UtmAttribution.empty());
37+
}
38+
39+
public LoginTokenResponse socialLogin(String email, UtmAttribution utm) {
40+
User user = userService.findOrCreate(email, utm);
3641

3742
TokenInfo accessTokenInfo = jwtProvider.createAccessToken(user.getId());
3843
TokenInfo refreshTokenInfo = jwtProvider.createRefreshToken(user.getId());

src/main/java/com/ject/vs/chat/adapter/event/ChatMessageEventListener.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public void handle(ChatMessageSentEvent event) {
4545
sender.getNickname(),
4646
sender.getImageColor(),
4747
voteOptionCode,
48+
false,
4849
false
4950
);
5051

src/main/java/com/ject/vs/chat/adapter/web/ChatController.java

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.ject.vs.chat.adapter.web;
22

3+
import com.ject.vs.analytics.AnalyticsEvent;
4+
import com.ject.vs.analytics.AnalyticsEventLogger;
35
import com.ject.vs.chat.adapter.web.dto.*;
46
import com.ject.vs.chat.port.in.*;
57
import com.ject.vs.chat.port.in.dto.*;
@@ -10,30 +12,51 @@
1012
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1113
import org.springframework.web.bind.annotation.*;
1214

15+
import java.util.List;
16+
1317
@RestController
1418
@RequestMapping("/api/chats")
1519
@RequiredArgsConstructor
1620
public class ChatController implements ChatDocs {
1721

1822
private final ChatQueryUseCase chatQueryUseCase;
1923
private final ChatCommandUseCase chatCommandUseCase;
24+
private final AnalyticsEventLogger analytics;
2025

2126
@GetMapping
2227
@Override
2328
public ChatListResponse getChatList(@AuthenticationPrincipal Long userId,
2429
@RequestParam VoteStatus status) {
25-
return new ChatListResponse(
26-
chatQueryUseCase.getChatList(userId, status).stream()
27-
.map(ChatListItemResponse::from)
28-
.toList()
29-
);
30+
List<ChatListItemResponse> chats = chatQueryUseCase.getChatList(userId, status).stream()
31+
.map(ChatListItemResponse::from)
32+
.toList();
33+
34+
// unread_total_count: 각 채팅방의 안 읽은 메시지 수를 서버에서 합산(SUM)
35+
long unreadTotalCount = chats.stream()
36+
.mapToLong(ChatListItemResponse::unreadCount)
37+
.sum();
38+
39+
analytics.log(AnalyticsEvent.of("chat_list_viewed")
40+
.put("status", status)
41+
.put("chat_count", chats.size())
42+
.put("unread_total_count", unreadTotalCount));
43+
44+
return new ChatListResponse(chats);
3045
}
3146

3247
@GetMapping("/{voteId}")
3348
@Override
3449
public ChatRoomResponse getChatRoom(@AuthenticationPrincipal Long userId,
3550
@PathVariable Long voteId) {
36-
return ChatRoomResponse.from(chatQueryUseCase.getChatRoom(voteId));
51+
ChatRoomResponse response = ChatRoomResponse.from(chatQueryUseCase.getChatRoom(voteId));
52+
53+
analytics.log(AnalyticsEvent.of("chat_room_entered")
54+
.put("vote_id", response.voteId())
55+
.put("vote_status", response.status())
56+
.put("participant_count", response.participantCount())
57+
.put("end_at", response.endAt()));
58+
59+
return response;
3760
}
3861

3962
@GetMapping("/{voteId}/gauge")
@@ -48,7 +71,15 @@ public MessagePageResponse getMessages(@PathVariable Long voteId,
4871
@AuthenticationPrincipal Long userId,
4972
@RequestParam(required = false) Long cursor,
5073
@RequestParam(defaultValue = "30") int size) {
51-
return MessagePageResponse.from(chatQueryUseCase.getMessages(voteId, userId, cursor, size));
74+
MessagePageResponse response = MessagePageResponse.from(chatQueryUseCase.getMessages(voteId, userId, cursor, size));
75+
76+
analytics.log(AnalyticsEvent.of("chat_messages_viewed")
77+
.put("vote_id", voteId)
78+
.put("message_count", response.messages().size())
79+
.put("next_cursor", response.nextCursor())
80+
.put("has_next", response.hasNext()));
81+
82+
return response;
5283
}
5384

5485
@PostMapping("/{voteId}/messages")
@@ -58,7 +89,17 @@ public MessageResponse sendMessage(@PathVariable Long voteId,
5889
@AuthenticationPrincipal Long userId,
5990
@RequestBody @Valid SendMessageRequest request) {
6091
SendMessageCommand command = new SendMessageCommand(voteId, userId, request.content());
61-
return MessageResponse.from(chatCommandUseCase.sendMessage(command));
92+
MessageResult result = chatCommandUseCase.sendMessage(command);
93+
94+
analytics.log(AnalyticsEvent.of("chat_message_sent")
95+
.put("vote_id", voteId)
96+
.put("message_id", result.messageId())
97+
.put("message_length", request.content() != null ? request.content().length() : 0)
98+
.put("sender_vote_option", result.senderVoteOption())
99+
.put("is_mine", result.isMine())
100+
.put("is_first_message", result.isFirstMessage()));
101+
102+
return MessageResponse.from(result);
62103
}
63104

64105
@PostMapping("/{voteId}/read")
@@ -68,5 +109,9 @@ public void markAsRead(@PathVariable Long voteId,
68109
@AuthenticationPrincipal Long userId,
69110
@RequestBody @Valid MarkAsReadRequest request) {
70111
chatCommandUseCase.markAsRead(new MarkAsReadCommand(voteId, userId, request.lastReadMessageId()));
112+
113+
analytics.log(AnalyticsEvent.of("chat_read_updated")
114+
.put("vote_id", voteId)
115+
.put("last_read_message_id", request.lastReadMessageId()));
71116
}
72117
}

0 commit comments

Comments
 (0)