diff --git a/docs/api-specs/admin-analytics-api.md b/docs/api-specs/admin-analytics-api.md index cd90ac8..862ef91 100644 --- a/docs/api-specs/admin-analytics-api.md +++ b/docs/api-specs/admin-analytics-api.md @@ -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 기본 기간이 적용된다. 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 ea43eb6..a294831 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 @@ -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; @@ -38,8 +38,8 @@ public ApiResponse mixpanel( } @GetMapping("/sentry") - @Operation(summary = "Sentry 프로젝트 전체 관측 데이터", - description = "프로젝트별 오류·로그·성능·프로파일·메트릭·세션·릴리즈와 full 오류 이벤트를 반환한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED") + @Operation(summary = "Sentry 사용자 이벤트와 프로젝트 관측 데이터", + description = "analytics_event 태그의 모든 사용자 이벤트와 오류·로그·성능·세션·릴리즈를 프로젝트별로 제공한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED") public ApiResponse sentry( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java b/src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java index cbd5ddc..aa279d4 100644 --- a/src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java @@ -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; @@ -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 DATASETS = List.of( "errors", "logs", "spans", "profile_functions", "tracemetrics"); @@ -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); @@ -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); @@ -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 catalog = parseAnalyticsEventCatalog( + catalogResponse.body()); + if (catalog.isEmpty()) { + return new SentryIssueReport.AnalyticsEventCatalog( + AnalyticsStatus.CONNECTED, 0L, List.of()); + } + + Map> daysByEvent = new LinkedHashMap<>(); + List names = catalog.stream().map(SentryIssueReport.AnalyticsEventSeries::event).toList(); + for (int start = 0; start < names.size(); start += ANALYTICS_EVENT_BATCH_SIZE) { + List 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 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 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 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 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> parseAnalyticsEventDays( + String body, List 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> counts = new LinkedHashMap<>(); + Map> users = new LinkedHashMap<>(); + for (JsonNode series : timeSeries) { + String event = analyticsEventNameFromGroup(series.path("groupBy")); + if (!StringUtils.hasText(event) || !events.contains(event)) { + continue; + } + Map> target = "count_unique(user)".equals(text(series, "yAxis")) + ? users : counts; + Map 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> result = new LinkedHashMap<>(); + for (String event : events) { + List 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 emptyAnalyticsDays(LocalDate from, LocalDate to) { + List 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), diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/SentryIssueReport.java b/src/main/java/com/swyp/picke/domain/admin/analytics/SentryIssueReport.java index d28b1d7..c7394df 100644 --- a/src/main/java/com/swyp/picke/domain/admin/analytics/SentryIssueReport.java +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/SentryIssueReport.java @@ -34,6 +34,7 @@ public record ProjectIssues( List issues, List recentEvents, List datasets, + AnalyticsEventCatalog analyticsEvents, MetricCatalog metricCatalog, SessionHealth sessionHealth, ResourceCatalog releases) { @@ -93,6 +94,28 @@ public record DatasetSeries( List days) { } + /** analytics_event 태그로 전송된 사용자 행동 이벤트 집계. */ + public record AnalyticsEventCatalog( + AnalyticsStatus status, + Long totalEvents, + List events) { + } + + public record AnalyticsEventSeries( + String event, + Long total, + Long uniqueUsers, + Instant firstSeen, + Instant lastSeen, + List days) { + } + + public record AnalyticsEventDay( + LocalDate date, + Long count, + Long uniqueUsers) { + } + /** 앱이 전송한 커스텀 Sentry 메트릭의 이름·타입·단위·건수·마지막 수집 시각 원본. */ public record MetricCatalog( AnalyticsStatus status, @@ -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())); diff --git a/src/test/java/com/swyp/picke/domain/admin/analytics/SentryClientTest.java b/src/test/java/com/swyp/picke/domain/admin/analytics/SentryClientTest.java index f63a03d..2fba337 100644 --- a/src/test/java/com/swyp/picke/domain/admin/analytics/SentryClientTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/analytics/SentryClientTest.java @@ -25,6 +25,10 @@ private static class CapturingTransport implements AnalyticsHttpTransport { "[[1788825600,25],[1788912000,40],[1788998400,60]]"); private AnalyticsHttpResponse eventResponse = new AnalyticsHttpResponse( 200, Map.of("content-type", List.of("application/json")), "[]"); + private AnalyticsHttpResponse analyticsEventCatalogResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), "{\"data\":[]}"); + private AnalyticsHttpResponse analyticsEventTimeseriesResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), "{\"timeSeries\":[]}"); private AnalyticsHttpResponse datasetResponse = new AnalyticsHttpResponse( 200, Map.of("content-type", List.of("application/json")), """ {"timeSeries":[{"values":[ @@ -67,6 +71,14 @@ CapturingTransport withEvents(AnalyticsHttpResponse response) { return this; } + CapturingTransport withAnalyticsEvents( + AnalyticsHttpResponse catalogResponse, + AnalyticsHttpResponse timeseriesResponse) { + analyticsEventCatalogResponse = catalogResponse; + analyticsEventTimeseriesResponse = timeseriesResponse; + return this; + } + @Override public AnalyticsHttpResponse get(URI uri, Map headers) { this.uris.add(uri); @@ -74,10 +86,16 @@ public AnalyticsHttpResponse get(URI uri, Map headers) { if (uri.getPath().endsWith("/stats/")) { return statsResponse; } + if (uri.getPath().contains("/organizations/") && uri.getPath().endsWith("/events/")) { + return analyticsEventCatalogResponse; + } if (uri.getPath().endsWith("/events/")) { return eventResponse; } if (uri.getPath().endsWith("/events-timeseries/")) { + if (uri.getQuery() != null && uri.getQuery().contains("topEvents=")) { + return analyticsEventTimeseriesResponse; + } return datasetResponse; } if (uri.getPath().endsWith("/trace-items/metrics/")) { @@ -145,7 +163,7 @@ void fetchesEveryProjectWithAbsoluteRange() { var result = client("token", transport).fetchUnresolvedIssues(from, to); assertThat(transport.headers).containsEntry("Authorization", "Bearer token"); - assertThat(transport.uris).hasSize(24); + assertThat(transport.uris).hasSize(26); assertThat(transport.uris.getFirst().toString()) .contains("/api/0/projects/picke/picke-ios/issues/") .contains("query=is:unresolved") @@ -219,6 +237,9 @@ void fetchesEveryProjectWithAbsoluteRange() { tuple("profile_functions", AnalyticsStatus.CONNECTED, 6L), tuple("tracemetrics", AnalyticsStatus.CONNECTED, 6L), tuple("sign_up", AnalyticsStatus.CONNECTED, 6L)); + assertThat(result.projects().getFirst().analyticsEvents().status()) + .isEqualTo(AnalyticsStatus.CONNECTED); + assertThat(result.projects().getFirst().analyticsEvents().events()).isEmpty(); assertThat(result.projects().getFirst().metricCatalog().entries().getFirst()) .containsEntry("name", "app.launch.count") .containsEntry("count", 8); @@ -229,6 +250,63 @@ void fetchesEveryProjectWithAbsoluteRange() { .containsEntry("version", "picke-ios@1.2.3+45"); } + + @Test + @DisplayName("analytics_event 태그로 수집한 모든 액션을 발생 수와 사용자 일별 추이로 묶는다") + void groupsEveryAnalyticsEventLikeMixpanel() { + var transport = new CapturingTransport(response(200, "application/json", "[]")) + .withAnalyticsEvents( + response(200, "application/json", """ + {"data":[ + {"tag[analytics_event,string]":"ui_action","count()":7, + "count_unique(user)":3,"min(timestamp)":"2026-09-08T01:00:00Z", + "max(timestamp)":"2026-09-10T02:00:00Z"}, + {"tag[analytics_event,string]":"sign_up","count()":"2", + "count_unique(user)":2,"min(timestamp)":"2026-09-09T03:00:00Z", + "max(timestamp)":"2026-09-10T04:00:00Z"} + ]} + """), + response(200, "application/json", """ + {"timeSeries":[ + {"yAxis":"count()","groupBy":[{"key":"tag[analytics_event,string]","value":"ui_action"}], + "values":[{"timestamp":1788825600000,"value":1},{"timestamp":1788912000000,"value":2},{"timestamp":1788998400000,"value":4}]}, + {"yAxis":"count_unique(user)","groupBy":[{"key":"tag[analytics_event,string]","value":"ui_action"}], + "values":[{"timestamp":1788825600000,"value":1},{"timestamp":1788912000000,"value":1},{"timestamp":1788998400000,"value":2}]}, + {"yAxis":"count()","groupBy":[{"key":"analytics_event","value":"sign_up"}], + "values":[{"timestamp":1788912000000,"value":1},{"timestamp":1788998400000,"value":1}]}, + {"yAxis":"count_unique(user)","groupBy":[{"key":"analytics_event","value":"sign_up"}], + "values":[{"timestamp":1788912000000,"value":1},{"timestamp":1788998400000,"value":1}]} + ]} + """)); + + var result = client("token", transport).fetchUnresolvedIssues(from, to); + + assertThat(transport.uris).anyMatch(uri -> uri.getPath().endsWith("/events/") + && uri.getPath().contains("/organizations/") + && uri.getQuery().contains("field=tag[analytics_event,string]") + && uri.getQuery().contains("query=has:analytics_event")); + assertThat(transport.uris).anyMatch(uri -> uri.getPath().endsWith("/events-timeseries/") + && uri.getQuery().contains("groupBy=tag[analytics_event,string]") + && uri.getQuery().contains("topEvents=2") + && uri.getQuery().contains("yAxis=count_unique(user)")); + var analytics = result.projects().getFirst().analyticsEvents(); + assertThat(analytics.status()).isEqualTo(AnalyticsStatus.CONNECTED); + assertThat(analytics.totalEvents()).isEqualTo(9); + assertThat(analytics.events()) + .extracting(SentryIssueReport.AnalyticsEventSeries::event, + SentryIssueReport.AnalyticsEventSeries::total, + SentryIssueReport.AnalyticsEventSeries::uniqueUsers) + .containsExactly(tuple("ui_action", 7L, 3L), tuple("sign_up", 2L, 2L)); + assertThat(analytics.events().getLast().days()) + .extracting(SentryIssueReport.AnalyticsEventDay::date, + SentryIssueReport.AnalyticsEventDay::count, + SentryIssueReport.AnalyticsEventDay::uniqueUsers) + .containsExactly( + tuple(LocalDate.of(2026, 9, 8), 0L, 0L), + tuple(LocalDate.of(2026, 9, 9), 1L, 1L), + tuple(LocalDate.of(2026, 9, 10), 1L, 1L)); + } + @Test @DisplayName("응답 순서를 믿지 않고 이벤트 수 내림차순으로 다시 정렬한다. sort=freq 가 절대 기간에서 순서를 보장하지 않는다") void sortsIssuesByEventsDescending() {