diff --git a/docs/api-specs/admin-analytics-api.md b/docs/api-specs/admin-analytics-api.md index b5e5b53..cd90ac8 100644 --- a/docs/api-specs/admin-analytics-api.md +++ b/docs/api-specs/admin-analytics-api.md @@ -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로 실측 확인했다. @@ -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 이벤트. 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 dbdbf2e..ea43eb6 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 @@ -38,8 +38,8 @@ public ApiResponse mixpanel( } @GetMapping("/sentry") - @Operation(summary = "Sentry 미해결 이슈 상위 목록", - description = "프로젝트별 일간 수신 오류와 미해결 이슈 최대 20건을 반환한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED") + @Operation(summary = "Sentry 프로젝트 전체 관측 데이터", + description = "프로젝트별 오류·로그·성능·프로파일·메트릭·세션·릴리즈와 full 오류 이벤트를 반환한다. 토큰·프로젝트 설정이 없으면 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/MixpanelClient.java b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java index 23b1866..e5fb949 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 @@ -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; @@ -89,7 +89,8 @@ public MixpanelEventReport fetchDailyCounts(List 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); @@ -116,6 +117,7 @@ private Aggregation aggregate( Set availableEvents = new HashSet<>(); Set reportUsers = new HashSet<>(); Set activeUsers = new HashSet<>(); + Map signUpsByDate = new HashMap<>(); long signUps = 0; for (String line : body.split("\n")) { @@ -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; } @@ -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 signUpDays( + Map counts, LocalDate from, LocalDate to) { + List 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) { @@ -187,6 +202,7 @@ private record Aggregation( Long uniqueUsers, Long activeUsers, Long signUps, + List signUpDays, List availableEvents, List events) { } diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelEventReport.java b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelEventReport.java index 897f8ed..d446e07 100644 --- a/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelEventReport.java +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelEventReport.java @@ -20,6 +20,7 @@ public record MixpanelEventReport( LocalDate summaryDate, Long activeUsers, Long signUps, + List signUpDays, List availableEvents, List events) { @@ -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()); } } 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 5ff8112..fc5f263 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 @@ -1,5 +1,6 @@ package com.swyp.picke.domain.admin.analytics; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; @@ -19,7 +20,7 @@ import org.springframework.web.util.UriComponentsBuilder; /** - * Sentry 프로젝트의 미해결 이슈를 이벤트 수 내림차순으로 읽는다. + * Sentry 프로젝트의 오류·로그·성능·프로파일·메트릭·세션·릴리즈 데이터를 읽는다. * *

인증은 조직 인증 토큰(Bearer)이다. 토큰이 없으면 조회하지 않고 NOT_CONFIGURED 를 준다. * 기간을 절대 날짜로 넘기려면 statsPeriod 를 빈 값으로 함께 보내야 한다. @@ -29,6 +30,10 @@ 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 List DATASETS = List.of( + "errors", "logs", "spans", "profile_functions", "tracemetrics"); private final String baseUrl; private final String organization; @@ -82,16 +87,26 @@ private SentryIssueReport.ProjectIssues fetchProject(String project, LocalDate f Map.of("Authorization", "Bearer " + authToken)); AnalyticsHttpResponse statsResponse = transport.get(statsUri(project, from, to), Map.of("Authorization", "Bearer " + authToken)); + AnalyticsHttpResponse eventResponse = transport.get(eventUri(project, from, to), + Map.of("Authorization", "Bearer " + authToken)); if (!issueResponse.isSuccess() || !issueResponse.isJson() - || !statsResponse.isSuccess() || !statsResponse.isJson()) { - log.warn("[Sentry] 조회 실패: project={}, issuesStatus={}, statsStatus={}", - project, issueResponse.statusCode(), statsResponse.statusCode()); + || !statsResponse.isSuccess() || !statsResponse.isJson() + || !eventResponse.isSuccess() || !eventResponse.isJson()) { + log.warn("[Sentry] 조회 실패: project={}, issuesStatus={}, statsStatus={}, eventsStatus={}", + project, issueResponse.statusCode(), statsResponse.statusCode(), eventResponse.statusCode()); return SentryIssueReport.emptyProject(project, AnalyticsStatus.UNAVAILABLE); } try { List issues = parseIssues(issueResponse.body()); List days = parseDays(statsResponse.body(), from, to); + List recentEvents = parseEvents(eventResponse.body()); + List datasets = DATASETS.stream() + .map(dataset -> fetchDataset(project, dataset, from, to)) + .toList(); + SentryIssueReport.MetricCatalog metricCatalog = fetchMetricCatalog(project, from, to); + SentryIssueReport.SessionHealth sessionHealth = fetchSessionHealth(project, from, to); + SentryIssueReport.ResourceCatalog releases = fetchReleases(project); long unresolvedTotal = issues.stream() .map(SentryIssueReport.Issue::events) .filter(events -> events != null) @@ -99,9 +114,10 @@ private SentryIssueReport.ProjectIssues fetchProject(String project, LocalDate f .sum(); long total = days.stream().mapToLong(SentryIssueReport.Day::events).sum(); return new SentryIssueReport.ProjectIssues( - project, AnalyticsStatus.CONNECTED, total, unresolvedTotal, days, issues); + project, AnalyticsStatus.CONNECTED, total, unresolvedTotal, days, issues, recentEvents, + datasets, metricCatalog, sessionHealth, releases); } catch (Exception e) { - log.warn("[Sentry] 이슈 응답 파싱 실패: project={}, {}", project, e.getClass().getSimpleName()); + log.warn("[Sentry] 핵심 응답 파싱 실패: project={}, {}", project, e.getClass().getSimpleName()); return SentryIssueReport.emptyProject(project, AnalyticsStatus.UNAVAILABLE); } } @@ -141,6 +157,165 @@ private URI statsUri(String project, LocalDate from, LocalDate to) { .toUri(); } + private URI eventUri(String project, LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/projects/{organization}/{project}/events/") + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .queryParam("full", "true") + .buildAndExpand(organization, project) + .toUri(); + } + + private SentryIssueReport.DatasetSeries fetchDataset( + String project, String dataset, LocalDate from, LocalDate to) { + AnalyticsHttpResponse response = transport.get(datasetUri(project, dataset, from, to), + Map.of("Authorization", "Bearer " + authToken)); + if (!response.isSuccess() || !response.isJson()) { + log.warn("[Sentry] 데이터셋 조회 실패: project={}, dataset={}, status={}", + project, dataset, response.statusCode()); + return new SentryIssueReport.DatasetSeries(dataset, AnalyticsStatus.UNAVAILABLE, null, List.of()); + } + try { + List days = parseDatasetDays(response.body(), from, to); + long total = days.stream().mapToLong(SentryIssueReport.Day::events).sum(); + return new SentryIssueReport.DatasetSeries(dataset, AnalyticsStatus.CONNECTED, total, days); + } catch (Exception e) { + log.warn("[Sentry] 데이터셋 파싱 실패: project={}, dataset={}, {}", + project, dataset, e.getClass().getSimpleName()); + return new SentryIssueReport.DatasetSeries(dataset, AnalyticsStatus.UNAVAILABLE, null, List.of()); + } + } + + private URI datasetUri(String project, String dataset, LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/organizations/{organization}/events-timeseries/") + .queryParam("project", project) + .queryParam("dataset", dataset) + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .queryParam("interval", 86400) + .queryParam("yAxis", "count()") + .buildAndExpand(organization) + .toUri(); + } + + private SentryIssueReport.MetricCatalog fetchMetricCatalog(String project, LocalDate from, LocalDate to) { + AnalyticsHttpResponse response = transport.get(metricCatalogUri(project, from, to), + Map.of("Authorization", "Bearer " + authToken)); + if (!response.isSuccess() || !response.isJson()) { + return new SentryIssueReport.MetricCatalog(AnalyticsStatus.UNAVAILABLE, List.of()); + } + try { + JsonNode root = objectMapper.readTree(response.body()); + if (!root.isArray()) { + throw new IllegalArgumentException("Sentry metric catalog response must be an array."); + } + List> entries = new ArrayList<>(); + for (JsonNode node : root) { + entries.add(objectMapper.convertValue(node, new TypeReference<>() { })); + } + return new SentryIssueReport.MetricCatalog(AnalyticsStatus.CONNECTED, List.copyOf(entries)); + } catch (Exception e) { + log.warn("[Sentry] 메트릭 카탈로그 파싱 실패: project={}, {}", + project, e.getClass().getSimpleName()); + return new SentryIssueReport.MetricCatalog(AnalyticsStatus.UNAVAILABLE, List.of()); + } + } + + private URI metricCatalogUri(String project, LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/organizations/{organization}/trace-items/metrics/") + .queryParam("project", project) + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .queryParam("sort", "-count") + .queryParam("expand", "context") + .buildAndExpand(organization) + .toUri(); + } + + private SentryIssueReport.SessionHealth fetchSessionHealth( + String project, LocalDate from, LocalDate to) { + AnalyticsHttpResponse response = transport.get(sessionUri(project, from, to), + Map.of("Authorization", "Bearer " + authToken)); + if (!response.isSuccess() || !response.isJson()) { + return new SentryIssueReport.SessionHealth(AnalyticsStatus.UNAVAILABLE, List.of(), Map.of()); + } + try { + JsonNode root = objectMapper.readTree(response.body()); + JsonNode intervals = root.path("intervals"); + JsonNode groups = root.path("groups"); + if (!intervals.isArray() || !groups.isArray()) { + throw new IllegalArgumentException("Sentry session response must contain intervals and groups."); + } + List series = new ArrayList<>(); + for (JsonNode group : groups) { + String status = text(group.path("by"), "session.status"); + Long total = number(group.path("totals"), "sum(session)"); + JsonNode values = group.path("series").path("sum(session)"); + List days = new ArrayList<>(); + for (int index = 0; index < Math.min(intervals.size(), values.size()); index++) { + LocalDate date = Instant.parse(intervals.get(index).asText()).atZone(ZoneOffset.UTC).toLocalDate(); + if (!date.isBefore(from) && !date.isAfter(to)) { + days.add(new SentryIssueReport.Day(date, values.get(index).asLong())); + } + } + series.add(new SentryIssueReport.SessionSeries(status, total, List.copyOf(days))); + } + Map details = objectMapper.convertValue(root, new TypeReference<>() { }); + return new SentryIssueReport.SessionHealth( + AnalyticsStatus.CONNECTED, List.copyOf(series), details); + } catch (Exception e) { + log.warn("[Sentry] 세션 상태 파싱 실패: project={}, {}", project, e.getClass().getSimpleName()); + return new SentryIssueReport.SessionHealth(AnalyticsStatus.UNAVAILABLE, List.of(), Map.of()); + } + } + + private URI sessionUri(String project, LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/organizations/{organization}/sessions/") + .queryParam("project", project) + .queryParam("field", "sum(session)") + .queryParam("groupBy", "session.status") + .queryParam("interval", "1d") + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .buildAndExpand(organization) + .toUri(); + } + + private SentryIssueReport.ResourceCatalog fetchReleases(String project) { + AnalyticsHttpResponse response = transport.get(releaseUri(project), + Map.of("Authorization", "Bearer " + authToken)); + if (!response.isSuccess() || !response.isJson()) { + return new SentryIssueReport.ResourceCatalog(AnalyticsStatus.UNAVAILABLE, List.of()); + } + try { + JsonNode root = objectMapper.readTree(response.body()); + if (!root.isArray()) { + throw new IllegalArgumentException("Sentry release response must be an array."); + } + List> entries = new ArrayList<>(); + for (JsonNode node : root) { + entries.add(objectMapper.convertValue(node, new TypeReference<>() { })); + } + return new SentryIssueReport.ResourceCatalog(AnalyticsStatus.CONNECTED, List.copyOf(entries)); + } catch (Exception e) { + log.warn("[Sentry] 릴리즈 파싱 실패: project={}, {}", project, e.getClass().getSimpleName()); + return new SentryIssueReport.ResourceCatalog(AnalyticsStatus.UNAVAILABLE, List.of()); + } + } + + private URI releaseUri(String project) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/organizations/{organization}/releases/") + .queryParam("project", project) + .queryParam("per_page", 20) + .buildAndExpand(organization) + .toUri(); + } + private List parseDays(String body, LocalDate from, LocalDate to) throws Exception { JsonNode root = objectMapper.readTree(body); if (!root.isArray()) { @@ -163,6 +338,37 @@ private List parseDays(String body, LocalDate from, Local return days; } + private List parseDatasetDays( + String body, LocalDate from, LocalDate to) throws Exception { + JsonNode root = objectMapper.readTree(body); + JsonNode timeSeries = root.path("timeSeries"); + if (!timeSeries.isArray()) { + throw new IllegalArgumentException("Sentry timeseries response must contain timeSeries."); + } + Map counts = new java.util.HashMap<>(); + for (JsonNode series : timeSeries) { + JsonNode values = series.path("values"); + if (!values.isArray()) { + continue; + } + for (JsonNode point : values) { + if (!point.path("timestamp").isNumber() || !point.path("value").isNumber()) { + throw new IllegalArgumentException("Sentry timeseries point must contain timestamp and value."); + } + LocalDate date = Instant.ofEpochMilli(point.path("timestamp").asLong()) + .atZone(ZoneOffset.UTC).toLocalDate(); + if (!date.isBefore(from) && !date.isAfter(to)) { + counts.merge(date, point.path("value").asLong(), Long::sum); + } + } + } + List days = new ArrayList<>(); + for (LocalDate cursor = from; !cursor.isAfter(to); cursor = cursor.plusDays(1)) { + days.add(new SentryIssueReport.Day(cursor, counts.getOrDefault(cursor, 0L))); + } + return days; + } + private List parseIssues(String body) throws Exception { JsonNode root = objectMapper.readTree(body); if (!root.isArray()) { @@ -189,6 +395,48 @@ private List parseIssues(String body) throws Exception .toList(); } + private List parseEvents(String body) throws Exception { + JsonNode root = objectMapper.readTree(body); + if (!root.isArray()) { + throw new IllegalArgumentException("Sentry event response must be an array."); + } + + List parsed = new ArrayList<>(); + for (JsonNode node : root) { + List tags = new ArrayList<>(); + JsonNode tagNodes = node.path("tags"); + if (tagNodes.isArray()) { + for (JsonNode tag : tagNodes) { + tags.add(new SentryIssueReport.Tag(text(tag, "key"), text(tag, "value"))); + } + } + Map metadata = node.path("metadata").isObject() + ? objectMapper.convertValue(node.path("metadata"), new TypeReference<>() { }) + : Map.of(); + Map details = objectMapper.convertValue(node, new TypeReference<>() { }); + parsed.add(new SentryIssueReport.Event( + text(node, "eventID"), + text(node, "id"), + text(node, "groupID"), + text(node, "projectID"), + text(node, "title"), + text(node, "message"), + text(node, "platform"), + text(node, "event.type"), + text(node, "location"), + text(node, "culprit"), + text(node, "crashFile"), + instant(node, "dateCreated"), + List.copyOf(tags), + metadata, + details)); + if (parsed.size() == RECENT_EVENT_LIMIT) { + break; + } + } + return parsed; + } + private String text(JsonNode node, String field) { JsonNode value = node.get(field); return value == null || value.isNull() ? null : value.asText(); 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 ab2bb41..d28b1d7 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 @@ -3,6 +3,7 @@ import java.time.Instant; import java.time.LocalDate; import java.util.List; +import java.util.Map; /** * Sentry 미해결 이슈 상위 목록. iOS·Android 를 프로젝트별로 나눠 담는다. @@ -21,7 +22,7 @@ public record SentryIssueReport( /** * @param project Sentry 프로젝트 슬러그. - * @param totalEvents 이 프로젝트 목록에 담긴 이슈들의 합계. 프로젝트 전체 이벤트 수가 아니다. + * @param totalEvents 프로젝트 stats API가 반환한 선택 기간 전체 수신 이벤트 수. * @param issues 이벤트 수 내림차순. */ public record ProjectIssues( @@ -30,7 +31,12 @@ public record ProjectIssues( Long totalEvents, Long unresolvedEvents, List days, - List issues) { + List issues, + List recentEvents, + List datasets, + MetricCatalog metricCatalog, + SessionHealth sessionHealth, + ResourceCatalog releases) { } public record Day(LocalDate date, Long events) { @@ -54,11 +60,70 @@ public record Issue( String permalink) { } + /** + * Sentry 프로젝트 오류 이벤트 목록 API가 full=true 에서 주는 진단 필드다. + * details 는 Sentry 응답 전체를 보존해 SDK 컨텍스트가 추가돼도 서버 계약에서 유실되지 않게 한다. + */ + public record Event( + String eventId, + String id, + String groupId, + String projectId, + String title, + String message, + String platform, + String type, + String location, + String culprit, + String crashFile, + Instant dateCreated, + List tags, + Map metadata, + Map details) { + } + + public record Tag(String key, String value) { + } + + /** errors, logs, spans, profile_functions, tracemetrics 데이터셋의 일별 발생량. */ + public record DatasetSeries( + String dataset, + AnalyticsStatus status, + Long total, + List days) { + } + + /** 앱이 전송한 커스텀 Sentry 메트릭의 이름·타입·단위·건수·마지막 수집 시각 원본. */ + public record MetricCatalog( + AnalyticsStatus status, + List> entries) { + } + + public record SessionHealth( + AnalyticsStatus status, + List series, + Map details) { + } + + public record SessionSeries( + String status, + Long total, + List days) { + } + + public record ResourceCatalog( + AnalyticsStatus status, + List> entries) { + } + static SentryIssueReport empty(AnalyticsStatus status, LocalDate from, LocalDate to) { return new SentryIssueReport(status, null, from, to, null, List.of()); } static ProjectIssues emptyProject(String project, AnalyticsStatus status) { - return new ProjectIssues(project, status, null, null, List.of(), List.of()); + return new ProjectIssues(project, status, null, null, List.of(), List.of(), List.of(), 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/MixpanelClientTest.java b/src/test/java/com/swyp/picke/domain/admin/analytics/MixpanelClientTest.java index ba1369a..2866c0b 100644 --- a/src/test/java/com/swyp/picke/domain/admin/analytics/MixpanelClientTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/analytics/MixpanelClientTest.java @@ -92,6 +92,11 @@ void countsRawEventsPerDay() { assertThat(result.summaryDate()).isEqualTo(to); assertThat(result.activeUsers()).isEqualTo(2); assertThat(result.signUps()).isEqualTo(1); + assertThat(result.signUpDays()) + .extracting(MixpanelEventReport.SignUpDay::date, MixpanelEventReport.SignUpDay::count) + .containsExactly(tuple(LocalDate.of(2026, 9, 8), 0L), + tuple(LocalDate.of(2026, 9, 9), 0L), + tuple(LocalDate.of(2026, 9, 10), 1L)); assertThat(result.events().getFirst().total()).isEqualTo(3); assertThat(result.events().getFirst().uniqueUsers()).isEqualTo(3); assertThat(result.events().getFirst().firstSeen()).isNotNull(); 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 8f298e2..5a4c78e 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 @@ -23,6 +23,28 @@ private static class CapturingTransport implements AnalyticsHttpTransport { 200, Map.of("content-type", List.of("application/json")), "[[1788825600,25],[1788912000,40],[1788998400,60]]"); + private AnalyticsHttpResponse eventResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), "[]"); + private AnalyticsHttpResponse datasetResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), """ + {"timeSeries":[{"values":[ + {"timestamp":1788825600000,"value":1}, + {"timestamp":1788912000000,"value":2}, + {"timestamp":1788998400000,"value":3} + ]}]} + """); + private AnalyticsHttpResponse metricResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), + "[{\"name\":\"app.launch.count\",\"type\":\"counter\",\"unit\":null,\"count\":8}]"); + private AnalyticsHttpResponse sessionResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), """ + {"intervals":["2026-09-08T00:00:00Z","2026-09-09T00:00:00Z","2026-09-10T00:00:00Z"], + "groups":[{"by":{"session.status":"healthy"},"totals":{"sum(session)":9}, + "series":{"sum(session)":[2,3,4]}}]} + """); + private AnalyticsHttpResponse releaseResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), + "[{\"version\":\"picke-ios@1.2.3+45\",\"status\":\"open\"}]"); final List uris = new java.util.ArrayList<>(); Map headers; @@ -40,6 +62,11 @@ CapturingTransport withStats(AnalyticsHttpResponse response) { return this; } + CapturingTransport withEvents(AnalyticsHttpResponse response) { + eventResponse = response; + return this; + } + @Override public AnalyticsHttpResponse get(URI uri, Map headers) { this.uris.add(uri); @@ -47,6 +74,21 @@ public AnalyticsHttpResponse get(URI uri, Map headers) { if (uri.getPath().endsWith("/stats/")) { return statsResponse; } + if (uri.getPath().endsWith("/events/")) { + return eventResponse; + } + if (uri.getPath().endsWith("/events-timeseries/")) { + return datasetResponse; + } + if (uri.getPath().endsWith("/trace-items/metrics/")) { + return metricResponse; + } + if (uri.getPath().endsWith("/sessions/")) { + return sessionResponse; + } + if (uri.getPath().endsWith("/releases/")) { + return releaseResponse; + } return byProject.entrySet().stream() .filter(entry -> uri.getPath().contains(entry.getKey())) .map(Map.Entry::getValue) @@ -88,12 +130,22 @@ void fetchesEveryProjectWithAbsoluteRange() { "userCount":null,"firstSeen":"2026-09-09T01:00:00Z","lastSeen":"2026-09-09T02:00:00Z", "permalink":null} ] + """)).withEvents(response(200, "application/json", """ + [ + {"eventID":"event-a","id":"event-a","groupID":"1","projectID":"11", + "title":"Crash A","message":"Fatal signal","platform":"cocoa","event.type":"error", + "location":"VoteView.swift","culprit":"VoteView","crashFile":"app.dSYM", + "dateCreated":"2026-09-10T04:00:00Z", + "tags":[{"key":"environment","value":"production"},{"key":"release","value":"1.2.3"}], + "metadata":{"filename":"VoteView.swift","function":"submitVote"}, + "user":{"email":"should-not-be-copied@example.com"}} + ] """)); var result = client("token", transport).fetchUnresolvedIssues(from, to); assertThat(transport.headers).containsEntry("Authorization", "Bearer token"); - assertThat(transport.uris).hasSize(4); + assertThat(transport.uris).hasSize(22); assertThat(transport.uris.getFirst().toString()) .contains("/api/0/projects/picke/picke-ios/issues/") .contains("query=is:unresolved") @@ -107,6 +159,16 @@ void fetchesEveryProjectWithAbsoluteRange() { .contains("/api/0/projects/picke/picke-ios/stats/") && uri.toString().contains("resolution=1d") && uri.toString().contains("stat=received")); + assertThat(transport.uris).anyMatch(uri -> uri.toString() + .contains("/api/0/projects/picke/picke-ios/events/") + && uri.toString().contains("start=2026-09-08T00:00") + && uri.toString().contains("end=2026-09-10T23:59:59") + && uri.toString().contains("full=true")); + assertThat(transport.uris).anyMatch(uri -> uri.toString() + .contains("/api/0/organizations/picke/events-timeseries/") + && uri.toString().contains("project=picke-ios") + && uri.toString().contains("dataset=logs") + && uri.toString().contains("yAxis=count()")); assertThat(result.status()).isEqualTo(AnalyticsStatus.CONNECTED); assertThat(result.fetchedAt()).isNotNull(); assertThat(result.projects()).extracting(SentryIssueReport.ProjectIssues::project, @@ -125,6 +187,34 @@ void fetchesEveryProjectWithAbsoluteRange() { .containsExactly(tuple("Crash A", 120L), tuple("Crash B", 5L)); assertThat(issues.getFirst().users()).isEqualTo(31); assertThat(issues.getLast().users()).isNull(); + var event = result.projects().getFirst().recentEvents().getFirst(); + assertThat(event.eventId()).isEqualTo("event-a"); + assertThat(event.groupId()).isEqualTo("1"); + assertThat(event.platform()).isEqualTo("cocoa"); + assertThat(event.type()).isEqualTo("error"); + assertThat(event.tags()).extracting(SentryIssueReport.Tag::key, SentryIssueReport.Tag::value) + .containsExactly(tuple("environment", "production"), tuple("release", "1.2.3")); + assertThat(event.metadata()).containsEntry("filename", "VoteView.swift") + .containsEntry("function", "submitVote"); + assertThat(event.details()).containsKey("user").containsKey("tags").containsKey("metadata"); + assertThat(result.projects().getFirst().datasets()) + .extracting(SentryIssueReport.DatasetSeries::dataset, + SentryIssueReport.DatasetSeries::status, + SentryIssueReport.DatasetSeries::total) + .containsExactly( + tuple("errors", AnalyticsStatus.CONNECTED, 6L), + tuple("logs", AnalyticsStatus.CONNECTED, 6L), + tuple("spans", AnalyticsStatus.CONNECTED, 6L), + tuple("profile_functions", AnalyticsStatus.CONNECTED, 6L), + tuple("tracemetrics", AnalyticsStatus.CONNECTED, 6L)); + assertThat(result.projects().getFirst().metricCatalog().entries().getFirst()) + .containsEntry("name", "app.launch.count") + .containsEntry("count", 8); + assertThat(result.projects().getFirst().sessionHealth().series().getFirst().status()) + .isEqualTo("healthy"); + assertThat(result.projects().getFirst().sessionHealth().series().getFirst().total()).isEqualTo(9); + assertThat(result.projects().getFirst().releases().entries().getFirst()) + .containsEntry("version", "picke-ios@1.2.3+45"); } @Test