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
12 changes: 10 additions & 2 deletions docs/api-specs/admin-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- `GET https://data.mixpanel.com/api/2.0/export?from_date&to_date`. 응답은 한 줄에 이벤트 하나인 NDJSON.
- 인증은 프로젝트 API 비밀을 Basic 사용자명 자리에 넣고 비밀번호를 비우는 레거시 방식이다. 이 방식에 `project_id`를 넣으면 400이 된다. 비밀이 이미 프로젝트를 특정한다.
- `events[].days[]`: `{date, count}`. 최신 날짜부터. 원본을 전부 받아 세므로 이벤트가 없던 날짜는 미집계가 아니라 **0**이다.
- `signUpDays[]`: 선택 기간 전체의 `sign_up` 일별 가입 수. 이벤트 선택값과 무관하게 집계하며 날짜 오름차순이다.
- `events`를 비우면 기간에 나타난 이벤트 전부를 발생 수 내림차순으로 준다. 이벤트 이름을 미리 설정해 둘 필요가 없다.
- 원본을 전부 받으므로 기간은 **31일까지**다(`MixpanelClient.MAX_DAYS`). 4일치가 약 2MB다.
- `properties.time`은 프로젝트 타임존 기준 epoch 초이고 `from_date`·`to_date` 경계도 같은 타임존을 따른다. 둘을 같은 타임존으로 묶어야 Mixpanel 화면 숫자와 맞는다. `picke.analytics.mixpanel.project-zone`(기본 `UTC`)로 맞춘다. 이 프로젝트는 UTC로 실측 확인했다.
Expand All @@ -34,12 +35,19 @@

- `GET /api/0/projects/{org}/{project}/issues/?query=is:unresolved&sort=freq`. 조직 인증 토큰 `Authorization: Bearer`.
- 절대 기간을 쓰려면 `statsPeriod`를 빈 값으로 함께 보낸다. 생략하면 Sentry 기본 기간이 적용된다.
- iOS·Android를 각각 호출해 `projects[]`로 나눠 준다. `projects[]`: `{project, status, totalEvents, issues[]}`.
- iOS·Android를 각각 호출해 `projects[]`로 나눠 준다.
- 한 프로젝트가 막혀도 다른 프로젝트는 살린다. 대신 최상위 `status`는 `UNAVAILABLE`, 최상위 `totalEvents`는 `null`이다. 일부만 더한 값을 전체 합계처럼 보여주지 않는다.
- 프로젝트별 최대 20건. `projects[].totalEvents`는 그 20건의 합계이며 프로젝트 전체 이벤트 수가 아니다.
- `projects[].totalEvents`는 stats API의 선택 기간 전체 수신 이벤트 합계다. `issues[]`는 미해결 상위 20건이다.
- `sort=freq`는 절대 기간에서 이벤트 수 내림차순을 보장하지 않는다(실측 확인). 응답 순서를 믿지 않고 서버가 다시 정렬한다.
- `issues[].events`는 Sentry가 문자열로 주기도 한다. 숫자·문자열 모두 읽는다.
- `recentEvents[]`는 `/events/?full=true`의 최근 오류 이벤트 최대 10건이다. 자주 쓰는 필드를 정규화하고 `details`에 사용자·브레드크럼·컨텍스트·예외·스택트레이스 등 전체 JSON을 보존한다.
- `datasets[]`는 Picke-iOS에서 활성화한 `errors`, `logs`, `spans`, `profile_functions`, `tracemetrics` 데이터셋의 일별 발생량이다. 각 데이터셋 실패는 프로젝트 핵심 오류/이슈 조회를 실패시키지 않는다.
- `metricCatalog`는 커스텀 메트릭의 이름·타입·단위·건수·마지막 수집 시각과 컨텍스트를 원본 형태로 준다.
- `sessionHealth`는 자동 세션 추적의 `healthy`, `errored`, `crashed` 등 상태별 합계와 일별 시리즈를 준다.
- `releases`는 프로젝트 최근 릴리즈 최대 20개의 버전·빌드·커밋·배포·상태 원본을 준다.
- iOS 설정에서 Session Replay는 샘플 비율이 0으로 꺼져 있으므로 Replay 녹화 데이터는 조회하지 않는다.
- 환경변수: `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`(기본 `picke`), `SENTRY_PROJECTS`(기본 `picke-ios,picke-android`). 설정 키는 `picke.analytics.sentry.*`.
- Explore·메트릭·세션 API는 `org:read`, 오류 이벤트는 `event:read`, 프로젝트·릴리즈 조회는 `project:read` 또는 해당 상위 권한이 필요하다.
- 앱은 이미 같은 org·project로 Sentry에 리포트한다. 토큰만 서버에 넣으면 같은 데이터를 읽는다.
- 실측(2026-09-14, 최근 30일): `picke-ios` 미해결 2건·1,765 이벤트, `picke-android` 9건·181 이벤트.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ public ApiResponse<MixpanelEventReport> mixpanel(
}

@GetMapping("/sentry")
@Operation(summary = "Sentry 미해결 이슈 상위 목록",
description = "프로젝트별 일간 수신 오류와 미해결 이슈 최대 20건을 반환한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED")
@Operation(summary = "Sentry 프로젝트 전체 관측 데이터",
description = "프로젝트별 오류·로그·성능·프로파일·메트릭·세션·릴리즈와 full 오류 이벤트를 반환한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED")
public ApiResponse<SentryIssueReport> sentry(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
import java.util.Base64;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -89,7 +89,8 @@ public MixpanelEventReport fetchDailyCounts(List<String> requestedEvents, LocalD
Aggregation aggregation = aggregate(response.body(), targetEvents(requestedEvents), from, to);
return new MixpanelEventReport(AnalyticsStatus.CONNECTED, Instant.now(), from, to,
aggregation.totalEvents(), aggregation.uniqueUsers(), to,
aggregation.activeUsers(), aggregation.signUps(), aggregation.availableEvents(), aggregation.events());
aggregation.activeUsers(), aggregation.signUps(), aggregation.signUpDays(),
aggregation.availableEvents(), aggregation.events());
} catch (Exception e) {
log.warn("[Mixpanel] 원본 이벤트 파싱 실패: {}", e.getClass().getSimpleName());
return MixpanelEventReport.empty(AnalyticsStatus.UNAVAILABLE, from, to);
Expand All @@ -116,6 +117,7 @@ private Aggregation aggregate(
Set<String> availableEvents = new HashSet<>();
Set<String> reportUsers = new HashSet<>();
Set<String> activeUsers = new HashSet<>();
Map<LocalDate, Long> signUpsByDate = new HashMap<>();
long signUps = 0;

for (String line : body.split("\n")) {
Expand Down Expand Up @@ -144,6 +146,9 @@ private Aggregation aggregate(
signUps++;
}
}
if ("sign_up".equals(event)) {
signUpsByDate.merge(date, 1L, Long::sum);
}
if (wanted != null && !wanted.contains(event)) {
continue;
}
Expand All @@ -161,9 +166,19 @@ private Aggregation aggregate(
.toList();
long total = series.stream().mapToLong(MixpanelEventReport.EventSeries::total).sum();
return new Aggregation(total, (long) reportUsers.size(), (long) activeUsers.size(), signUps,
signUpDays(signUpsByDate, from, to),
availableEvents.stream().sorted().toList(), series);
}

private List<MixpanelEventReport.SignUpDay> signUpDays(
Map<LocalDate, Long> counts, LocalDate from, LocalDate to) {
List<MixpanelEventReport.SignUpDay> days = new ArrayList<>();
for (LocalDate cursor = from; !cursor.isAfter(to); cursor = cursor.plusDays(1)) {
days.add(new MixpanelEventReport.SignUpDay(cursor, counts.getOrDefault(cursor, 0L)));
}
return days;
}

/** 원본을 전부 받았으므로 이벤트가 없던 날짜는 미집계가 아니라 0 이다. */
private MixpanelEventReport.EventSeries series(
String event, EventStats stats, LocalDate from, LocalDate to) {
Expand All @@ -187,6 +202,7 @@ private record Aggregation(
Long uniqueUsers,
Long activeUsers,
Long signUps,
List<MixpanelEventReport.SignUpDay> signUpDays,
List<String> availableEvents,
List<MixpanelEventReport.EventSeries> events) {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public record MixpanelEventReport(
LocalDate summaryDate,
Long activeUsers,
Long signUps,
List<SignUpDay> signUpDays,
List<String> availableEvents,
List<EventSeries> events) {

Expand All @@ -38,8 +39,11 @@ public record EventSeries(
public record Day(LocalDate date, Long count, Long uniqueUsers) {
}

public record SignUpDay(LocalDate date, Long count) {
}

static MixpanelEventReport empty(AnalyticsStatus status, LocalDate from, LocalDate to) {
return new MixpanelEventReport(
status, null, from, to, null, null, to, null, null, List.of(), List.of());
status, null, from, to, null, null, to, null, null, List.of(), List.of(), List.of());
}
}
Loading
Loading