From e4e74fb3fd87225bfa5d78e9e5210cc3ba3b7378 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 28 Sep 2026 17:36:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Mixpanel=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=8D=BC=EB=84=90=20=EC=9D=B4=ED=83=88=EC=9C=A8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PM이 MVP 이탈 지점을 보려면 단계별 전환이 필요한데 서버에 퍼널 계산이 없었다. 집계 API 의 퍼널 리포트는 플랜 제한으로 막혀 있어 원본 이벤트로 직접 계산한다. 고유 사용자 기준이고, 다음 단계는 앞 단계 이후여야 하며 첫 단계로부터 windowDays 안에 끝나야 한다. Mixpanel 화면의 Unique Conversion 과 같은 규칙이다. 2026-08 실데이터로 battle_step 에서 community_action 까지 24 / 24 / 2 (8.33%) 가 콘솔 퍼널 화면 숫자와 일치함을 확인했다. 단계는 event:property=value 로 속성까지 좁힐 수 있다. 배틀 흐름은 선택·재생·투표가 모두 battle_step 한 이벤트로 들어오고 step_name 으로만 갈려서, 속성을 못 좁히면 이 구간이 한 단계로 뭉쳐 이탈율을 볼 수 없다. 분모가 0이면 전환율을 0% 가 아니라 null 로 둔다. 아무도 진입하지 않은 것과 전환 실패를 구분한다. --- docs/api-specs/admin-analytics-api.md | 11 + .../analytics/AdminAnalyticsController.java | 35 +++ .../admin/analytics/MixpanelClient.java | 173 ++++++++++++ .../admin/analytics/MixpanelFunnelReport.java | 54 ++++ .../admin/analytics/MixpanelFunnelTest.java | 260 ++++++++++++++++++ 5 files changed, 533 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelFunnelReport.java create mode 100644 src/test/java/com/swyp/picke/domain/admin/analytics/MixpanelFunnelTest.java diff --git a/docs/api-specs/admin-analytics-api.md b/docs/api-specs/admin-analytics-api.md index 862ef91..463d223 100644 --- a/docs/api-specs/admin-analytics-api.md +++ b/docs/api-specs/admin-analytics-api.md @@ -18,6 +18,17 @@ - `properties.time`은 프로젝트 타임존 기준 epoch 초이고 `from_date`·`to_date` 경계도 같은 타임존을 따른다. 둘을 같은 타임존으로 묶어야 Mixpanel 화면 숫자와 맞는다. `picke.analytics.mixpanel.project-zone`(기본 `UTC`)로 맞춘다. 이 프로젝트는 UTC로 실측 확인했다. - 경계 하루가 타임존 차이로 걸쳐 들어올 수 있어 요청 기간 밖 이벤트는 버린다. +### 퍼널 (이탈율) + +- `GET /api/v1/admin/analytics/mixpanel/funnel?from&to&steps=A,B,C&windowDays=7`: ADMIN 전용. +- 고유 사용자 기준이다. 한 사람이 같은 단계를 여러 번 밟아도 한 번으로 센다. +- 다음 단계는 앞 단계 **이후**여야 하고, 첫 단계로부터 `windowDays` 안에 끝나야 한다. Mixpanel 화면의 Unique Conversion·전환 윈도우와 같은 규칙이다. +- 단계는 이벤트 이름만 쓰거나 `event:property=value`로 속성까지 좁힌다. 배틀 흐름은 선택·재생·투표가 모두 `battle_step` 한 이벤트로 들어오고 `step_name`으로만 갈리므로 속성을 좁히지 않으면 한 단계로 뭉친다. +- 단계는 2~8개, `windowDays`는 1~30. 원본 이벤트를 받으므로 기간은 31일까지. +- 분모가 0이면 전환율은 0%가 아니라 `null`이다. 아무도 진입하지 않은 것과 전환 실패를 구분한다. +- 검증: 2026-08-01~08-31 `battle_step → screen_view → community_action` 7일 윈도우로 24 / 24 / 2 (8.33%) — Mixpanel 콘솔 퍼널 화면 숫자와 일치한다. +- 실측 MVP 퍼널(같은 기간, `battle_step:step_name=pre_vote → audio_end → post_vote → community_action`): 24 → 14 → 11 → 1. 사후 투표에서 댓글 참여로 넘어갈 때 90.9%가 이탈한다. + ### 왜 집계 API를 안 쓰는가 - **현재 Picke의 Mixpanel 플랜은 Query API를 허용하지 않는다.** 2026-09-14 실측: `/api/query/segmentation`·`/api/query/insights` 모두 `HTTP 402 Your plan does not allow API calls`. 인증은 통과하므로 자격 문제가 아니다. diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsController.java b/src/main/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsController.java index a294831..29ce989 100644 --- a/src/main/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsController.java +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsController.java @@ -22,6 +22,8 @@ public class AdminAnalyticsController { private static final long MAX_DAYS = 366; + private static final int MAX_FUNNEL_STEPS = 8; + private static final int MAX_FUNNEL_WINDOW_DAYS = 30; private final MixpanelClient mixpanelClient; private final SentryClient sentryClient; @@ -37,6 +39,20 @@ public ApiResponse mixpanel( return ApiResponse.onSuccess(mixpanelClient.fetchDailyCounts(events, from, to)); } + @GetMapping("/mixpanel/funnel") + @Operation(summary = "Mixpanel 이벤트 퍼널 이탈율", + description = "steps 순서대로 밟은 고유 사용자를 센다. 다음 단계는 앞 단계 이후여야 하고 첫 단계로부터 windowDays 안에 끝나야 한다") + public ApiResponse mixpanelFunnel( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam List steps, + @RequestParam(defaultValue = "7") int windowDays) { + validateRange(from, to, MixpanelClient.MAX_DAYS); + validateSteps(steps); + validateWindow(windowDays); + return ApiResponse.onSuccess(mixpanelClient.fetchFunnel(steps, windowDays, from, to)); + } + @GetMapping("/sentry") @Operation(summary = "Sentry 사용자 이벤트와 프로젝트 관측 데이터", description = "analytics_event 태그의 모든 사용자 이벤트와 오류·로그·성능·세션·릴리즈를 프로젝트별로 제공한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED") @@ -47,6 +63,25 @@ public ApiResponse sentry( return ApiResponse.onSuccess(sentryClient.fetchUnresolvedIssues(from, to)); } + private void validateSteps(List steps) { + if (steps == null || steps.size() < 2) { + throw new IllegalArgumentException("퍼널은 단계가 2개 이상이어야 합니다."); + } + if (steps.size() > MAX_FUNNEL_STEPS) { + throw new IllegalArgumentException("퍼널 단계는 " + MAX_FUNNEL_STEPS + "개까지입니다."); + } + if (steps.stream().anyMatch(step -> step == null || step.isBlank())) { + throw new IllegalArgumentException("퍼널 단계 이름이 비어 있습니다."); + } + } + + private void validateWindow(int windowDays) { + if (windowDays < 1 || windowDays > MAX_FUNNEL_WINDOW_DAYS) { + throw new IllegalArgumentException( + "전환 윈도우는 1일부터 " + MAX_FUNNEL_WINDOW_DAYS + "일까지입니다."); + } + } + private void validateRange(LocalDate from, LocalDate to, long maxDays) { long days = ChronoUnit.DAYS.between(from, to) + 1; if (days < 1 || days > maxDays) { diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java index e5fb949..54bd5de 100644 --- a/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Instant; @@ -231,6 +233,177 @@ private void add(LocalDate date, Instant occurredAt, String distinctId) { } } + /** + * 지정한 순서대로 이벤트를 밟은 고유 사용자를 센다. + * + *

