Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
bbf6203
feat: ํ–‰๋™ ๋กœ๊ทธ(analytics event)
Junhyukkkk Jun 7, 2026
be09d56
feat: ์ผ๋ฐ˜ ํˆฌํ‘œ ํ–‰๋™ ๋กœ๊ทธ ์ถ”๊ฐ€ (vote_detail/participate/cancel)
Junhyukkkk Jun 7, 2026
ac0759f
feat: ์ด๋ชจ์ง€ ํ–‰๋™ ๋กœ๊ทธ ์ถ”๊ฐ€ (emoji_reacted, ์ด์ „ ์ƒํƒœ ๋น„๊ต action)
Junhyukkkk Jun 7, 2026
8013a80
feat: ํˆฌํ‘œ ๊ฒฐ๊ณผ/๊ณต์œ  ํ–‰๋™ ๋กœ๊ทธ ์ถ”๊ฐ€ (result_page_viewed, share_link_generated)
Junhyukkkk Jun 7, 2026
f812f2e
feat: ๋ชฐ์ž…ํ˜• ํˆฌํ‘œ ํ–‰๋™ ๋กœ๊ทธ ์ถ”๊ฐ€ (feed/participated/live)
Junhyukkkk Jun 7, 2026
4cef082
feat: ๋ฌด๋ฃŒํˆฌํ‘œ ํ–‰๋™ ๋กœ๊ทธ ์ถ”๊ฐ€ (free_votes_checked, free_limit_exceeded)
Junhyukkkk Jun 7, 2026
bed3a8f
feat: ์ฑ„ํŒ… ํ–‰๋™ ๋กœ๊ทธ ์ถ”๊ฐ€ (unread_total_count ํ•ฉ์‚ฐ, is_first_message ํŒ๋‹จ)
Junhyukkkk Jun 7, 2026
68f521a
feat: ์•Œ๋ฆผ/ํ‘ธ์‹œ ํ–‰๋™ ๋กœ๊ทธ ์ถ”๊ฐ€ (notification_list_viewed/opened, push_token_regโ€ฆ
Junhyukkkk Jun 7, 2026
8fff078
feat: ์†Œ์…œ ๋กœ๊ทธ์ธ ๊ฐ€์ž… ํ–‰๋™ ๋กœ๊ทธ ์ถ”๊ฐ€ (signup_completed)
Junhyukkkk Jun 7, 2026
8e9cb34
test: ํ–‰๋™ ๋กœ๊ทธ ๋„์ž…์— ๋”ฐ๋ฅธ ์ปจํŠธ๋กค๋Ÿฌ ํ…Œ์ŠคํŠธ ๋ณด๊ฐ•
Junhyukkkk Jun 7, 2026
7a30f4d
feat: ํ–‰๋™ ๋กœ๊ทธ(analytics event) ๋กœ๊น… ์ธํ”„๋ผ ์ถ”๊ฐ€
Junhyukkkk Jun 7, 2026
acb74c5
feat: UTM ๊ฐ€์ž… ์ถœ์ฒ˜ ์ €์žฅ ์Šคํ‚ค๋งˆ/๋„๋ฉ”์ธ ์ถ”๊ฐ€ (users ์ปฌ๋Ÿผ, UtmAttribution)
Junhyukkkk Jun 9, 2026
a17caf5
feat: ์‹ ๊ทœ ๊ฐ€์ž… ์‹œ์—๋งŒ UTM ์ถœ์ฒ˜ ๊ธฐ๋ก (findOrCreate/socialLogin ์˜ค๋ฒ„๋กœ๋“œ)
Junhyukkkk Jun 9, 2026
bac9459
feat: UTM first-touch ์ฟ ํ‚ค์™€ /api/track/visit ์—”๋“œํฌ์ธํŠธ ์ถ”๊ฐ€
Junhyukkkk Jun 9, 2026
cba2bbe
feat: ์†Œ์…œ ๋กœ๊ทธ์ธ ๊ฐ€์ž…์— UTM ์ถœ์ฒ˜ ์—ฐ๊ฒฐ (์ฟ ํ‚ค ์†Œ๋น„ ํ›„ ๋งŒ๋ฃŒ)
Junhyukkkk Jun 9, 2026
38d9a41
feat: VoteDuration์— HOURS_72(72์‹œ๊ฐ„) ์ถ”๊ฐ€
Junhyukkkk Jun 10, 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
81 changes: 81 additions & 0 deletions src/main/java/com/ject/vs/analytics/AnalyticsEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.ject.vs.analytics;

import java.util.LinkedHashMap;
import java.util.Map;

/**
* ํ–‰๋™ ๋กœ๊ทธ ํ•œ ๊ฑด์„ ํ‘œํ˜„ํ•˜๋Š” ๊ฐ’ ๊ฐ์ฒด. ์ด๋ฒคํŠธ ์ด๋ฆ„๊ณผ ์ด๋ฒคํŠธ๋ณ„ ๋กœ๊ทธ ๋ณ€์ˆ˜๋ฅผ ๋‹ด๋Š”๋‹ค.
*
* <p>๊ณตํ†ต ๋กœ๊ทธ ๋ณ€์ˆ˜(user_id / anonymous_id / is_member / platform / occurred_at)๋Š”
* {@link AnalyticsEventLogger#log(AnalyticsEvent)} ์‹œ์ ์— ์ฑ„์›Œ์ง€๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ๋Š” ๋‹ค๋ฃจ์ง€ ์•Š๋Š”๋‹ค.
*
* <p>๋กœ๊ฑฐ์— ์˜์กดํ•˜์ง€ ์•Š๋Š” ์ˆœ์ˆ˜ ๊ฐ’ ๊ฐ์ฒด์ด๋ฏ€๋กœ, ๋กœ๊ฑฐ๋ฅผ ๋ชฉ(mock)์œผ๋กœ ์ฃผ์ž…ํ•œ ํ…Œ์ŠคํŠธ์—์„œ๋„
* {@code log(...)} ๊ฐ€ no-op์ด ๋˜์–ด NPE ์—†์ด ์•ˆ์ „ํ•˜๋‹ค.
*
* <pre>{@code
* analytics.log(AnalyticsEvent.of("vote_detail_viewed")
* .put("vote_id", voteId)
* .put("vote_status", status));
* }</pre>
*/
public final class AnalyticsEvent {

private final String name;
private final Map<String, Object> properties = new LinkedHashMap<>();
private Long userId;
private boolean userIdOverridden;
private String anonymousId;
private boolean anonymousIdOverridden;

private AnalyticsEvent(String name) {
this.name = name;
}

public static AnalyticsEvent of(String name) {
return new AnalyticsEvent(name);
}

/** ์ด๋ฒคํŠธ๋ณ„ ๋กœ๊ทธ ๋ณ€์ˆ˜ ์ถ”๊ฐ€. null ๊ฐ’๋„ ๊ทธ๋Œ€๋กœ ๊ธฐ๋กํ•œ๋‹ค. */
public AnalyticsEvent put(String key, Object value) {
properties.put(key, value);
return this;
}

/** SecurityContext์—์„œ user_id๋ฅผ ์–ป์„ ์ˆ˜ ์—†๋Š” ํ๋ฆ„(์˜ˆ: OAuth ๋กœ๊ทธ์ธ ์„ฑ๊ณต ํ•ธ๋“ค๋Ÿฌ)์—์„œ ์ง์ ‘ ์ง€์ •. */
public AnalyticsEvent userId(Long userId) {
this.userId = userId;
this.userIdOverridden = true;
return this;
}

/** ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์ด๋ฏธ ๋ณด์œ ํ•œ anonymous_id๋ฅผ ์ง์ ‘ ์ง€์ •(์ฟ ํ‚ค ์žฌํŒŒ์‹ฑ ์ƒ๋žต). */
public AnalyticsEvent anonymousId(String anonymousId) {
this.anonymousId = anonymousId;
this.anonymousIdOverridden = true;
return this;
}

String name() {
return name;
}

Map<String, Object> properties() {
return properties;
}

boolean userIdOverridden() {
return userIdOverridden;
}

Long userId() {
return userId;
}

boolean anonymousIdOverridden() {
return anonymousIdOverridden;
}

String anonymousId() {
return anonymousId;
}
}
110 changes: 110 additions & 0 deletions src/main/java/com/ject/vs/analytics/AnalyticsEventLogger.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.ject.vs.analytics;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.time.Clock;
import java.time.Instant;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* ํ–‰๋™ ๋กœ๊ทธ(analytics event)๋ฅผ ๊ตฌ์กฐํ™”๋œ JSON ํ•œ ์ค„๋กœ ๋‚จ๊ธฐ๋Š” ์ปดํฌ๋„ŒํŠธ.
*
* <p>๊ณตํ†ต ๋กœ๊ทธ ๋ณ€์ˆ˜(user_id / anonymous_id / is_member / platform / occurred_at)๋Š”
* ํ˜„์žฌ HTTP ์š”์ฒญ ์ปจํ…์ŠคํŠธ์—์„œ ์ž๋™์œผ๋กœ ์ฑ„์›Œ์ง„๋‹ค. ์ด๋ฒคํŠธ๋ณ„ ๋ณ€์ˆ˜๋Š” {@link AnalyticsEvent}๋กœ ์ „๋‹ฌํ•œ๋‹ค.
*
* <pre>{@code
* analytics.log(AnalyticsEvent.of("vote_detail_viewed")
* .put("vote_id", voteId)
* .put("vote_status", status));
* }</pre>
*
* <p>์œ ์ผํ•œ ์ง„์ž…์ ์ด void {@code log(AnalyticsEvent)}์ด๋ฏ€๋กœ ํ…Œ์ŠคํŠธ์—์„œ ๋ชฉ(mock)์œผ๋กœ ์ฃผ์ž…ํ•ด๋„
* ๋ถ€์ˆ˜ํšจ๊ณผ ์—†์ด ๋™์ž‘ํ•œ๋‹ค. ๋กœ๊ทธ ์ ์žฌ ์‹คํŒจ๊ฐ€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๋„๋ก ๋ชจ๋“  ์˜ˆ์™ธ๋ฅผ ์‚ผํ‚จ๋‹ค.
*/
@Component
@RequiredArgsConstructor
public class AnalyticsEventLogger {

/** ๋ณ„๋„ ๋กœ๊ฑฐ ์ด๋ฆ„์œผ๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ ์ˆ˜์ง‘/ํ•„ํ„ฐ๋ง์ด ์‰ฝ๋„๋ก ํ•œ๋‹ค. */
private static final Logger log = LoggerFactory.getLogger("analytics");

private static final String ANONYMOUS_COOKIE = "anonymous_id";

private final ObjectMapper objectMapper;
private final Clock clock;

public void log(AnalyticsEvent event) {
try {
HttpServletRequest request = currentRequest();

Long userId = event.userIdOverridden() ? event.userId() : resolveUserId();
String anonymousId = event.anonymousIdOverridden()
? event.anonymousId()
: resolveAnonymousId(request);

Map<String, Object> payload = new LinkedHashMap<>();
payload.put("event", event.name());
payload.put("user_id", userId);
payload.put("anonymous_id", anonymousId);
payload.put("is_member", userId != null);
payload.put("platform", resolvePlatform(request));
payload.put("occurred_at", Instant.now(clock).toString());
payload.putAll(event.properties());

AnalyticsEventLogger.log.info(objectMapper.writeValueAsString(payload));
} catch (Exception e) {
// ๋กœ๊น… ์‹คํŒจ๊ฐ€ ์š”์ฒญ ์ฒ˜๋ฆฌ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๋„๋ก ํก์ˆ˜
AnalyticsEventLogger.log.warn("analytics event logging failed for '{}': {}", event.name(), e.getMessage());
}
}

private HttpServletRequest currentRequest() {
var attributes = RequestContextHolder.getRequestAttributes();
if (attributes instanceof ServletRequestAttributes servletAttributes) {
return servletAttributes.getRequest();
}
return null;
}

private Long resolveUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) return null;
Object principal = authentication.getPrincipal();
return (principal instanceof Long userId) ? userId : null;
}

