From 33d08b7da8edfcddd26447d9531f27b5f6b250d2 Mon Sep 17 00:00:00 2001 From: hyesungoh Date: Mon, 4 May 2026 19:32:10 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Mixpanel=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=98=ED=82=B9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + gradle/analytics.gradle | 3 + .../org/gitanimals/core/event/EventLogger.kt | 5 + .../core/event/MixpanelConfiguration.kt | 31 +++++ .../core/event/MixpanelEventLogger.kt | 31 +++++ .../gitanimals/core/event/NoOpEventLogger.kt | 12 ++ .../gotcha/controller/GotchaController.kt | 22 ++++ .../identity/app/AppleLoginFacade.kt | 14 ++ .../identity/app/GithubLoginFacade.kt | 19 +++ .../gitanimals/quiz/app/SolveQuizFacade.kt | 21 +++ ...QuizCreatedInsertHibernateEventListener.kt | 18 ++- ...zSolveContextDoneHibernateEventListener.kt | 21 +++ src/main/resources/application.properties | 3 + .../core/event/MixpanelEventLoggerTest.kt | 65 ++++++++++ .../gotcha/controller/GotchaControllerTest.kt | 47 +++++++ .../identity/app/GithubLoginFacadeTest.kt | 26 ++++ .../quiz/app/QuizEventTrackingTest.kt | 120 ++++++++++++++++++ 17 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 gradle/analytics.gradle create mode 100644 src/main/kotlin/org/gitanimals/core/event/EventLogger.kt create mode 100644 src/main/kotlin/org/gitanimals/core/event/MixpanelConfiguration.kt create mode 100644 src/main/kotlin/org/gitanimals/core/event/MixpanelEventLogger.kt create mode 100644 src/main/kotlin/org/gitanimals/core/event/NoOpEventLogger.kt create mode 100644 src/test/kotlin/org/gitanimals/core/event/MixpanelEventLoggerTest.kt create mode 100644 src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt create mode 100644 src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt diff --git a/build.gradle b/build.gradle index 6082092..7c7121a 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ apply from: "gradle/spring.gradle" apply from: "gradle/logging.gradle" apply from: "gradle/monitor.gradle" apply from: "gradle/jetbrains.gradle" +apply from: "gradle/analytics.gradle" sentry { includeSourceContext = true diff --git a/gradle/analytics.gradle b/gradle/analytics.gradle new file mode 100644 index 0000000..a254196 --- /dev/null +++ b/gradle/analytics.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation "com.mixpanel:mixpanel-java:1.5.4" +} diff --git a/src/main/kotlin/org/gitanimals/core/event/EventLogger.kt b/src/main/kotlin/org/gitanimals/core/event/EventLogger.kt new file mode 100644 index 0000000..7e39ea6 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/core/event/EventLogger.kt @@ -0,0 +1,5 @@ +package org.gitanimals.core.event + +interface EventLogger { + fun track(eventName: String, distinctId: String, properties: Map = emptyMap()) +} diff --git a/src/main/kotlin/org/gitanimals/core/event/MixpanelConfiguration.kt b/src/main/kotlin/org/gitanimals/core/event/MixpanelConfiguration.kt new file mode 100644 index 0000000..9ec1c0f --- /dev/null +++ b/src/main/kotlin/org/gitanimals/core/event/MixpanelConfiguration.kt @@ -0,0 +1,31 @@ +package org.gitanimals.core.event + +import com.mixpanel.mixpanelapi.MessageBuilder +import com.mixpanel.mixpanelapi.MixpanelAPI +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +@Configuration +class MixpanelConfiguration( + @Value("\${mixpanel.project.token:}") private val token: String, +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Bean + @Profile("!test") + fun mixpanelEventLogger(): EventLogger { + if (token.isBlank()) { + logger.warn("MIXPANEL_PROJECT_TOKEN is blank — falling back to NoOpEventLogger") + return NoOpEventLogger() + } + return MixpanelEventLogger(MixpanelAPI(), MessageBuilder(token)) + } + + @Bean + @Profile("test") + fun noOpEventLogger(): EventLogger = NoOpEventLogger() +} diff --git a/src/main/kotlin/org/gitanimals/core/event/MixpanelEventLogger.kt b/src/main/kotlin/org/gitanimals/core/event/MixpanelEventLogger.kt new file mode 100644 index 0000000..601af15 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/core/event/MixpanelEventLogger.kt @@ -0,0 +1,31 @@ +package org.gitanimals.core.event + +import com.mixpanel.mixpanelapi.ClientDelivery +import com.mixpanel.mixpanelapi.MessageBuilder +import com.mixpanel.mixpanelapi.MixpanelAPI +import org.gitanimals.core.GracefulShutdownDispatcher.gracefulLaunch +import org.json.JSONObject +import org.slf4j.LoggerFactory + +class MixpanelEventLogger( + private val mixpanelAPI: MixpanelAPI, + private val messageBuilder: MessageBuilder, +) : EventLogger { + + private val logger = LoggerFactory.getLogger(this::class.java) + + override fun track(eventName: String, distinctId: String, properties: Map) { + gracefulLaunch { + runCatching { + val props = JSONObject() + properties.forEach { (k, v) -> if (v != null) props.put(k, v) } + val message = messageBuilder.event(distinctId, eventName, props) + val delivery = ClientDelivery() + delivery.addMessage(message) + mixpanelAPI.deliver(delivery) + }.onFailure { + logger.warn("Failed to track Mixpanel event '{}': {}", eventName, it.message) + } + } + } +} diff --git a/src/main/kotlin/org/gitanimals/core/event/NoOpEventLogger.kt b/src/main/kotlin/org/gitanimals/core/event/NoOpEventLogger.kt new file mode 100644 index 0000000..4f7fec1 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/core/event/NoOpEventLogger.kt @@ -0,0 +1,12 @@ +package org.gitanimals.core.event + +import org.slf4j.LoggerFactory + +class NoOpEventLogger : EventLogger { + + private val logger = LoggerFactory.getLogger(this::class.java) + + override fun track(eventName: String, distinctId: String, properties: Map) { + logger.debug("NoOpEventLogger.track: event={}, distinctId={}", eventName, distinctId) + } +} diff --git a/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt b/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt index 5710b87..9a2d7ce 100644 --- a/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt +++ b/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt @@ -2,10 +2,14 @@ package org.gitanimals.gotcha.controller import org.gitanimals.core.auth.RequiredUserEntryPoints import org.gitanimals.core.auth.UserEntryPoint +import org.gitanimals.core.event.EventLogger +import org.gitanimals.core.filter.MDCFilter.Companion.USER_ID import org.gitanimals.gotcha.app.GotchaFacadeV3 import org.gitanimals.gotcha.app.response.GotchaResponseV3 import org.gitanimals.gotcha.controller.response.ErrorResponse import org.gitanimals.gotcha.domain.GotchaType +import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.* @@ -13,8 +17,11 @@ import org.springframework.web.bind.annotation.* @RestController class GotchaController( private val gotchaFacadeV3: GotchaFacadeV3, + private val eventLogger: EventLogger, ) { + private val logger = LoggerFactory.getLogger(this::class.simpleName) + @RequiredUserEntryPoints([UserEntryPoint.GITHUB]) @PostMapping(path = ["/gotchas"], headers = ["Api-Version=3"]) fun gotchaV3( @@ -26,6 +33,21 @@ class GotchaController( val gotchaResponses = gotchaFacadeV3.gotcha(token, gotchaType, count) + val userId = MDC.get(USER_ID) + gotchaResponses.forEach { response -> + runCatching { + eventLogger.track( + eventName = "complete_gotcha", + distinctId = userId, + properties = mapOf( + "pet_persona" to response.name, + "cost_point" to gotchaType.point, + "user_id" to userId, + ), + ) + }.onFailure { logger.warn("Failed to track complete_gotcha event: {}", it.message) } + } + return mapOf("gotchaResults" to gotchaResponses) } diff --git a/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt b/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt index ebe331f..033c08e 100644 --- a/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt +++ b/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper import io.jsonwebtoken.Jwts import org.gitanimals.core.AUTHORIZATION_EXCEPTION +import org.gitanimals.core.event.EventLogger import org.gitanimals.identity.app.AppleOauth2Api.AppleAuthKeyResponse import org.gitanimals.identity.domain.EntryPoint import org.gitanimals.identity.domain.UserService @@ -23,6 +24,7 @@ class AppleLoginFacade( private val appleOauth2Api: AppleOauth2Api, private val objectMapper: ObjectMapper, @Value("\${login.secret}") private val loginSecret: String, + private val eventLogger: EventLogger, ) { private val logger = LoggerFactory.getLogger(this::class.simpleName) @@ -49,6 +51,18 @@ class AppleLoginFacade( } } + runCatching { + eventLogger.track( + eventName = "complete_login", + distinctId = user.id.toString(), + properties = mapOf( + "provider" to "apple", + "user_id" to user.id, + "is_new_user" to !isExistsUser, + ), + ) + }.onFailure { logger.warn("Failed to track complete_login event: {}", it.message) } + return tokenManager.createToken(user).withType() } diff --git a/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt b/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt index 78466c6..442e8a2 100644 --- a/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt +++ b/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt @@ -1,7 +1,9 @@ package org.gitanimals.identity.app +import org.gitanimals.core.event.EventLogger import org.gitanimals.identity.domain.EntryPoint import org.gitanimals.identity.domain.UserService +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @Service @@ -10,11 +12,15 @@ class GithubLoginFacade( private val userService: UserService, private val contributionApi: ContributionApi, private val tokenManager: TokenManager, + private val eventLogger: EventLogger, ) { + private val logger = LoggerFactory.getLogger(this::class.simpleName) + fun login(code: String): String { val oauthUserResponse = githubOauth2Api.getOauthUsername(githubOauth2Api.getToken(code)) + var isNewUser = false val user = when (userService.existsByEntryPointAndAuthenticationId( entryPoint = EntryPoint.GITHUB, authenticationId = oauthUserResponse.id, @@ -58,6 +64,7 @@ class GithubLoginFacade( entryPoint = EntryPoint.GITHUB, ) } else { + isNewUser = true val contributedYears = contributionApi.getAllContributionYearsWithToken(oauthUserResponse.username) val contributionCountPerYears = @@ -77,6 +84,18 @@ class GithubLoginFacade( } } + runCatching { + eventLogger.track( + eventName = "complete_login", + distinctId = user.id.toString(), + properties = mapOf( + "provider" to "github", + "user_id" to user.id, + "is_new_user" to isNewUser, + ), + ) + }.onFailure { logger.warn("Failed to track complete_login event: {}", it.message) } + return tokenManager.createToken(user).withType() } } diff --git a/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt b/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt index 369653d..3dfd755 100644 --- a/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt +++ b/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt @@ -1,14 +1,17 @@ package org.gitanimals.quiz.app import org.gitanimals.core.auth.InternalAuth +import org.gitanimals.core.event.EventLogger import org.gitanimals.quiz.app.request.CreateSolveQuizRequest import org.gitanimals.quiz.app.response.QuizContextResponse import org.gitanimals.quiz.app.response.TodaySolvedContextResponse import org.gitanimals.quiz.domain.approved.QuizService import org.gitanimals.quiz.domain.context.QuizSolveContext import org.gitanimals.quiz.domain.context.QuizSolveContextService +import org.gitanimals.quiz.domain.context.QuizSolveContextStatus import org.gitanimals.quiz.domain.core.Language import org.gitanimals.quiz.domain.core.Level +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @Service @@ -16,8 +19,11 @@ class SolveQuizFacade( private val internalAuth: InternalAuth, private val quizService: QuizService, private val quizSolveContextService: QuizSolveContextService, + private val eventLogger: EventLogger, ) { + private val logger = LoggerFactory.getLogger(this::class.simpleName) + fun createContext(locale: String, request: CreateSolveQuizRequest): Long { val userId = internalAuth.getUserId() @@ -54,6 +60,21 @@ class SolveQuizFacade( val userId = internalAuth.getUserId() quizSolveContextService.solveQuiz(id, userId, answer) + + val context = quizSolveContextService.getQuizSolveContextByIdAndUserId(id, userId) + runCatching { + eventLogger.track( + eventName = "submit_quiz_answer", + distinctId = userId.toString(), + properties = mapOf( + "quiz_id" to id, + "is_correct" to (context.getStatus() in setOf(QuizSolveContextStatus.SUCCESS, QuizSolveContextStatus.DONE)), + "user_id" to userId, + ) + ) + }.onFailure { + logger.warn("Failed to track submit_quiz_answer event. cause ${it.message}", it) + } } fun getQuizById(id: Long): QuizSolveContext { diff --git a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt index 4620428..bfb6a1f 100644 --- a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt +++ b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt @@ -1,5 +1,6 @@ package org.gitanimals.quiz.infra.hibernate +import org.gitanimals.core.event.EventLogger import org.gitanimals.quiz.domain.approved.Quiz import org.gitanimals.quiz.infra.event.NewQuizCreated import org.hibernate.event.spi.PostInsertEvent @@ -12,6 +13,7 @@ import org.springframework.stereotype.Component @Component class NewQuizCreatedInsertHibernateEventListener( private val applicationEventPublisher: ApplicationEventPublisher, + private val eventLogger: EventLogger, ) : PostInsertEventListener { private val logger = LoggerFactory.getLogger(this::class.simpleName) @@ -21,13 +23,27 @@ class NewQuizCreatedInsertHibernateEventListener( override fun onPostInsert(event: PostInsertEvent) { if (event.entity is Quiz) { + val quiz = event.entity as Quiz runCatching { applicationEventPublisher.publishEvent( - NewQuizCreated.from(event.entity as Quiz) + NewQuizCreated.from(quiz) ) }.onFailure { logger.error("Cannot publish NewQuizCreate event. cause ${it.message}", it) } + runCatching { + eventLogger.track( + eventName = "complete_make_quiz", + distinctId = quiz.userId.toString(), + properties = mapOf( + "quiz_id" to quiz.id, + "language" to quiz.language.name, + "user_id" to quiz.userId, + ) + ) + }.onFailure { + logger.warn("Failed to track complete_make_quiz event. cause ${it.message}", it) + } } } } diff --git a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt index 26dee79..8168234 100644 --- a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt +++ b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt @@ -3,6 +3,7 @@ package org.gitanimals.quiz.infra.hibernate import org.gitanimals.core.GracefulShutdownDispatcher.gracefulLaunch import org.gitanimals.core.IdGenerator import org.gitanimals.core.clock +import org.gitanimals.core.event.EventLogger import org.gitanimals.inbox.domain.InboxType import org.gitanimals.quiz.app.IdentityApi import org.gitanimals.quiz.app.InboxApi @@ -34,9 +35,11 @@ class QuizSolveContextDoneHibernateEventListener( if (quizSolveContext.getStatus() == QuizSolveContextStatus.DONE) { applicationEventPublisher.publishEvent( QuizSolveContextDoneLogicDelegator.QuizSolveContextDone( + contextId = quizSolveContext.id, userId = quizSolveContext.userId, prize = quizSolveContext.getPrize(), status = quizSolveContext.getStatus(), + language = quizSolveContext.category.name, ) ) } @@ -47,14 +50,17 @@ class QuizSolveContextDoneHibernateEventListener( class QuizSolveContextDoneLogicDelegator( private val inboxApi: InboxApi, private val identityApi: IdentityApi, + private val eventLogger: EventLogger, ) { private val logger = LoggerFactory.getLogger(this::class.simpleName) data class QuizSolveContextDone( + val contextId: Long, val userId: Long, val prize: Int, val status: QuizSolveContextStatus, + val language: String, ) @EventListener(QuizSolveContextDone::class) @@ -91,6 +97,21 @@ class QuizSolveContextDoneLogicDelegator( it ) } + + runCatching { + eventLogger.track( + eventName = "complete_solve_quiz", + distinctId = event.userId.toString(), + properties = mapOf( + "context_id" to event.contextId, + "score" to event.prize, + "language" to event.language, + "user_id" to event.userId, + ) + ) + }.onFailure { + logger.warn("Failed to track complete_solve_quiz event. cause ${it.message}", it) + } } } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index dfaa0c6..a549aa5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -61,3 +61,6 @@ tokenizer.api.key=foo quiz.approve.token= relay.approve.token= gitanimals.admin.token= + +### Mixpanel ### +mixpanel.project.token=${MIXPANEL_PROJECT_TOKEN:} diff --git a/src/test/kotlin/org/gitanimals/core/event/MixpanelEventLoggerTest.kt b/src/test/kotlin/org/gitanimals/core/event/MixpanelEventLoggerTest.kt new file mode 100644 index 0000000..ccec707 --- /dev/null +++ b/src/test/kotlin/org/gitanimals/core/event/MixpanelEventLoggerTest.kt @@ -0,0 +1,65 @@ +package org.gitanimals.core.event + +import com.mixpanel.mixpanelapi.ClientDelivery +import com.mixpanel.mixpanelapi.MessageBuilder +import com.mixpanel.mixpanelapi.MixpanelAPI +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.json.JSONObject + +internal class MixpanelEventLoggerTest : DescribeSpec({ + + val mixpanelAPI = mockk(relaxed = true) + val token = "test-token" + val messageBuilder = MessageBuilder(token) + val logger = MixpanelEventLogger(mixpanelAPI, messageBuilder) + + describe("track") { + + context("when called with eventName, distinctId, and properties") { + it("delivers a ClientDelivery to MixpanelAPI") { + val deliverySlot = slot() + every { mixpanelAPI.deliver(capture(deliverySlot)) } returns Unit + + logger.track( + eventName = "complete_login", + distinctId = "user-123", + properties = mapOf("platform" to "github", "count" to 1) + ) + + // give coroutine time to execute + Thread.sleep(200) + + verify(exactly = 1) { mixpanelAPI.deliver(any()) } + } + } + + context("when properties contain null values") { + it("skips null values and still delivers") { + logger.track( + eventName = "complete_login", + distinctId = "user-456", + properties = mapOf("key" to null, "valid" to "value") + ) + + Thread.sleep(200) + + verify(atLeast = 1) { mixpanelAPI.deliver(any()) } + } + } + + context("when MixpanelAPI throws") { + it("swallows the exception and does not propagate") { + every { mixpanelAPI.deliver(any()) } throws RuntimeException("network error") + + // should not throw + logger.track("some_event", "user-789") + + Thread.sleep(200) + } + } + } +}) diff --git a/src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt b/src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt new file mode 100644 index 0000000..96106e3 --- /dev/null +++ b/src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt @@ -0,0 +1,47 @@ +package org.gitanimals.gotcha.controller + +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.gitanimals.core.event.EventLogger +import org.gitanimals.core.filter.MDCFilter.Companion.USER_ID +import org.gitanimals.gotcha.app.GotchaFacadeV3 +import org.gitanimals.gotcha.app.response.GotchaResponseV3 +import org.gitanimals.gotcha.domain.GotchaType +import org.slf4j.MDC + +internal class GotchaControllerTest : DescribeSpec({ + + val gotchaFacadeV3 = mockk() + val eventLogger = mockk(relaxed = true) + val controller = GotchaController(gotchaFacadeV3, eventLogger) + + beforeEach { + MDC.put(USER_ID, "42") + } + + afterEach { + MDC.clear() + } + + describe("gotchaV3 메소드는") { + context("gotcha가 성공하면") { + every { gotchaFacadeV3.gotcha(any(), any(), any()) } returns listOf( + GotchaResponseV3(name = "GOOSE", dropRate = "1.0"), + ) + + it("complete_gotcha 이벤트를 user_id와 함께 트래킹한다") { + controller.gotchaV3("token", "DEFAULT", 1) + + verify { + eventLogger.track( + eventName = "complete_gotcha", + distinctId = "42", + properties = match { it["user_id"] != null }, + ) + } + } + } + } +}) diff --git a/src/test/kotlin/org/gitanimals/identity/app/GithubLoginFacadeTest.kt b/src/test/kotlin/org/gitanimals/identity/app/GithubLoginFacadeTest.kt index 00ecc65..503eba6 100644 --- a/src/test/kotlin/org/gitanimals/identity/app/GithubLoginFacadeTest.kt +++ b/src/test/kotlin/org/gitanimals/identity/app/GithubLoginFacadeTest.kt @@ -6,6 +6,8 @@ import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.mockk.every +import io.mockk.verify +import org.gitanimals.core.event.EventLogger import org.gitanimals.identity.domain.* import org.gitanimals.identity.infra.JwtTokenManager import org.springframework.boot.autoconfigure.domain.EntityScan @@ -31,6 +33,7 @@ internal class GithubLoginFacadeTest( private val userRepository: UserRepository, @MockkBean(relaxed = true) private val githubOauth2Api: GithubOauth2Api, @MockkBean(relaxed = true) private val contributionApi: ContributionApi, + @MockkBean(relaxed = true) private val eventLogger: EventLogger, ) : DescribeSpec({ afterEach { @@ -110,5 +113,28 @@ internal class GithubLoginFacadeTest( userRepository.findAll().count() shouldBe 1 } } + + context("로그인에 성공했을때") { + val username = "track-user" + val authenticationId = "track-id" + + every { githubOauth2Api.getOauthUsername(any()) } returns GithubOauth2Api.OAuthUserResponse( + username = username, + id = authenticationId, + profileImage = "https://...", + ) + + it("complete_login 이벤트를 user_id와 함께 트래킹한다") { + githubLoginFacade.login("code") + + verify { + eventLogger.track( + eventName = "complete_login", + distinctId = any(), + properties = match { it["user_id"] != null }, + ) + } + } + } } }) diff --git a/src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt b/src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt new file mode 100644 index 0000000..5facba3 --- /dev/null +++ b/src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt @@ -0,0 +1,120 @@ +package org.gitanimals.quiz.app + +import com.ninjasquad.springmockk.MockkBean +import io.kotest.core.annotation.DisplayName +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.just +import io.mockk.runs +import io.mockk.verify +import org.gitanimals.core.auth.InternalAuth +import org.gitanimals.core.event.EventLogger +import org.gitanimals.quiz.app.request.CreateSolveQuizRequest +import org.gitanimals.quiz.domain.approved.QuizRepository +import org.gitanimals.quiz.domain.approved.QuizService +import org.gitanimals.quiz.domain.context.QuizSolveContextRepository +import org.gitanimals.quiz.domain.context.QuizSolveContextService +import org.gitanimals.quiz.domain.context.quizSolveContext +import org.gitanimals.quiz.domain.core.Category +import org.gitanimals.quiz.domain.core.Level +import org.gitanimals.quiz.domain.quiz.quiz +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestPropertySource + +@DataJpaTest +@ContextConfiguration( + classes = [ + SolveQuizFacade::class, + QuizSolveContextService::class, + QuizService::class, + ] +) +@EntityScan(basePackages = ["org.gitanimals.quiz.domain"]) +@EnableJpaRepositories(basePackages = ["org.gitanimals.quiz.domain"]) +@DisplayName("QuizEventTrackingTest 클래스의") +@TestPropertySource("classpath:test.properties") +internal class QuizEventTrackingTest( + private val solveQuizFacade: SolveQuizFacade, + private val quizRepository: QuizRepository, + private val quizSolveContextRepository: QuizSolveContextRepository, + private val quizSolveContextService: QuizSolveContextService, + @MockkBean private val internalAuth: InternalAuth, + @MockkBean private val identityApi: IdentityApi, + @MockkBean private val eventLogger: EventLogger, +) : DescribeSpec({ + + beforeAny { + every { internalAuth.getUserId() } returns userId + every { eventLogger.track(any(), any(), any()) } just runs + } + + afterAny { + quizRepository.deleteAll() + quizSolveContextRepository.deleteAll() + } + + describe("answerQuizById 메소드는") { + context("퀴즈 답변 제출 후") { + val quizContext = quizSolveContextRepository.save(quizSolveContext(userId = userId)) + quizSolveContextService.getAndStartSolveQuizContext(quizContext.id, quizContext.userId) + + it("submit_quiz_answer 이벤트를 트래킹한다") { + solveQuizFacade.answerQuizById(quizContext.id, "YES") + + verify(exactly = 1) { + eventLogger.track( + eventName = "submit_quiz_answer", + distinctId = userId.toString(), + properties = match { props -> + props["quiz_id"] == quizContext.id && + props["user_id"] == userId + } + ) + } + } + } + + context("EventLogger가 예외를 던져도") { + val quizContext2 = quizSolveContextRepository.save(quizSolveContext(userId = userId)) + quizSolveContextService.getAndStartSolveQuizContext(quizContext2.id, quizContext2.userId) + every { eventLogger.track(any(), any(), any()) } throws RuntimeException("tracking error") + + it("예외가 전파되지 않는다") { + solveQuizFacade.answerQuizById(quizContext2.id, "YES") + } + } + } + + describe("createContext 메소드는") { + context("퀴즈 컨텍스트 생성 시") { + quizRepository.saveAll( + listOf( + quiz(level = Level.EASY), + quiz(level = Level.EASY), + quiz(level = Level.MEDIUM), + quiz(level = Level.DIFFICULT), + quiz(level = Level.DIFFICULT), + ) + ) + every { identityApi.getUserByToken(any()) } returns defaultUser + + it("createContext가 정상 동작한다") { + solveQuizFacade.createContext("KR", CreateSolveQuizRequest(Category.BACKEND)) + } + } + } +}) { + + companion object { + private const val userId = 42L + + private val defaultUser = IdentityApi.UserResponse( + id = userId.toString(), + username = "testuser", + points = "1000", + ) + } +} From 69877698f2145e28c41d53697a560c9d24d519a9 Mon Sep 17 00:00:00 2001 From: hyesungoh Date: Wed, 6 May 2026 00:21:06 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20EventLogger=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EB=B6=80=EC=9D=98=20runCatching=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20GotchaController=EC=97=90?= =?UTF-8?q?=20InternalAuth=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EventLogger.track 계약을 "절대 throw 하지 않음"으로 명시 (KDoc) - 호출부 6곳의 외부 runCatching/onFailure 제거 (MixpanelEventLogger 내부에서 이미 보장) - GotchaController가 MDC 직접 조회 대신 InternalAuth.findUserId() 사용, userId null이면 silent skip --- .../org/gitanimals/core/event/EventLogger.kt | 4 +++ .../gotcha/controller/GotchaController.kt | 16 ++++------ .../identity/app/AppleLoginFacade.kt | 20 ++++++------ .../identity/app/GithubLoginFacade.kt | 23 ++++++-------- .../gitanimals/quiz/app/SolveQuizFacade.kt | 23 +++++--------- ...QuizCreatedInsertHibernateEventListener.kt | 20 +++++------- ...zSolveContextDoneHibernateEventListener.kt | 22 ++++++------- .../gotcha/controller/GotchaControllerTest.kt | 31 ++++++++++++------- .../quiz/app/QuizEventTrackingTest.kt | 9 ------ 9 files changed, 72 insertions(+), 96 deletions(-) diff --git a/src/main/kotlin/org/gitanimals/core/event/EventLogger.kt b/src/main/kotlin/org/gitanimals/core/event/EventLogger.kt index 7e39ea6..7309a30 100644 --- a/src/main/kotlin/org/gitanimals/core/event/EventLogger.kt +++ b/src/main/kotlin/org/gitanimals/core/event/EventLogger.kt @@ -1,5 +1,9 @@ package org.gitanimals.core.event interface EventLogger { + + /** + * 분석 이벤트를 트래킹한다. 절대 예외를 throw 하지 않으며, 실패는 구현체 내부에서 로깅으로 처리된다. + */ fun track(eventName: String, distinctId: String, properties: Map = emptyMap()) } diff --git a/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt b/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt index 9a2d7ce..ee2f4d2 100644 --- a/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt +++ b/src/main/kotlin/org/gitanimals/gotcha/controller/GotchaController.kt @@ -1,15 +1,13 @@ package org.gitanimals.gotcha.controller +import org.gitanimals.core.auth.InternalAuth import org.gitanimals.core.auth.RequiredUserEntryPoints import org.gitanimals.core.auth.UserEntryPoint import org.gitanimals.core.event.EventLogger -import org.gitanimals.core.filter.MDCFilter.Companion.USER_ID import org.gitanimals.gotcha.app.GotchaFacadeV3 import org.gitanimals.gotcha.app.response.GotchaResponseV3 import org.gitanimals.gotcha.controller.response.ErrorResponse import org.gitanimals.gotcha.domain.GotchaType -import org.slf4j.LoggerFactory -import org.slf4j.MDC import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.* @@ -18,10 +16,9 @@ import org.springframework.web.bind.annotation.* class GotchaController( private val gotchaFacadeV3: GotchaFacadeV3, private val eventLogger: EventLogger, + private val internalAuth: InternalAuth, ) { - private val logger = LoggerFactory.getLogger(this::class.simpleName) - @RequiredUserEntryPoints([UserEntryPoint.GITHUB]) @PostMapping(path = ["/gotchas"], headers = ["Api-Version=3"]) fun gotchaV3( @@ -33,19 +30,18 @@ class GotchaController( val gotchaResponses = gotchaFacadeV3.gotcha(token, gotchaType, count) - val userId = MDC.get(USER_ID) - gotchaResponses.forEach { response -> - runCatching { + internalAuth.findUserId()?.let { userId -> + gotchaResponses.forEach { response -> eventLogger.track( eventName = "complete_gotcha", - distinctId = userId, + distinctId = userId.toString(), properties = mapOf( "pet_persona" to response.name, "cost_point" to gotchaType.point, "user_id" to userId, ), ) - }.onFailure { logger.warn("Failed to track complete_gotcha event: {}", it.message) } + } } return mapOf("gotchaResults" to gotchaResponses) diff --git a/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt b/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt index 033c08e..b7bc7c0 100644 --- a/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt +++ b/src/main/kotlin/org/gitanimals/identity/app/AppleLoginFacade.kt @@ -51,17 +51,15 @@ class AppleLoginFacade( } } - runCatching { - eventLogger.track( - eventName = "complete_login", - distinctId = user.id.toString(), - properties = mapOf( - "provider" to "apple", - "user_id" to user.id, - "is_new_user" to !isExistsUser, - ), - ) - }.onFailure { logger.warn("Failed to track complete_login event: {}", it.message) } + eventLogger.track( + eventName = "complete_login", + distinctId = user.id.toString(), + properties = mapOf( + "provider" to "apple", + "user_id" to user.id, + "is_new_user" to !isExistsUser, + ), + ) return tokenManager.createToken(user).withType() } diff --git a/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt b/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt index 442e8a2..dd60721 100644 --- a/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt +++ b/src/main/kotlin/org/gitanimals/identity/app/GithubLoginFacade.kt @@ -3,7 +3,6 @@ package org.gitanimals.identity.app import org.gitanimals.core.event.EventLogger import org.gitanimals.identity.domain.EntryPoint import org.gitanimals.identity.domain.UserService -import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @Service @@ -15,8 +14,6 @@ class GithubLoginFacade( private val eventLogger: EventLogger, ) { - private val logger = LoggerFactory.getLogger(this::class.simpleName) - fun login(code: String): String { val oauthUserResponse = githubOauth2Api.getOauthUsername(githubOauth2Api.getToken(code)) @@ -84,17 +81,15 @@ class GithubLoginFacade( } } - runCatching { - eventLogger.track( - eventName = "complete_login", - distinctId = user.id.toString(), - properties = mapOf( - "provider" to "github", - "user_id" to user.id, - "is_new_user" to isNewUser, - ), - ) - }.onFailure { logger.warn("Failed to track complete_login event: {}", it.message) } + eventLogger.track( + eventName = "complete_login", + distinctId = user.id.toString(), + properties = mapOf( + "provider" to "github", + "user_id" to user.id, + "is_new_user" to isNewUser, + ), + ) return tokenManager.createToken(user).withType() } diff --git a/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt b/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt index 3dfd755..03af387 100644 --- a/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt +++ b/src/main/kotlin/org/gitanimals/quiz/app/SolveQuizFacade.kt @@ -11,7 +11,6 @@ import org.gitanimals.quiz.domain.context.QuizSolveContextService import org.gitanimals.quiz.domain.context.QuizSolveContextStatus import org.gitanimals.quiz.domain.core.Language import org.gitanimals.quiz.domain.core.Level -import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @Service @@ -22,8 +21,6 @@ class SolveQuizFacade( private val eventLogger: EventLogger, ) { - private val logger = LoggerFactory.getLogger(this::class.simpleName) - fun createContext(locale: String, request: CreateSolveQuizRequest): Long { val userId = internalAuth.getUserId() @@ -62,19 +59,15 @@ class SolveQuizFacade( quizSolveContextService.solveQuiz(id, userId, answer) val context = quizSolveContextService.getQuizSolveContextByIdAndUserId(id, userId) - runCatching { - eventLogger.track( - eventName = "submit_quiz_answer", - distinctId = userId.toString(), - properties = mapOf( - "quiz_id" to id, - "is_correct" to (context.getStatus() in setOf(QuizSolveContextStatus.SUCCESS, QuizSolveContextStatus.DONE)), - "user_id" to userId, - ) + eventLogger.track( + eventName = "submit_quiz_answer", + distinctId = userId.toString(), + properties = mapOf( + "quiz_id" to id, + "is_correct" to (context.getStatus() in setOf(QuizSolveContextStatus.SUCCESS, QuizSolveContextStatus.DONE)), + "user_id" to userId, ) - }.onFailure { - logger.warn("Failed to track submit_quiz_answer event. cause ${it.message}", it) - } + ) } fun getQuizById(id: Long): QuizSolveContext { diff --git a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt index bfb6a1f..057bcea 100644 --- a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt +++ b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/NewQuizCreatedInsertHibernateEventListener.kt @@ -31,19 +31,15 @@ class NewQuizCreatedInsertHibernateEventListener( }.onFailure { logger.error("Cannot publish NewQuizCreate event. cause ${it.message}", it) } - runCatching { - eventLogger.track( - eventName = "complete_make_quiz", - distinctId = quiz.userId.toString(), - properties = mapOf( - "quiz_id" to quiz.id, - "language" to quiz.language.name, - "user_id" to quiz.userId, - ) + eventLogger.track( + eventName = "complete_make_quiz", + distinctId = quiz.userId.toString(), + properties = mapOf( + "quiz_id" to quiz.id, + "language" to quiz.language.name, + "user_id" to quiz.userId, ) - }.onFailure { - logger.warn("Failed to track complete_make_quiz event. cause ${it.message}", it) - } + ) } } } diff --git a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt index 8168234..e1087aa 100644 --- a/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt +++ b/src/main/kotlin/org/gitanimals/quiz/infra/hibernate/QuizSolveContextDoneHibernateEventListener.kt @@ -98,20 +98,16 @@ class QuizSolveContextDoneLogicDelegator( ) } - runCatching { - eventLogger.track( - eventName = "complete_solve_quiz", - distinctId = event.userId.toString(), - properties = mapOf( - "context_id" to event.contextId, - "score" to event.prize, - "language" to event.language, - "user_id" to event.userId, - ) + eventLogger.track( + eventName = "complete_solve_quiz", + distinctId = event.userId.toString(), + properties = mapOf( + "context_id" to event.contextId, + "score" to event.prize, + "language" to event.language, + "user_id" to event.userId, ) - }.onFailure { - logger.warn("Failed to track complete_solve_quiz event. cause ${it.message}", it) - } + ) } } } diff --git a/src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt b/src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt index 96106e3..104c9d7 100644 --- a/src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt +++ b/src/test/kotlin/org/gitanimals/gotcha/controller/GotchaControllerTest.kt @@ -4,32 +4,24 @@ import io.kotest.core.spec.style.DescribeSpec import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.gitanimals.core.auth.InternalAuth import org.gitanimals.core.event.EventLogger -import org.gitanimals.core.filter.MDCFilter.Companion.USER_ID import org.gitanimals.gotcha.app.GotchaFacadeV3 import org.gitanimals.gotcha.app.response.GotchaResponseV3 -import org.gitanimals.gotcha.domain.GotchaType -import org.slf4j.MDC internal class GotchaControllerTest : DescribeSpec({ val gotchaFacadeV3 = mockk() val eventLogger = mockk(relaxed = true) - val controller = GotchaController(gotchaFacadeV3, eventLogger) - - beforeEach { - MDC.put(USER_ID, "42") - } - - afterEach { - MDC.clear() - } + val internalAuth = mockk() + val controller = GotchaController(gotchaFacadeV3, eventLogger, internalAuth) describe("gotchaV3 메소드는") { context("gotcha가 성공하면") { every { gotchaFacadeV3.gotcha(any(), any(), any()) } returns listOf( GotchaResponseV3(name = "GOOSE", dropRate = "1.0"), ) + every { internalAuth.findUserId() } returns 42L it("complete_gotcha 이벤트를 user_id와 함께 트래킹한다") { controller.gotchaV3("token", "DEFAULT", 1) @@ -43,5 +35,20 @@ internal class GotchaControllerTest : DescribeSpec({ } } } + + context("InternalAuth.findUserId()가 null을 반환하면") { + every { gotchaFacadeV3.gotcha(any(), any(), any()) } returns listOf( + GotchaResponseV3(name = "GOOSE", dropRate = "1.0"), + ) + every { internalAuth.findUserId() } returns null + + it("이벤트를 트래킹하지 않고 silent하게 동작한다") { + controller.gotchaV3("token", "DEFAULT", 1) + + verify(exactly = 0) { + eventLogger.track(any(), any(), any()) + } + } + } } }) diff --git a/src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt b/src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt index 5facba3..6d84ffa 100644 --- a/src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt +++ b/src/test/kotlin/org/gitanimals/quiz/app/QuizEventTrackingTest.kt @@ -77,15 +77,6 @@ internal class QuizEventTrackingTest( } } - context("EventLogger가 예외를 던져도") { - val quizContext2 = quizSolveContextRepository.save(quizSolveContext(userId = userId)) - quizSolveContextService.getAndStartSolveQuizContext(quizContext2.id, quizContext2.userId) - every { eventLogger.track(any(), any(), any()) } throws RuntimeException("tracking error") - - it("예외가 전파되지 않는다") { - solveQuizFacade.answerQuizById(quizContext2.id, "YES") - } - } } describe("createContext 메소드는") {