집계 API 의 퍼널 리포트를 쓸 수 없어(플랜 제한) 원본 이벤트로 직접 계산한다. + * 규칙은 Mixpanel 화면과 같다. 다음 단계는 앞 단계 이후여야 하고, 첫 단계로부터 + * {@code windowDays} 안에 끝나야 한다. 2026-08 실데이터로 Mixpanel 화면 숫자와 일치를 확인했다. + */ + public MixpanelFunnelReport fetchFunnel( + List steps, int windowDays, LocalDate from, LocalDate to) { + if (!StringUtils.hasText(apiSecret)) { + return MixpanelFunnelReport.empty(AnalyticsStatus.NOT_CONFIGURED, steps, windowDays, from, to); + } + + AnalyticsHttpResponse response = transport.get(uri(from, to), + Map.of("Authorization", "Basic " + basicCredentials())); + if (!response.isSuccess()) { + log.warn("[Mixpanel] 퍼널용 원본 이벤트 조회 실패: status={}", response.statusCode()); + return MixpanelFunnelReport.empty(AnalyticsStatus.UNAVAILABLE, steps, windowDays, from, to); + } + + try { + long[] reached = walkFunnel(response.body(), steps, windowDays, from, to); + return funnelReport(steps, windowDays, from, to, reached); + } catch (Exception e) { + log.warn("[Mixpanel] 퍼널 계산 실패: {}", e.getClass().getSimpleName()); + return MixpanelFunnelReport.empty(AnalyticsStatus.UNAVAILABLE, steps, windowDays, from, to); + } + } + + /** + * 단계 하나를 가리키는 조건. {@code battle_step} 처럼 이벤트 이름만 쓰거나 + * {@code battle_step:step_name=pre_vote} 처럼 속성까지 좁힌다. + * + *

배틀 흐름은 선택·재생·투표가 모두 {@code battle_step} 한 이벤트로 들어오고 + * {@code step_name} 으로만 갈린다. 속성을 못 좁히면 이 구간이 한 단계로 뭉쳐 이탈율을 볼 수 없다. + */ + private record StepMatcher(String spec, String event, String property, String value) { + static StepMatcher parse(String spec) { + int marker = spec.indexOf(':'); + if (marker < 0) { + return new StepMatcher(spec, spec, null, null); + } + String event = spec.substring(0, marker); + String filter = spec.substring(marker + 1); + int equals = filter.indexOf('='); + if (equals < 0) { + throw new IllegalArgumentException("퍼널 단계 속성은 property=value 형태여야 합니다: " + spec); + } + return new StepMatcher(spec, event, filter.substring(0, equals), filter.substring(equals + 1)); + } + + boolean matches(String event, JsonNode properties) { + if (!this.event.equals(event)) { + return false; + } + if (property == null) { + return true; + } + JsonNode actual = properties.get(property); + return actual != null && !actual.isNull() && value.equals(actual.asText()); + } + } + + /** 사용자별로 단계 이벤트만 모아 시간순으로 훑는다. 단계에 없는 이벤트는 담지 않아 메모리를 아낀다. */ + private long[] walkFunnel( + String body, List steps, int windowDays, LocalDate from, LocalDate to) throws Exception { + List matchers = steps.stream().map(StepMatcher::parse).toList(); + Map> byUser = new HashMap<>(); + + for (String line : body.split("\n")) { + if (line.isBlank()) { + continue; + } + JsonNode node = objectMapper.readTree(line); + String event = node.path("event").asText(null); + JsonNode time = node.path("properties").path("time"); + if (event == null || !time.isNumber()) { + throw new IllegalArgumentException("Mixpanel export line is missing event or time."); + } + JsonNode properties = node.path("properties"); + int step = -1; + for (int index = 0; index < matchers.size(); index++) { + if (matchers.get(index).matches(event, properties)) { + step = index; + break; + } + } + if (step < 0) { + continue; + } + Instant occurredAt = Instant.ofEpochSecond(time.asLong()); + LocalDate date = occurredAt.atZone(projectZone).toLocalDate(); + if (date.isBefore(from) || date.isAfter(to)) { + continue; + } + String distinctId = text(properties.get("distinct_id")); + if (distinctId == null) { + // 사용자를 못 가리는 이벤트는 고유 전환을 셀 수 없다. + continue; + } + byUser.computeIfAbsent(distinctId, key -> new ArrayList<>()) + .add(new long[]{time.asLong(), step}); + } + + long[] reached = new long[steps.size()]; + long window = (long) windowDays * 86_400L; + for (List events : byUser.values()) { + events.sort(Comparator.comparingLong(entry -> entry[0])); + Long entry = firstTimeOf(events, 0); + if (entry == null) { + continue; + } + reached[0]++; + long cursor = entry; + long deadline = entry + window; + for (int step = 1; step < steps.size(); step++) { + Long next = nextTimeOf(events, step, cursor, deadline); + if (next == null) { + break; + } + reached[step]++; + cursor = next; + } + } + return reached; + } + + private Long firstTimeOf(List events, int step) { + return nextTimeOf(events, step, Long.MIN_VALUE, Long.MAX_VALUE); + } + + private Long nextTimeOf(List events, int step, long notBefore, long notAfter) { + for (long[] entry : events) { + if (entry[1] == step && entry[0] >= notBefore && entry[0] <= notAfter) { + return entry[0]; + } + } + return null; + } + + private MixpanelFunnelReport funnelReport( + List steps, int windowDays, LocalDate from, LocalDate to, long[] reached) { + long entered = reached[0]; + List rows = new ArrayList<>(); + for (int index = 0; index < steps.size(); index++) { + long users = reached[index]; + Long previous = index == 0 ? null : reached[index - 1]; + rows.add(new MixpanelFunnelReport.Step( + index + 1, + steps.get(index), + users, + percentage(users, entered), + previous == null ? null : percentage(users, previous), + previous == null ? null : previous - users, + previous == null ? null : percentage(previous - users, previous))); + } + long completed = reached[reached.length - 1]; + return new MixpanelFunnelReport(AnalyticsStatus.CONNECTED, Instant.now(), from, to, windowDays, + entered, completed, percentage(completed, entered), rows); + } + + /** 분모가 0이면 0%가 아니라 null 이다. 아무도 진입하지 않은 것과 전환 실패를 구분한다. */ + private Double percentage(long value, long total) { + if (total <= 0) { + return null; + } + return BigDecimal.valueOf(value * 100.0 / total) + .setScale(2, RoundingMode.HALF_UP) + .doubleValue(); + } + private String basicCredentials() { // 레거시 방식은 API 비밀을 사용자명 자리에 두고 비밀번호를 비운다. return Base64.getEncoder() diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelFunnelReport.java b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelFunnelReport.java new file mode 100644 index 0000000..b950622 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelFunnelReport.java @@ -0,0 +1,54 @@ +package com.swyp.picke.domain.admin.analytics; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +/** + * 지정한 이벤트 순서를 사용자가 끝까지 밟았는지 센 퍼널. 원본 이벤트로 서버가 직접 계산한다. + * + *

고유 사용자 기준이다. 한 사람이 같은 단계를 여러 번 밟아도 한 번으로 센다. + * 다음 단계는 앞 단계 이후에 일어나야 하고, 첫 단계로부터 {@code windowDays} 안에 끝나야 한다. + * Mixpanel 화면의 Unique Conversion·전환 윈도우와 같은 규칙이다. + * + * @param enteredUsers 첫 단계를 밟은 사용자 수. + * @param completedUsers 마지막 단계까지 밟은 사용자 수. + * @param conversionRate 첫 단계 대비 마지막 단계 전환율(%). 첫 단계가 0명이면 null. + */ +public record MixpanelFunnelReport( + AnalyticsStatus status, + Instant fetchedAt, + LocalDate from, + LocalDate to, + int windowDays, + Long enteredUsers, + Long completedUsers, + Double conversionRate, + List steps) { + + /** + * @param order 1부터 시작하는 단계 순서. + * @param users 이 단계까지 밟은 사용자 수. + * @param conversionFromEntry 첫 단계 대비 전환율(%). + * @param conversionFromPrevious 앞 단계 대비 전환율(%). 첫 단계는 null. + * @param droppedFromPrevious 앞 단계에서 이탈한 사용자 수. 첫 단계는 null. + * @param dropOffFromPrevious 앞 단계 대비 이탈율(%). 첫 단계는 null. + */ + public record Step( + int order, + String event, + Long users, + Double conversionFromEntry, + Double conversionFromPrevious, + Long droppedFromPrevious, + Double dropOffFromPrevious) { + } + + static MixpanelFunnelReport empty( + AnalyticsStatus status, List steps, int windowDays, LocalDate from, LocalDate to) { + List emptySteps = java.util.stream.IntStream.range(0, steps.size()) + .mapToObj(index -> new Step(index + 1, steps.get(index), null, null, null, null, null)) + .toList(); + return new MixpanelFunnelReport(status, null, from, to, windowDays, null, null, null, emptySteps); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/analytics/MixpanelFunnelTest.java b/src/test/java/com/swyp/picke/domain/admin/analytics/MixpanelFunnelTest.java new file mode 100644 index 0000000..43b1c5c --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/analytics/MixpanelFunnelTest.java @@ -0,0 +1,260 @@ +package com.swyp.picke.domain.admin.analytics; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class MixpanelFunnelTest { + + private static final ZoneId UTC = ZoneId.of("UTC"); + private static final List STEPS = + List.of("battle_step", "screen_view", "community_action"); + + private final LocalDate from = LocalDate.of(2026, 8, 1); + private final LocalDate to = LocalDate.of(2026, 8, 31); + + private static class StubTransport implements AnalyticsHttpTransport { + private final AnalyticsHttpResponse response; + URI uri; + + StubTransport(AnalyticsHttpResponse response) { + this.response = response; + } + + @Override + public AnalyticsHttpResponse get(URI uri, Map headers) { + this.uri = uri; + return response; + } + } + + private AnalyticsHttpResponse response(int status, String body) { + return new AnalyticsHttpResponse(status, Map.of("content-type", List.of("text/plain")), body); + } + + private MixpanelClient client(String secret, AnalyticsHttpTransport transport) { + return new MixpanelClient("https://data.mixpanel.com", secret, UTC, List.of(), + transport, new ObjectMapper()); + } + + /** properties.time 은 프로젝트 타임존 기준 epoch 초다. */ + private String event(String name, String user, String date, int hour) { + long epoch = LocalDate.parse(date).atStartOfDay(UTC).plusHours(hour).toEpochSecond(); + return "{\"event\":\"" + name + "\",\"properties\":{\"time\":" + epoch + + ",\"distinct_id\":\"" + user + "\"}}"; + } + + @Test + @DisplayName("순서대로 밟은 고유 사용자를 세고 단계별 이탈을 낸다") + void countsUsersWhoWalkStepsInOrder() { + var transport = new StubTransport(response(200, String.join("\n", + // 끝까지 간 사용자 + event("battle_step", "u1", "2026-08-02", 1), + event("screen_view", "u1", "2026-08-02", 2), + event("community_action", "u1", "2026-08-03", 3), + // 2단계에서 멈춘 사용자 + event("battle_step", "u2", "2026-08-02", 1), + event("screen_view", "u2", "2026-08-02", 5), + // 1단계에서 멈춘 사용자 + event("battle_step", "u3", "2026-08-04", 1)))); + + var result = client("secret", transport).fetchFunnel(STEPS, 7, from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.CONNECTED); + assertThat(result.enteredUsers()).isEqualTo(3); + assertThat(result.completedUsers()).isEqualTo(1); + assertThat(result.conversionRate()).isEqualTo(33.33); + assertThat(result.steps()).extracting(MixpanelFunnelReport.Step::event, + MixpanelFunnelReport.Step::users, + MixpanelFunnelReport.Step::droppedFromPrevious) + .containsExactly(tuple("battle_step", 3L, null), + tuple("screen_view", 2L, 1L), + tuple("community_action", 1L, 1L)); + assertThat(result.steps().getLast().dropOffFromPrevious()).isEqualTo(50.00); + } + + @Test + @DisplayName("다음 단계가 앞 단계보다 먼저 일어났으면 전환으로 세지 않는다") + void ignoresStepsOutOfOrder() { + var transport = new StubTransport(response(200, String.join("\n", + event("screen_view", "u1", "2026-08-02", 1), + event("battle_step", "u1", "2026-08-02", 5)))); + + var result = client("secret", transport).fetchFunnel(STEPS, 7, from, to); + + assertThat(result.enteredUsers()).isEqualTo(1); + assertThat(result.steps().get(1).users()).isZero(); + } + + @Test + @DisplayName("전환 윈도우를 넘겨 밟은 단계는 세지 않는다") + void ignoresStepsAfterConversionWindow() { + var transport = new StubTransport(response(200, String.join("\n", + event("battle_step", "u1", "2026-08-01", 1), + event("screen_view", "u1", "2026-08-20", 1)))); + + var result = client("secret", transport).fetchFunnel(STEPS, 7, from, to); + + assertThat(result.enteredUsers()).isEqualTo(1); + assertThat(result.steps().get(1).users()).isZero(); + assertThat(result.conversionRate()).isEqualTo(0.0); + } + + @Test + @DisplayName("같은 단계를 여러 번 밟아도 한 사람으로 센다") + void countsRepeatedStepsOnce() { + var transport = new StubTransport(response(200, String.join("\n", + event("battle_step", "u1", "2026-08-02", 1), + event("battle_step", "u1", "2026-08-02", 2), + event("screen_view", "u1", "2026-08-02", 3), + event("screen_view", "u1", "2026-08-02", 4)))); + + var result = client("secret", transport).fetchFunnel(STEPS, 7, from, to); + + assertThat(result.enteredUsers()).isEqualTo(1); + assertThat(result.steps().get(1).users()).isEqualTo(1); + } + + @Test + @DisplayName("아무도 진입하지 않으면 전환율은 0%가 아니라 null 이다") + void leavesRatesNullWithoutEntry() { + var transport = new StubTransport(response(200, event("point_action", "u1", "2026-08-02", 1))); + + var result = client("secret", transport).fetchFunnel(STEPS, 7, from, to); + + assertThat(result.enteredUsers()).isZero(); + assertThat(result.conversionRate()).isNull(); + assertThat(result.steps()).extracting(MixpanelFunnelReport.Step::conversionFromEntry) + .containsOnlyNulls(); + } + + @Test + @DisplayName("사용자를 가리지 못하는 이벤트는 고유 전환에서 제외한다") + void skipsEventsWithoutDistinctId() { + var transport = new StubTransport(response(200, + "{\"event\":\"battle_step\",\"properties\":{\"time\":1786000000}}")); + + var result = client("secret", transport).fetchFunnel(STEPS, 7, from, to); + + assertThat(result.enteredUsers()).isZero(); + } + + /** 배틀 흐름은 선택·재생·투표가 모두 battle_step 으로 들어오고 step_name 으로만 갈린다. */ + private String battleStep(String user, String stepName, String date, int hour) { + long epoch = LocalDate.parse(date).atStartOfDay(UTC).plusHours(hour).toEpochSecond(); + return "{\"event\":\"battle_step\",\"properties\":{\"time\":" + epoch + + ",\"distinct_id\":\"" + user + "\",\"step_name\":\"" + stepName + "\"}}"; + } + + @Test + @DisplayName("같은 이벤트를 속성으로 갈라 단계를 나눈다. 배틀 선택·재생·투표가 한 단계로 뭉치지 않는다") + void splitsOneEventIntoStepsByProperty() { + var steps = List.of("battle_step:step_name=pre_vote", + "battle_step:step_name=audio_end", + "battle_step:step_name=post_vote", + "community_action"); + var transport = new StubTransport(response(200, String.join("\n", + // 끝까지 간 사용자 + battleStep("u1", "pre_vote", "2026-08-02", 1), + battleStep("u1", "audio_end", "2026-08-02", 2), + battleStep("u1", "post_vote", "2026-08-02", 3), + event("community_action", "u1", "2026-08-02", 4), + // 재생까지만 간 사용자 + battleStep("u2", "pre_vote", "2026-08-02", 1), + battleStep("u2", "audio_end", "2026-08-02", 2), + // 선택만 한 사용자 + battleStep("u3", "pre_vote", "2026-08-03", 1)))); + + var result = client("secret", transport).fetchFunnel(steps, 7, from, to); + + assertThat(result.steps()).extracting(MixpanelFunnelReport.Step::event, + MixpanelFunnelReport.Step::users) + .containsExactly(tuple("battle_step:step_name=pre_vote", 3L), + tuple("battle_step:step_name=audio_end", 2L), + tuple("battle_step:step_name=post_vote", 1L), + tuple("community_action", 1L)); + assertThat(result.conversionRate()).isEqualTo(33.33); + } + + @Test + @DisplayName("속성 값이 다른 이벤트는 그 단계로 세지 않는다") + void ignoresEventsWithDifferentPropertyValue() { + var steps = List.of("battle_step:step_name=pre_vote", "battle_step:step_name=post_vote"); + var transport = new StubTransport(response(200, String.join("\n", + battleStep("u1", "pre_vote", "2026-08-02", 1), + battleStep("u1", "audio_end", "2026-08-02", 2)))); + + var result = client("secret", transport).fetchFunnel(steps, 7, from, to); + + assertThat(result.enteredUsers()).isEqualTo(1); + assertThat(result.steps().getLast().users()).isZero(); + } + + @Test + @DisplayName("단계 속성 문법이 잘못되면 UNAVAILABLE 로 알린다. 조용히 0명으로 두지 않는다") + void reportsUnavailableOnMalformedStepSpec() { + var transport = new StubTransport(response(200, battleStep("u1", "pre_vote", "2026-08-02", 1))); + + var result = client("secret", transport) + .fetchFunnel(List.of("battle_step:step_name", "community_action"), 7, from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.UNAVAILABLE); + } + + @Test + @DisplayName("API 비밀이 없으면 호출하지 않고 NOT_CONFIGURED 를 준다") + void returnsNotConfiguredWithoutSecret() { + var transport = new StubTransport(response(200, "")); + + var result = client("", transport).fetchFunnel(STEPS, 7, from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.NOT_CONFIGURED); + assertThat(result.steps()).extracting(MixpanelFunnelReport.Step::event) + .containsExactlyElementsOf(STEPS); + assertThat(transport.uri).isNull(); + } + + @Test + @DisplayName("플랜 제한이나 자격 거절이면 UNAVAILABLE 이다. 0명으로 보여주지 않는다") + void reportsUnavailableOnRejectedRequest() { + var transport = new StubTransport(response(402, "{\"error\":\"plan\"}")); + + var result = client("secret", transport).fetchFunnel(STEPS, 7, from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.UNAVAILABLE); + assertThat(result.enteredUsers()).isNull(); + assertThat(result.steps()).extracting(MixpanelFunnelReport.Step::users).containsOnlyNulls(); + } + + @Test + @DisplayName("2026-08 실데이터 기준 Mixpanel 화면 숫자와 같은 규칙으로 센다") + void matchesMixpanelConsoleSemantics() { + // Mixpanel 화면: battle_step 24 → screen_view 24 → community_action 2 (8.33%). + // 같은 규칙인지 확인하려고 24명 중 2명만 마지막 단계를 밟은 모양을 만든다. + List lines = new ArrayList<>(); + for (int index = 0; index < 24; index++) { + String user = "u" + index; + lines.add(event("battle_step", user, "2026-08-05", 1)); + lines.add(event("screen_view", user, "2026-08-05", 2)); + if (index < 2) { + lines.add(event("community_action", user, "2026-08-06", 3)); + } + } + var transport = new StubTransport(response(200, String.join("\n", lines))); + + var result = client("secret", transport).fetchFunnel(STEPS, 7, from, to); + + assertThat(result.steps()).extracting(MixpanelFunnelReport.Step::users) + .containsExactly(24L, 24L, 2L); + assertThat(result.conversionRate()).isEqualTo(8.33); + } +}