private String resolveAnonymousId(HttpServletRequest request) {
if (request == null || request.getCookies() == null) return null;
return Arrays.stream(request.getCookies())
.filter(c -> ANONYMOUS_COOKIE.equals(c.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
}

/** User-Agent ๊ธฐ๋ฐ˜ ํ”Œ๋žซํผ ๋ถ„๋ฅ˜: ios / android / web. */
private String resolvePlatform(HttpServletRequest request) {
if (request == null) return "unknown";
String ua = request.getHeader("User-Agent");
if (ua == null || ua.isBlank()) return "unknown";
String lower = ua.toLowerCase();
if (lower.contains("android")) return "android";
if (lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios")
|| lower.contains("cfnetwork") || lower.contains("darwin")) {
return "ios";
}
return "web";
}
}
40 changes: 40 additions & 0 deletions src/main/java/com/ject/vs/analytics/TrackingController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.ject.vs.analytics;

import com.ject.vs.config.UtmCookie;
import com.ject.vs.user.domain.UtmAttribution;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
* ์œ ์ž… ์ถ”์  ์—”๋“œํฌ์ธํŠธ.
*
* <p>UTM ํŒŒ๋ผ๋ฏธํ„ฐ๋Š” ํ”„๋ก ํŠธ SPA URL(์˜ˆ: {@code /vote/123?utm_source=instagram})์— ๋ถ™์–ด ๋“ค์–ด์˜ค๋ฏ€๋กœ
* ๋ฐฑ์—”๋“œ๋Š” ์ง์ ‘ ๋ณผ ์ˆ˜ ์—†๋‹ค. ํ”„๋ก ํŠธ๊ฐ€ ๋žœ๋”ฉ ์‹œ ์ด ์—”๋“œํฌ์ธํŠธ๋ฅผ 1ํšŒ ํ˜ธ์ถœํ•ด UTM์„ ๋„˜๊ฒจ์ฃผ๋ฉด,
* ๋ฐฑ์—”๋“œ๋Š” first-touch ์ถœ์ฒ˜๋ฅผ ์ฟ ํ‚ค์— ๋ฐ•์ œํ•˜๊ณ  ์ดํ›„ ๊ฐ€์ž… ์™„๋ฃŒ ์‹œ users ํ…Œ์ด๋ธ”์— ๊ธฐ๋กํ•œ๋‹ค.
*/
@RestController
@RequestMapping("/api/track")
@RequiredArgsConstructor
public class TrackingController {

private final UtmCookie utmCookie;

@GetMapping("/visit")
public ResponseEntity<Void> visit(
@RequestParam(value = "utm_source", required = false) String source,
@RequestParam(value = "utm_medium", required = false) String medium,
@RequestParam(value = "utm_campaign", required = false) String campaign,
@RequestParam(value = "utm_content", required = false) String content,
HttpServletRequest request,
HttpServletResponse response) {

utmCookie.writeFirstTouch(request, response, UtmAttribution.of(source, medium, campaign, content));
return ResponseEntity.noContent().build();
}
}
7 changes: 6 additions & 1 deletion src/main/java/com/ject/vs/auth/port/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.ject.vs.common.exception.BusinessException;
import com.ject.vs.user.domain.User;
import com.ject.vs.user.domain.UserRepository;
import com.ject.vs.user.domain.UtmAttribution;
import com.ject.vs.user.exception.UserErrorCode;
import com.ject.vs.user.port.UserService;
import com.ject.vs.util.JwtProvider;
Expand All @@ -32,7 +33,11 @@ public class AuthService {
private final JwtProvider jwtProvider;

public LoginTokenResponse socialLogin(String email) {
User user = userService.findOrCreate(email);
return socialLogin(email, UtmAttribution.empty());
}

public LoginTokenResponse socialLogin(String email, UtmAttribution utm) {
User user = userService.findOrCreate(email, utm);

TokenInfo accessTokenInfo = jwtProvider.createAccessToken(user.getId());
TokenInfo refreshTokenInfo = jwtProvider.createRefreshToken(user.getId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public void handle(ChatMessageSentEvent event) {
sender.getNickname(),
sender.getImageColor(),
voteOptionCode,
false,
false
);

Expand Down
61 changes: 53 additions & 8 deletions src/main/java/com/ject/vs/chat/adapter/web/ChatController.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.ject.vs.chat.adapter.web;

import com.ject.vs.analytics.AnalyticsEvent;
import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.chat.adapter.web.dto.*;
import com.ject.vs.chat.port.in.*;
import com.ject.vs.chat.port.in.dto.*;
Expand All @@ -10,30 +12,51 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/chats")
@RequiredArgsConstructor
public class ChatController implements ChatDocs {

private final ChatQueryUseCase chatQueryUseCase;
private final ChatCommandUseCase chatCommandUseCase;
private final AnalyticsEventLogger analytics;

@GetMapping
@Override
public ChatListResponse getChatList(@AuthenticationPrincipal Long userId,
@RequestParam VoteStatus status) {
return new ChatListResponse(
chatQueryUseCase.getChatList(userId, status).stream()
.map(ChatListItemResponse::from)
.toList()
);
List<ChatListItemResponse> chats = chatQueryUseCase.getChatList(userId, status).stream()
.map(ChatListItemResponse::from)
.toList();

// unread_total_count: ๊ฐ ์ฑ„ํŒ…๋ฐฉ์˜ ์•ˆ ์ฝ์€ ๋ฉ”์‹œ์ง€ ์ˆ˜๋ฅผ ์„œ๋ฒ„์—์„œ ํ•ฉ์‚ฐ(SUM)
long unreadTotalCount = chats.stream()
.mapToLong(ChatListItemResponse::unreadCount)
.sum();

analytics.log(AnalyticsEvent.of("chat_list_viewed")
.put("status", status)
.put("chat_count", chats.size())
.put("unread_total_count", unreadTotalCount));

return new ChatListResponse(chats);
}

@GetMapping("/{voteId}")
@Override
public ChatRoomResponse getChatRoom(@AuthenticationPrincipal Long userId,
@PathVariable Long voteId) {
return ChatRoomResponse.from(chatQueryUseCase.getChatRoom(voteId));
ChatRoomResponse response = ChatRoomResponse.from(chatQueryUseCase.getChatRoom(voteId));

analytics.log(AnalyticsEvent.of("chat_room_entered")
.put("vote_id", response.voteId())
.put("vote_status", response.status())
.put("participant_count", response.participantCount())
.put("end_at", response.endAt()));

return response;
}

@GetMapping("/{voteId}/gauge")
Expand All @@ -48,7 +71,15 @@ public MessagePageResponse getMessages(@PathVariable Long voteId,
@AuthenticationPrincipal Long userId,
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "30") int size) {
return MessagePageResponse.from(chatQueryUseCase.getMessages(voteId, userId, cursor, size));
MessagePageResponse response = MessagePageResponse.from(chatQueryUseCase.getMessages(voteId, userId, cursor, size));

analytics.log(AnalyticsEvent.of("chat_messages_viewed")
.put("vote_id", voteId)
.put("message_count", response.messages().size())
.put("next_cursor", response.nextCursor())
.put("has_next", response.hasNext()));

return response;
}

@PostMapping("/{voteId}/messages")
Expand All @@ -58,7 +89,17 @@ public MessageResponse sendMessage(@PathVariable Long voteId,
@AuthenticationPrincipal Long userId,
@RequestBody @Valid SendMessageRequest request) {
SendMessageCommand command = new SendMessageCommand(voteId, userId, request.content());
return MessageResponse.from(chatCommandUseCase.sendMessage(command));
MessageResult result = chatCommandUseCase.sendMessage(command);

analytics.log(AnalyticsEvent.of("chat_message_sent")
.put("vote_id", voteId)
.put("message_id", result.messageId())
.put("message_length", request.content() != null ? request.content().length() : 0)
.put("sender_vote_option", result.senderVoteOption())
.put("is_mine", result.isMine())
.put("is_first_message", result.isFirstMessage()));

return MessageResponse.from(result);
}

@PostMapping("/{voteId}/read")
Expand All @@ -68,5 +109,9 @@ public void markAsRead(@PathVariable Long voteId,
@AuthenticationPrincipal Long userId,
@RequestBody @Valid MarkAsReadRequest request) {
chatCommandUseCase.markAsRead(new MarkAsReadCommand(voteId, userId, request.lastReadMessageId()));

analytics.log(AnalyticsEvent.of("chat_read_updated")
.put("vote_id", voteId)
.put("last_read_message_id", request.lastReadMessageId()));
}
}
Loading
Loading