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 aa279d4..478b21b 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 @@ -34,7 +34,7 @@ public class SentryClient { /** 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 String ANALYTICS_EVENT_FIELD = "analytics_event"; private static final List DATASETS = List.of( "errors", "logs", "spans", "profile_functions", "tracemetrics"); @@ -107,8 +107,10 @@ private SentryIssueReport.ProjectIssues fetchProject(String project, LocalDate f List datasets = new ArrayList<>(DATASETS.stream() .map(dataset -> fetchDataset(project, dataset, from, to)) .toList()); - datasets.add(fetchSignUps(project, from, to)); - SentryIssueReport.AnalyticsEventCatalog analyticsEvents = fetchAnalyticsEvents(project, from, to); + SentryIssueReport.DatasetSeries signUps = fetchSignUps(project, from, to); + datasets.add(signUps); + SentryIssueReport.AnalyticsEventCatalog analyticsEvents = fetchAnalyticsEvents( + project, from, to, signUps); SentryIssueReport.MetricCatalog metricCatalog = fetchMetricCatalog(project, from, to); SentryIssueReport.SessionHealth sessionHealth = fetchSessionHealth(project, from, to); SentryIssueReport.ResourceCatalog releases = fetchReleases(project); @@ -270,7 +272,10 @@ private URI metricCatalogUri(String project, LocalDate from, LocalDate to) { } private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents( - String project, LocalDate from, LocalDate to) { + String project, + LocalDate from, + LocalDate to, + SentryIssueReport.DatasetSeries signUps) { AnalyticsHttpResponse catalogResponse = transport.get(analyticsEventCatalogUri(project, from, to), Map.of("Authorization", "Bearer " + authToken)); if (!catalogResponse.isSuccess() || !catalogResponse.isJson()) { @@ -280,15 +285,23 @@ private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents( } try { - List catalog = parseAnalyticsEventCatalog( - catalogResponse.body()); + List catalog = new ArrayList<>(parseAnalyticsEventCatalog( + catalogResponse.body())); + if (catalog.stream().noneMatch(event -> "sign_up".equals(event.event()))) { + analyticsEventFromMetric(signUps).ifPresent(catalog::add); + } + catalog.sort(Comparator.comparing(SentryIssueReport.AnalyticsEventSeries::total, + Comparator.nullsLast(Comparator.reverseOrder()))); 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(); + List names = catalog.stream() + .filter(event -> event.days().isEmpty()) + .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( @@ -305,7 +318,9 @@ private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents( 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)))) + daysByEvent.getOrDefault(series.event(), series.days().isEmpty() + ? emptyAnalyticsDays(from, to) + : series.days()))) .toList(); long total = events.stream().map(SentryIssueReport.AnalyticsEventSeries::total) .filter(value -> value != null).mapToLong(Long::longValue).sum(); @@ -318,6 +333,28 @@ private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents( } } + private java.util.Optional analyticsEventFromMetric( + SentryIssueReport.DatasetSeries signUps) { + if (signUps.status() != AnalyticsStatus.CONNECTED || signUps.total() == null) { + return java.util.Optional.empty(); + } + List days = signUps.days().stream() + .map(day -> new SentryIssueReport.AnalyticsEventDay(day.date(), day.events(), 0L)) + .toList(); + Instant firstSeen = signUps.days().stream() + .filter(day -> day.events() > 0) + .map(day -> day.date().atStartOfDay(ZoneOffset.UTC).toInstant()) + .findFirst() + .orElse(null); + Instant lastSeen = signUps.days().stream() + .filter(day -> day.events() > 0) + .reduce((first, second) -> second) + .map(day -> day.date().atTime(LocalTime.MAX).withNano(0).toInstant(ZoneOffset.UTC)) + .orElse(null); + return java.util.Optional.of(new SentryIssueReport.AnalyticsEventSeries( + "sign_up", signUps.total(), null, firstSeen, lastSeen, days)); + } + private URI analyticsEventCatalogUri(String project, LocalDate from, LocalDate to) { return UriComponentsBuilder.fromUriString(baseUrl) .path("/api/0/organizations/{organization}/events/") @@ -345,16 +382,18 @@ private URI analyticsEventTimeseriesUri( .map(value -> events.size() > 1 ? "(" + value + ")" : value) .orElse("has:analytics_event"); return UriComponentsBuilder.fromUriString(baseUrl) - .path("/api/0/organizations/{organization}/events-timeseries/") + .path("/api/0/organizations/{organization}/events-stats/") .queryParam("project", project) .queryParam("dataset", "errors") .queryParam("start", from.atStartOfDay()) .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) - .queryParam("interval", 86400) + .queryParam("interval", "1d") .queryParam("query", query) - .queryParam("groupBy", ANALYTICS_EVENT_FIELD) + .queryParam("field", ANALYTICS_EVENT_FIELD) .queryParam("topEvents", events.size()) - .queryParam("sort", "-count()") + // batch query already contains at most topEvents names, so alphabetical sorting loses none. + .queryParam("sort", ANALYTICS_EVENT_FIELD) + .queryParam("partial", 1) .queryParam("excludeOther", 1) .queryParam("yAxis", "count()") .queryParam("yAxis", "count_unique(user)") @@ -389,31 +428,16 @@ private List parseAnalyticsEventCatalog( 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."); + JsonNode root = objectMapper.readTree(body); + if (!root.isObject()) { + throw new IllegalArgumentException("Sentry analytics events-stats response must be an object."); } 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); - } - } + for (String event : events) { + JsonNode eventSeries = root.path(event); + parseAnalyticsEventAxis(eventSeries.path("count()"), event, counts, from, to); + parseAnalyticsEventAxis(eventSeries.path("count_unique(user)"), event, users, from, to); } Map> result = new LinkedHashMap<>(); @@ -430,25 +454,42 @@ private Map> parseAnalyticsEve 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; + private void parseAnalyticsEventAxis( + JsonNode axis, + String event, + Map> target, + LocalDate from, + LocalDate to) { + JsonNode points = axis.path("data"); + if (!points.isArray()) { + return; + } + Map valuesByDate = target.computeIfAbsent(event, ignored -> new LinkedHashMap<>()); + for (JsonNode point : points) { + if (!point.isArray() || point.size() < 2 || !point.get(0).canConvertToLong()) { + continue; + } + LocalDate date = Instant.ofEpochSecond(point.get(0).asLong()) + .atZone(ZoneOffset.UTC).toLocalDate(); + JsonNode values = point.get(1); + if (date.isBefore(from) || date.isAfter(to) || !values.isArray()) { + continue; } + long value = 0; + for (JsonNode bucket : values) { + if (bucket.path("count").isNumber()) { + value += bucket.path("count").asLong(); + } + } + valuesByDate.merge(date, value, Long::sum); } - 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"); + private String analyticsEventName(JsonNode row) { + for (String field : List.of(ANALYTICS_EVENT_FIELD, "tags[analytics_event]", "tag[analytics_event]")) { + String value = text(row, field); + if (StringUtils.hasText(value)) { + return value; } } return null; 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 2fba337..6bae5f5 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 @@ -92,10 +92,10 @@ public AnalyticsHttpResponse get(URI uri, Map headers) { if (uri.getPath().endsWith("/events/")) { return eventResponse; } + if (uri.getPath().endsWith("/events-stats/")) { + return analyticsEventTimeseriesResponse; + } 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/")) { @@ -239,7 +239,11 @@ void fetchesEveryProjectWithAbsoluteRange() { 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().analyticsEvents().events()) + .extracting(SentryIssueReport.AnalyticsEventSeries::event, + SentryIssueReport.AnalyticsEventSeries::total, + SentryIssueReport.AnalyticsEventSeries::uniqueUsers) + .containsExactly(tuple("sign_up", 6L, null)); assertThat(result.projects().getFirst().metricCatalog().entries().getFirst()) .containsEntry("name", "app.launch.count") .containsEntry("count", 8); @@ -258,36 +262,38 @@ void groupsEveryAnalyticsEventLikeMixpanel() { .withAnalyticsEvents( response(200, "application/json", """ {"data":[ - {"tag[analytics_event,string]":"ui_action","count()":7, + {"analytics_event":"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", + {"analytics_event":"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}]} - ]} + { + "ui_action":{ + "count()":{"data":[[1788825600,[{"count":1}]],[1788912000,[{"count":2}]],[1788998400,[{"count":4}]]]}, + "count_unique(user)":{"data":[[1788825600,[{"count":1}]],[1788912000,[{"count":1}]],[1788998400,[{"count":2}]]]} + }, + "sign_up":{ + "count()":{"data":[[1788912000,[{"count":1}]],[1788998400,[{"count":1}]]]}, + "count_unique(user)":{"data":[[1788912000,[{"count":1}]],[1788998400,[{"count":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("field=analytics_event") && 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]") + assertThat(transport.uris).anyMatch(uri -> uri.getPath().endsWith("/events-stats/") + && uri.getQuery().contains("field=analytics_event") && uri.getQuery().contains("topEvents=2") + && uri.getQuery().contains("sort=analytics_event") + && uri.getQuery().contains("partial=1") && uri.getQuery().contains("yAxis=count_unique(user)")); var analytics = result.projects().getFirst().analyticsEvents(); assertThat(analytics.status()).isEqualTo(AnalyticsStatus.CONNECTED);