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
139 changes: 90 additions & 49 deletions src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> DATASETS = List.of(
"errors", "logs", "spans", "profile_functions", "tracemetrics");

Expand Down Expand Up @@ -107,8 +107,10 @@ private SentryIssueReport.ProjectIssues fetchProject(String project, LocalDate f
List<SentryIssueReport.DatasetSeries> 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);
Expand Down Expand Up @@ -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()) {
Expand All @@ -280,15 +285,23 @@ private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents(
}

try {
List<SentryIssueReport.AnalyticsEventSeries> catalog = parseAnalyticsEventCatalog(
catalogResponse.body());
List<SentryIssueReport.AnalyticsEventSeries> 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<String, List<SentryIssueReport.AnalyticsEventDay>> daysByEvent = new LinkedHashMap<>();
List<String> names = catalog.stream().map(SentryIssueReport.AnalyticsEventSeries::event).toList();
List<String> 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<String> batch = names.subList(start, Math.min(start + ANALYTICS_EVENT_BATCH_SIZE, names.size()));
AnalyticsHttpResponse seriesResponse = transport.get(
Expand All @@ -305,7 +318,9 @@ private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents(
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))))
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();
Expand All @@ -318,6 +333,28 @@ private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents(
}
}

private java.util.Optional<SentryIssueReport.AnalyticsEventSeries> analyticsEventFromMetric(
SentryIssueReport.DatasetSeries signUps) {
if (signUps.status() != AnalyticsStatus.CONNECTED || signUps.total() == null) {
return java.util.Optional.empty();
}
List<SentryIssueReport.AnalyticsEventDay> 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/")
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -389,31 +428,16 @@ private List<SentryIssueReport.AnalyticsEventSeries> parseAnalyticsEventCatalog(

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.");
JsonNode root = objectMapper.readTree(body);
if (!root.isObject()) {
throw new IllegalArgumentException("Sentry analytics events-stats response must be an object.");
}
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);
}
}
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<String, List<SentryIssueReport.AnalyticsEventDay>> result = new LinkedHashMap<>();
Expand All @@ -430,25 +454,42 @@ private Map<String, List<SentryIssueReport.AnalyticsEventDay>> 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<String, Map<LocalDate, Long>> target,
LocalDate from,
LocalDate to) {
JsonNode points = axis.path("data");
if (!points.isArray()) {
return;
}
Map<LocalDate, Long> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ public AnalyticsHttpResponse get(URI uri, Map<String, String> 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/")) {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading