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
10 changes: 9 additions & 1 deletion docs/api-specs/admin-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@
- EU·인도 데이터 거주 프로젝트는 호스트가 다르다. `picke.analytics.mixpanel.base-url`로 바꾼다.
- `ad_click` 이벤트에 `unit`·`placement`·`format` 속성이 붙어 온다. AdFit 클릭은 `unit` 없이 `format=popup`·`placement=app_start`로 들어온다. 광고 클릭을 매체별로 나누려면 이 속성을 쓴다.

## Sentry
## Sentry 사용자 이벤트

- iOS 분석 모듈은 Mixpanel에 보내는 모든 이벤트를 Sentry에도 info 이벤트로 전송하고 `analytics_event` 태그를 붙인다.
- `projects[].analyticsEvents.events[]`는 이 태그를 기준으로 이벤트명·기간 발생 수·고유 사용자·최초/마지막 발생·일별 발생 수/고유 사용자를 제공한다.
- 이벤트 이름은 서버에 하드코딩하지 않는다. 선택 기간에 실제 수집된 태그 값을 전부 조회하므로 `sign_up`, `ui_action` 등 새 액션도 자동으로 노출된다.
- Sentry Explore의 한 요청당 상위 시계열 10개 제한 때문에 이벤트명을 먼저 조회한 뒤 10개씩 나누어 일별 추이를 조회한다.
- `analyticsEvents.status`가 `UNAVAILABLE`이어도 오류·세션·릴리즈 등 다른 Sentry 데이터는 가능한 범위에서 유지한다.

## Sentry 오류

