Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions docs/api-specs/admin-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. 인증은 통과하므로 자격 문제가 아니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,6 +39,20 @@ public ApiResponse<MixpanelEventReport> mixpanel(
return ApiResponse.onSuccess(mixpanelClient.fetchDailyCounts(events, from, to));
}

@GetMapping("/mixpanel/funnel")
@Operation(summary = "Mixpanel 이벤트 퍼널 이탈율",
description = "steps 순서대로 밟은 고유 사용자를 센다. 다음 단계는 앞 단계 이후여야 하고 첫 단계로부터 windowDays 안에 끝나야 한다")
public ApiResponse<MixpanelFunnelReport> mixpanelFunnel(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
@RequestParam List<String> 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")
Expand All @@ -47,6 +63,25 @@ public ApiResponse<SentryIssueReport> sentry(
return ApiResponse.onSuccess(sentryClient.fetchUnresolvedIssues(from, to));
}

private void validateSteps(List<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -231,6 +233,177 @@ private void add(LocalDate date, Instant occurredAt, String distinctId) {
}
}

/**
* 지정한 순서대로 이벤트를 밟은 고유 사용자를 센다.
*
* <p>집계 API 의 퍼널 리포트를 쓸 수 없어(플랜 제한) 원본 이벤트로 직접 계산한다.
* 규칙은 Mixpanel 화면과 같다. 다음 단계는 앞 단계 이후여야 하고, 첫 단계로부터
* {@code windowDays} 안에 끝나야 한다. 2026-08 실데이터로 Mixpanel 화면 숫자와 일치를 확인했다.
*/
public MixpanelFunnelReport fetchFunnel(
List<String> 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} 처럼 속성까지 좁힌다.
*
* <p>배틀 흐름은 선택·재생·투표가 모두 {@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<String> steps, int windowDays, LocalDate from, LocalDate to) throws Exception {
List<StepMatcher> matchers = steps.stream().map(StepMatcher::parse).toList();
Map<String, List<long[]>> 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<long[]> 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<long[]> events, int step) {
return nextTimeOf(events, step, Long.MIN_VALUE, Long.MAX_VALUE);
}

private Long nextTimeOf(List<long[]> 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<String> steps, int windowDays, LocalDate from, LocalDate to, long[] reached) {
long entered = reached[0];
List<MixpanelFunnelReport.Step> 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()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.swyp.picke.domain.admin.analytics;

import java.time.Instant;
import java.time.LocalDate;
import java.util.List;

/**
* 지정한 이벤트 순서를 사용자가 끝까지 밟았는지 센 퍼널. 원본 이벤트로 서버가 직접 계산한다.
*
* <p>고유 사용자 기준이다. 한 사람이 같은 단계를 여러 번 밟아도 한 번으로 센다.
* 다음 단계는 앞 단계 이후에 일어나야 하고, 첫 단계로부터 {@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<Step> 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<String> steps, int windowDays, LocalDate from, LocalDate to) {
List<Step> 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);
}
}
Loading
Loading