- `GET /api/0/projects/{org}/{project}/issues/?query=is:unresolved&sort=freq`. 조직 인증 토큰 `Authorization: Bearer`.
- 절대 기간을 쓰려면 `statsPeriod`를 빈 값으로 함께 보낸다. 생략하면 Sentry 기본 기간이 적용된다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
@RequiredArgsConstructor
@RequestMapping("/api/v1/admin/analytics")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "관리자 지표", description = "Mixpanel 이벤트와 Sentry 오류를 관리자 화면에서 함께 본다")
@Tag(name = "관리자 지표", description = "Mixpanel 이벤트와 Sentry 사용자 이벤트·오류를 관리자 화면에서 함께 본다")
public class AdminAnalyticsController {

private static final long MAX_DAYS = 366;
Expand All @@ -38,8 +38,8 @@ public ApiResponse<MixpanelEventReport> mixpanel(
}

@GetMapping("/sentry")
@Operation(summary = "Sentry 프로젝트 전체 관측 데이터",
description = "프로젝트별 오류·로그·성능·프로파일·메트릭·세션·릴리즈와 full 오류 이벤트를 반환한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED")
@Operation(summary = "Sentry 사용자 이벤트와 프로젝트 관측 데이터",
description = "analytics_event 태그의 모든 사용자 이벤트와 오류·로그·성능·세션·릴리즈를 프로젝트별로 제공한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED")
public ApiResponse<SentryIssueReport> sentry(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
Expand Down
204 changes: 203 additions & 1 deletion src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import java.time.LocalTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
Expand All @@ -32,6 +33,8 @@ public class SentryClient {
private static final int ISSUE_LIMIT = 20;
/** full=true 는 Sentry가 페이지 크기를 최대 10건으로 제한한다. */
private static final int RECENT_EVENT_LIMIT = 10;
private static final int ANALYTICS_EVENT_BATCH_SIZE = 10;
private static final String ANALYTICS_EVENT_FIELD = "tag[analytics_event,string]";
private static final List<String> DATASETS = List.of(
"errors", "logs", "spans", "profile_functions", "tracemetrics");

Expand Down Expand Up @@ -105,6 +108,7 @@ private SentryIssueReport.ProjectIssues fetchProject(String project, LocalDate f
.map(dataset -> fetchDataset(project, dataset, from, to))
.toList());
datasets.add(fetchSignUps(project, from, to));
SentryIssueReport.AnalyticsEventCatalog analyticsEvents = fetchAnalyticsEvents(project, from, to);
SentryIssueReport.MetricCatalog metricCatalog = fetchMetricCatalog(project, from, to);
SentryIssueReport.SessionHealth sessionHealth = fetchSessionHealth(project, from, to);
SentryIssueReport.ResourceCatalog releases = fetchReleases(project);
Expand All @@ -116,7 +120,7 @@ private SentryIssueReport.ProjectIssues fetchProject(String project, LocalDate f
long total = days.stream().mapToLong(SentryIssueReport.Day::events).sum();
return new SentryIssueReport.ProjectIssues(
project, AnalyticsStatus.CONNECTED, total, unresolvedTotal, days, issues, recentEvents,
datasets, metricCatalog, sessionHealth, releases);
datasets, analyticsEvents, metricCatalog, sessionHealth, releases);
} catch (Exception e) {
log.warn("[Sentry] 핵심 응답 파싱 실패: project={}, {}", project, e.getClass().getSimpleName());
return SentryIssueReport.emptyProject(project, AnalyticsStatus.UNAVAILABLE);
Expand Down Expand Up @@ -265,6 +269,204 @@ private URI metricCatalogUri(String project, LocalDate from, LocalDate to) {
.toUri();
}

private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents(
String project, LocalDate from, LocalDate to) {
AnalyticsHttpResponse catalogResponse = transport.get(analyticsEventCatalogUri(project, from, to),
Map.of("Authorization", "Bearer " + authToken));
if (!catalogResponse.isSuccess() || !catalogResponse.isJson()) {
log.warn("[Sentry] 분석 이벤트 목록 조회 실패: project={}, status={}",
project, catalogResponse.statusCode());
return unavailableAnalyticsEvents();
}

try {
List<SentryIssueReport.AnalyticsEventSeries> catalog = parseAnalyticsEventCatalog(
catalogResponse.body());
if (catalog.isEmpty()) {
return new SentryIssueReport.AnalyticsEventCatalog(
AnalyticsStatus.CONNECTED, 0L, List.of());
}

Map<String, List<SentryIssueReport.AnalyticsEventDay>> daysByEvent = new LinkedHashMap<>();
List<String> names = catalog.stream().map(SentryIssueReport.AnalyticsEventSeries::event).toList();
for (int start = 0; start < names.size(); start += ANALYTICS_EVENT_BATCH_SIZE) {
List<String> batch = names.subList(start, Math.min(start + ANALYTICS_EVENT_BATCH_SIZE, names.size()));
AnalyticsHttpResponse seriesResponse = transport.get(
analyticsEventTimeseriesUri(project, batch, from, to),
Map.of("Authorization", "Bearer " + authToken));
if (!seriesResponse.isSuccess() || !seriesResponse.isJson()) {
log.warn("[Sentry] 분석 이벤트 추이 조회 실패: project={}, status={}",
project, seriesResponse.statusCode());
return unavailableAnalyticsEvents();
}
daysByEvent.putAll(parseAnalyticsEventDays(seriesResponse.body(), batch, from, to));
}

List<SentryIssueReport.AnalyticsEventSeries> events = catalog.stream()
.map(series -> new SentryIssueReport.AnalyticsEventSeries(
series.event(), series.total(), series.uniqueUsers(), series.firstSeen(), series.lastSeen(),
daysByEvent.getOrDefault(series.event(), emptyAnalyticsDays(from, to))))
.toList();
long total = events.stream().map(SentryIssueReport.AnalyticsEventSeries::total)
.filter(value -> value != null).mapToLong(Long::longValue).sum();
return new SentryIssueReport.AnalyticsEventCatalog(
AnalyticsStatus.CONNECTED, total, events);
} catch (Exception e) {
log.warn("[Sentry] 분석 이벤트 응답 파싱 실패: project={}, {}",
project, e.getClass().getSimpleName());
return unavailableAnalyticsEvents();
}
}

private URI analyticsEventCatalogUri(String project, LocalDate from, LocalDate to) {
return UriComponentsBuilder.fromUriString(baseUrl)
.path("/api/0/organizations/{organization}/events/")
.queryParam("project", project)
.queryParam("dataset", "errors")
.queryParam("start", from.atStartOfDay())
.queryParam("end", to.atTime(LocalTime.MAX).withNano(0))
.queryParam("field", ANALYTICS_EVENT_FIELD)
.queryParam("field", "count()")
.queryParam("field", "count_unique(user)")
.queryParam("field", "min(timestamp)")
.queryParam("field", "max(timestamp)")
.queryParam("query", "has:analytics_event")
.queryParam("sort", "-count()")
.queryParam("per_page", 100)
.buildAndExpand(organization)
.toUri();
}

private URI analyticsEventTimeseriesUri(
String project, List<String> events, LocalDate from, LocalDate to) {
String query = events.stream()
.map(event -> "analytics_event:\"" + event.replace("\"", "\\\"") + "\"")
.reduce((left, right) -> left + " OR " + right)
.map(value -> events.size() > 1 ? "(" + value + ")" : value)
.orElse("has:analytics_event");
return UriComponentsBuilder.fromUriString(baseUrl)
.path("/api/0/organizations/{organization}/events-timeseries/")
.queryParam("project", project)
.queryParam("dataset", "errors")
.queryParam("start", from.atStartOfDay())
.queryParam("end", to.atTime(LocalTime.MAX).withNano(0))
.queryParam("interval", 86400)
.queryParam("query", query)
.queryParam("groupBy", ANALYTICS_EVENT_FIELD)
.queryParam("topEvents", events.size())
.queryParam("sort", "-count()")
.queryParam("excludeOther", 1)
.queryParam("yAxis", "count()")
.queryParam("yAxis", "count_unique(user)")
.buildAndExpand(organization)
.toUri();
}

private List<SentryIssueReport.AnalyticsEventSeries> parseAnalyticsEventCatalog(String body) throws Exception {
JsonNode data = objectMapper.readTree(body).path("data");
if (!data.isArray()) {
throw new IllegalArgumentException("Sentry analytics event response must contain data.");
}
List<SentryIssueReport.AnalyticsEventSeries> events = new ArrayList<>();
for (JsonNode row : data) {
String event = analyticsEventName(row);
if (!StringUtils.hasText(event)) {
continue;
}
events.add(new SentryIssueReport.AnalyticsEventSeries(
event,
number(row, "count()"),
number(row, "count_unique(user)"),
instant(row, "min(timestamp)"),
instant(row, "max(timestamp)"),
List.of()));
}
return events.stream()
.sorted(Comparator.comparing(SentryIssueReport.AnalyticsEventSeries::total,
Comparator.nullsLast(Comparator.reverseOrder())))
.toList();
}

private Map<String, List<SentryIssueReport.AnalyticsEventDay>> parseAnalyticsEventDays(
String body, List<String> events, LocalDate from, LocalDate to) throws Exception {
JsonNode timeSeries = objectMapper.readTree(body).path("timeSeries");
if (!timeSeries.isArray()) {
throw new IllegalArgumentException("Sentry analytics timeseries response must contain timeSeries.");
}
Map<String, Map<LocalDate, Long>> counts = new LinkedHashMap<>();
Map<String, Map<LocalDate, Long>> users = new LinkedHashMap<>();
for (JsonNode series : timeSeries) {
String event = analyticsEventNameFromGroup(series.path("groupBy"));
if (!StringUtils.hasText(event) || !events.contains(event)) {
continue;
}
Map<String, Map<LocalDate, Long>> target = "count_unique(user)".equals(text(series, "yAxis"))
? users : counts;
Map<LocalDate, Long> valuesByDate = target.computeIfAbsent(event, ignored -> new LinkedHashMap<>());
JsonNode values = series.path("values");
if (!values.isArray()) {
continue;
}
for (JsonNode point : values) {
LocalDate date = Instant.ofEpochMilli(point.path("timestamp").asLong())
.atZone(ZoneOffset.UTC).toLocalDate();
if (!date.isBefore(from) && !date.isAfter(to) && point.path("value").isNumber()) {
valuesByDate.merge(date, point.path("value").asLong(), Long::sum);
}
}
}

Map<String, List<SentryIssueReport.AnalyticsEventDay>> result = new LinkedHashMap<>();
for (String event : events) {
List<SentryIssueReport.AnalyticsEventDay> days = new ArrayList<>();
for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) {
days.add(new SentryIssueReport.AnalyticsEventDay(
date,
counts.getOrDefault(event, Map.of()).getOrDefault(date, 0L),
users.getOrDefault(event, Map.of()).getOrDefault(date, 0L)));
}
result.put(event, List.copyOf(days));
}
return result;
}

private String analyticsEventName(JsonNode row) {
for (String field : List.of(ANALYTICS_EVENT_FIELD, "tag[analytics_event]", "analytics_event")) {
String value = text(row, field);
if (StringUtils.hasText(value)) {
return value;
}
}
return null;
}

private String analyticsEventNameFromGroup(JsonNode groupBy) {
if (!groupBy.isArray()) {
return null;
}
for (JsonNode group : groupBy) {
String key = text(group, "key");
if (ANALYTICS_EVENT_FIELD.equals(key) || "tag[analytics_event]".equals(key)
|| "analytics_event".equals(key)) {
return text(group, "value");
}
}
return null;
}

private List<SentryIssueReport.AnalyticsEventDay> emptyAnalyticsDays(LocalDate from, LocalDate to) {
List<SentryIssueReport.AnalyticsEventDay> days = new ArrayList<>();
for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) {
days.add(new SentryIssueReport.AnalyticsEventDay(date, 0L, 0L));
}
return List.copyOf(days);
}

private SentryIssueReport.AnalyticsEventCatalog unavailableAnalyticsEvents() {
return new SentryIssueReport.AnalyticsEventCatalog(
AnalyticsStatus.UNAVAILABLE, null, List.of());
}

private SentryIssueReport.SessionHealth fetchSessionHealth(
String project, LocalDate from, LocalDate to) {
AnalyticsHttpResponse response = transport.get(sessionUri(project, from, to),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public record ProjectIssues(
List<Issue> issues,
List<Event> recentEvents,
List<DatasetSeries> datasets,
AnalyticsEventCatalog analyticsEvents,
MetricCatalog metricCatalog,
SessionHealth sessionHealth,
ResourceCatalog releases) {
Expand Down Expand Up @@ -93,6 +94,28 @@ public record DatasetSeries(
List<Day> days) {
}

/** analytics_event 태그로 전송된 사용자 행동 이벤트 집계. */
public record AnalyticsEventCatalog(
AnalyticsStatus status,
Long totalEvents,
List<AnalyticsEventSeries> events) {
}

public record AnalyticsEventSeries(
String event,
Long total,
Long uniqueUsers,
Instant firstSeen,
Instant lastSeen,
List<AnalyticsEventDay> days) {
}

public record AnalyticsEventDay(
LocalDate date,
Long count,
Long uniqueUsers) {
}

/** 앱이 전송한 커스텀 Sentry 메트릭의 이름·타입·단위·건수·마지막 수집 시각 원본. */
public record MetricCatalog(
AnalyticsStatus status,
Expand Down Expand Up @@ -122,6 +145,7 @@ static SentryIssueReport empty(AnalyticsStatus status, LocalDate from, LocalDate

static ProjectIssues emptyProject(String project, AnalyticsStatus status) {
return new ProjectIssues(project, status, null, null, List.of(), List.of(), List.of(), List.of(),
new AnalyticsEventCatalog(status, null, List.of()),
new MetricCatalog(status, List.of()),
new SessionHealth(status, List.of(), Map.of()),
new ResourceCatalog(status, List.of()));
Expand Down
Loading
Loading