Skip to content

Commit d7191ed

Browse files
committed
Sentry 행동을 누락 없이 운영 지표로 판단하게 한다
Constraint: Sentry errors events-timeseries 그룹 조회가 운영 환경에서 400을 반환한다 Rejected: 이벤트별 개별 API 호출 | 이벤트 수만큼 요청이 증가한다 Confidence: high Scope-risk: narrow Directive: sign_up metric fallback을 analytics_event 태그 이벤트보다 우선하지 않는다 Tested: SentryClientTest, 운영 events-stats 응답, Gradle 전체 테스트 346건 중 관련 테스트 통과 Not-tested: 로컬 ffmpeg 부재로 오디오 파이프라인 기존 테스트 2건 실패
1 parent 1e01dde commit d7191ed

2 files changed

Lines changed: 115 additions & 68 deletions

File tree

‎src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java‎

Lines changed: 90 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public class SentryClient {
3434
/** full=true 는 Sentry가 페이지 크기를 최대 10건으로 제한한다. */
3535
private static final int RECENT_EVENT_LIMIT = 10;
3636
private static final int ANALYTICS_EVENT_BATCH_SIZE = 10;
37-
private static final String ANALYTICS_EVENT_FIELD = "tag[analytics_event,string]";
37+
private static final String ANALYTICS_EVENT_FIELD = "analytics_event";
3838
private static final List<String> DATASETS = List.of(
3939
"errors", "logs", "spans", "profile_functions", "tracemetrics");
4040

@@ -107,8 +107,10 @@ private SentryIssueReport.ProjectIssues fetchProject(String project, LocalDate f
107107
List<SentryIssueReport.DatasetSeries> datasets = new ArrayList<>(DATASETS.stream()
108108
.map(dataset -> fetchDataset(project, dataset, from, to))
109109
.toList());
110-
datasets.add(fetchSignUps(project, from, to));
111-
SentryIssueReport.AnalyticsEventCatalog analyticsEvents = fetchAnalyticsEvents(project, from, to);
110+
SentryIssueReport.DatasetSeries signUps = fetchSignUps(project, from, to);
111+
datasets.add(signUps);
112+
SentryIssueReport.AnalyticsEventCatalog analyticsEvents = fetchAnalyticsEvents(
113+
project, from, to, signUps);
112114
SentryIssueReport.MetricCatalog metricCatalog = fetchMetricCatalog(project, from, to);
113115
SentryIssueReport.SessionHealth sessionHealth = fetchSessionHealth(project, from, to);
114116
SentryIssueReport.ResourceCatalog releases = fetchReleases(project);
@@ -270,7 +272,10 @@ private URI metricCatalogUri(String project, LocalDate from, LocalDate to) {
270272
}
271273

272274
private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents(
273-
String project, LocalDate from, LocalDate to) {
275+
String project,
276+
LocalDate from,
277+
LocalDate to,
278+
SentryIssueReport.DatasetSeries signUps) {
274279
AnalyticsHttpResponse catalogResponse = transport.get(analyticsEventCatalogUri(project, from, to),
275280
Map.of("Authorization", "Bearer " + authToken));
276281
if (!catalogResponse.isSuccess() || !catalogResponse.isJson()) {
@@ -280,15 +285,23 @@ private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents(
280285
}
281286

282287
try {
283-
List<SentryIssueReport.AnalyticsEventSeries> catalog = parseAnalyticsEventCatalog(
284-
catalogResponse.body());
288+
List<SentryIssueReport.AnalyticsEventSeries> catalog = new ArrayList<>(parseAnalyticsEventCatalog(
289+
catalogResponse.body()));
290+
if (catalog.stream().noneMatch(event -> "sign_up".equals(event.event()))) {
291+
analyticsEventFromMetric(signUps).ifPresent(catalog::add);
292+
}
293+
catalog.sort(Comparator.comparing(SentryIssueReport.AnalyticsEventSeries::total,
294+
Comparator.nullsLast(Comparator.reverseOrder())));
285295
if (catalog.isEmpty()) {
286296
return new SentryIssueReport.AnalyticsEventCatalog(
287297
AnalyticsStatus.CONNECTED, 0L, List.of());
288298
}
289299

290300
Map<String, List<SentryIssueReport.AnalyticsEventDay>> daysByEvent = new LinkedHashMap<>();
291-
List<String> names = catalog.stream().map(SentryIssueReport.AnalyticsEventSeries::event).toList();
301+
List<String> names = catalog.stream()
302+
.filter(event -> event.days().isEmpty())
303+
.map(SentryIssueReport.AnalyticsEventSeries::event)
304+
.toList();
292305
for (int start = 0; start < names.size(); start += ANALYTICS_EVENT_BATCH_SIZE) {
293306
List<String> batch = names.subList(start, Math.min(start + ANALYTICS_EVENT_BATCH_SIZE, names.size()));
294307
AnalyticsHttpResponse seriesResponse = transport.get(
@@ -305,7 +318,9 @@ private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents(
305318
List<SentryIssueReport.AnalyticsEventSeries> events = catalog.stream()
306319
.map(series -> new SentryIssueReport.AnalyticsEventSeries(
307320
series.event(), series.total(), series.uniqueUsers(), series.firstSeen(), series.lastSeen(),
308-
daysByEvent.getOrDefault(series.event(), emptyAnalyticsDays(from, to))))
321+
daysByEvent.getOrDefault(series.event(), series.days().isEmpty()
322+
? emptyAnalyticsDays(from, to)
323+
: series.days())))
309324
.toList();
310325
long total = events.stream().map(SentryIssueReport.AnalyticsEventSeries::total)
311326
.filter(value -> value != null).mapToLong(Long::longValue).sum();
@@ -318,6 +333,28 @@ private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents(
318333
}
319334
}
320335

336+
private java.util.Optional<SentryIssueReport.AnalyticsEventSeries> analyticsEventFromMetric(
337+
SentryIssueReport.DatasetSeries signUps) {
338+
if (signUps.status() != AnalyticsStatus.CONNECTED || signUps.total() == null) {
339+
return java.util.Optional.empty();
340+
}
341+
List<SentryIssueReport.AnalyticsEventDay> days = signUps.days().stream()
342+
.map(day -> new SentryIssueReport.AnalyticsEventDay(day.date(), day.events(), 0L))
343+
.toList();
344+
Instant firstSeen = signUps.days().stream()
345+
.filter(day -> day.events() > 0)
346+
.map(day -> day.date().atStartOfDay(ZoneOffset.UTC).toInstant())
347+
.findFirst()
348+
.orElse(null);
349+
Instant lastSeen = signUps.days().stream()
350+
.filter(day -> day.events() > 0)
351+
.reduce((first, second) -> second)
352+
.map(day -> day.date().atTime(LocalTime.MAX).withNano(0).toInstant(ZoneOffset.UTC))
353+
.orElse(null);
354+
return java.util.Optional.of(new SentryIssueReport.AnalyticsEventSeries(
355+
"sign_up", signUps.total(), null, firstSeen, lastSeen, days));
356+
}
357+
321358
private URI analyticsEventCatalogUri(String project, LocalDate from, LocalDate to) {
322359
return UriComponentsBuilder.fromUriString(baseUrl)
323360
.path("/api/0/organizations/{organization}/events/")
@@ -345,16 +382,18 @@ private URI analyticsEventTimeseriesUri(
345382
.map(value -> events.size() > 1 ? "(" + value + ")" : value)
346383
.orElse("has:analytics_event");
347384
return UriComponentsBuilder.fromUriString(baseUrl)
348-
.path("/api/0/organizations/{organization}/events-timeseries/")
385+
.path("/api/0/organizations/{organization}/events-stats/")
349386
.queryParam("project", project)
350387
.queryParam("dataset", "errors")
351388
.queryParam("start", from.atStartOfDay())
352389
.queryParam("end", to.atTime(LocalTime.MAX).withNano(0))
353-
.queryParam("interval", 86400)
390+
.queryParam("interval", "1d")
354391
.queryParam("query", query)
355-
.queryParam("groupBy", ANALYTICS_EVENT_FIELD)
392+
.queryParam("field", ANALYTICS_EVENT_FIELD)
356393
.queryParam("topEvents", events.size())
357-
.queryParam("sort", "-count()")
394+
// batch query already contains at most topEvents names, so alphabetical sorting loses none.
395+
.queryParam("sort", ANALYTICS_EVENT_FIELD)
396+
.queryParam("partial", 1)
358397
.queryParam("excludeOther", 1)
359398
.queryParam("yAxis", "count()")
360399
.queryParam("yAxis", "count_unique(user)")
@@ -389,31 +428,16 @@ private List<SentryIssueReport.AnalyticsEventSeries> parseAnalyticsEventCatalog(
389428

390429
private Map<String, List<SentryIssueReport.AnalyticsEventDay>> parseAnalyticsEventDays(
391430
String body, List<String> events, LocalDate from, LocalDate to) throws Exception {
392-
JsonNode timeSeries = objectMapper.readTree(body).path("timeSeries");
393-
if (!timeSeries.isArray()) {
394-
throw new IllegalArgumentException("Sentry analytics timeseries response must contain timeSeries.");
431+
JsonNode root = objectMapper.readTree(body);
432+
if (!root.isObject()) {
433+
throw new IllegalArgumentException("Sentry analytics events-stats response must be an object.");
395434
}
396435
Map<String, Map<LocalDate, Long>> counts = new LinkedHashMap<>();
397436
Map<String, Map<LocalDate, Long>> users = new LinkedHashMap<>();
398-
for (JsonNode series : timeSeries) {
399-
String event = analyticsEventNameFromGroup(series.path("groupBy"));
400-
if (!StringUtils.hasText(event) || !events.contains(event)) {
401-
continue;
402-
}
403-
Map<String, Map<LocalDate, Long>> target = "count_unique(user)".equals(text(series, "yAxis"))
404-
? users : counts;
405-
Map<LocalDate, Long> valuesByDate = target.computeIfAbsent(event, ignored -> new LinkedHashMap<>());
406-
JsonNode values = series.path("values");
407-
if (!values.isArray()) {
408-
continue;
409-
}
410-
for (JsonNode point : values) {
411-
LocalDate date = Instant.ofEpochMilli(point.path("timestamp").asLong())
412-
.atZone(ZoneOffset.UTC).toLocalDate();
413-
if (!date.isBefore(from) && !date.isAfter(to) && point.path("value").isNumber()) {
414-
valuesByDate.merge(date, point.path("value").asLong(), Long::sum);
415-
}
416-
}
437+
for (String event : events) {
438+
JsonNode eventSeries = root.path(event);
439+
parseAnalyticsEventAxis(eventSeries.path("count()"), event, counts, from, to);
440+
parseAnalyticsEventAxis(eventSeries.path("count_unique(user)"), event, users, from, to);
417441
}
418442

419443
Map<String, List<SentryIssueReport.AnalyticsEventDay>> result = new LinkedHashMap<>();
@@ -430,25 +454,42 @@ private Map<String, List<SentryIssueReport.AnalyticsEventDay>> parseAnalyticsEve
430454
return result;
431455
}
432456

433-
private String analyticsEventName(JsonNode row) {
434-
for (String field : List.of(ANALYTICS_EVENT_FIELD, "tag[analytics_event]", "analytics_event")) {
435-
String value = text(row, field);
436-
if (StringUtils.hasText(value)) {
437-
return value;
457+
private void parseAnalyticsEventAxis(
458+
JsonNode axis,
459+
String event,
460+
Map<String, Map<LocalDate, Long>> target,
461+
LocalDate from,
462+
LocalDate to) {
463+
JsonNode points = axis.path("data");
464+
if (!points.isArray()) {
465+
return;
466+
}
467+
Map<LocalDate, Long> valuesByDate = target.computeIfAbsent(event, ignored -> new LinkedHashMap<>());
468+
for (JsonNode point : points) {
469+
if (!point.isArray() || point.size() < 2 || !point.get(0).canConvertToLong()) {
470+
continue;
471+
}
472+
LocalDate date = Instant.ofEpochSecond(point.get(0).asLong())
473+
.atZone(ZoneOffset.UTC).toLocalDate();
474+
JsonNode values = point.get(1);
475+
if (date.isBefore(from) || date.isAfter(to) || !values.isArray()) {
476+
continue;
438477
}
478+
long value = 0;
479+
for (JsonNode bucket : values) {
480+
if (bucket.path("count").isNumber()) {
481+
value += bucket.path("count").asLong();
482+
}
483+
}
484+
valuesByDate.merge(date, value, Long::sum);
439485
}
440-
return null;
441486
}
442487

443-
private String analyticsEventNameFromGroup(JsonNode groupBy) {
444-
if (!groupBy.isArray()) {
445-
return null;
446-
}
447-
for (JsonNode group : groupBy) {
448-
String key = text(group, "key");
449-
if (ANALYTICS_EVENT_FIELD.equals(key) || "tag[analytics_event]".equals(key)
450-
|| "analytics_event".equals(key)) {
451-
return text(group, "value");
488+
private String analyticsEventName(JsonNode row) {
489+
for (String field : List.of(ANALYTICS_EVENT_FIELD, "tags[analytics_event]", "tag[analytics_event]")) {
490+
String value = text(row, field);
491+
if (StringUtils.hasText(value)) {
492+
return value;
452493
}
453494
}
454495
return null;

‎src/test/java/com/swyp/picke/domain/admin/analytics/SentryClientTest.java‎

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,10 @@ public AnalyticsHttpResponse get(URI uri, Map<String, String> headers) {
9292
if (uri.getPath().endsWith("/events/")) {
9393
return eventResponse;
9494
}
95+
if (uri.getPath().endsWith("/events-stats/")) {
96+
return analyticsEventTimeseriesResponse;
97+
}
9598
if (uri.getPath().endsWith("/events-timeseries/")) {
96-
if (uri.getQuery() != null && uri.getQuery().contains("topEvents=")) {
97-
return analyticsEventTimeseriesResponse;
98-
}
9999
return datasetResponse;
100100
}
101101
if (uri.getPath().endsWith("/trace-items/metrics/")) {
@@ -239,7 +239,11 @@ void fetchesEveryProjectWithAbsoluteRange() {
239239
tuple("sign_up", AnalyticsStatus.CONNECTED, 6L));
240240
assertThat(result.projects().getFirst().analyticsEvents().status())
241241
.isEqualTo(AnalyticsStatus.CONNECTED);
242-
assertThat(result.projects().getFirst().analyticsEvents().events()).isEmpty();
242+
assertThat(result.projects().getFirst().analyticsEvents().events())
243+
.extracting(SentryIssueReport.AnalyticsEventSeries::event,
244+
SentryIssueReport.AnalyticsEventSeries::total,
245+
SentryIssueReport.AnalyticsEventSeries::uniqueUsers)
246+
.containsExactly(tuple("sign_up", 6L, null));
243247
assertThat(result.projects().getFirst().metricCatalog().entries().getFirst())
244248
.containsEntry("name", "app.launch.count")
245249
.containsEntry("count", 8);
@@ -258,36 +262,38 @@ void groupsEveryAnalyticsEventLikeMixpanel() {
258262
.withAnalyticsEvents(
259263
response(200, "application/json", """
260264
{"data":[
261-
{"tag[analytics_event,string]":"ui_action","count()":7,
265+
{"analytics_event":"ui_action","count()":7,
262266
"count_unique(user)":3,"min(timestamp)":"2026-09-08T01:00:00Z",
263267
"max(timestamp)":"2026-09-10T02:00:00Z"},
264-
{"tag[analytics_event,string]":"sign_up","count()":"2",
268+
{"analytics_event":"sign_up","count()":"2",
265269
"count_unique(user)":2,"min(timestamp)":"2026-09-09T03:00:00Z",
266270
"max(timestamp)":"2026-09-10T04:00:00Z"}
267271
]}
268272
"""),
269273
response(200, "application/json", """
270-
{"timeSeries":[
271-
{"yAxis":"count()","groupBy":[{"key":"tag[analytics_event,string]","value":"ui_action"}],
272-
"values":[{"timestamp":1788825600000,"value":1},{"timestamp":1788912000000,"value":2},{"timestamp":1788998400000,"value":4}]},
273-
{"yAxis":"count_unique(user)","groupBy":[{"key":"tag[analytics_event,string]","value":"ui_action"}],
274-
"values":[{"timestamp":1788825600000,"value":1},{"timestamp":1788912000000,"value":1},{"timestamp":1788998400000,"value":2}]},
275-
{"yAxis":"count()","groupBy":[{"key":"analytics_event","value":"sign_up"}],
276-
"values":[{"timestamp":1788912000000,"value":1},{"timestamp":1788998400000,"value":1}]},
277-
{"yAxis":"count_unique(user)","groupBy":[{"key":"analytics_event","value":"sign_up"}],
278-
"values":[{"timestamp":1788912000000,"value":1},{"timestamp":1788998400000,"value":1}]}
279-
]}
274+
{
275+
"ui_action":{
276+
"count()":{"data":[[1788825600,[{"count":1}]],[1788912000,[{"count":2}]],[1788998400,[{"count":4}]]]},
277+
"count_unique(user)":{"data":[[1788825600,[{"count":1}]],[1788912000,[{"count":1}]],[1788998400,[{"count":2}]]]}
278+
},
279+
"sign_up":{
280+
"count()":{"data":[[1788912000,[{"count":1}]],[1788998400,[{"count":1}]]]},
281+
"count_unique(user)":{"data":[[1788912000,[{"count":1}]],[1788998400,[{"count":1}]]]}
282+
}
283+
}
280284
"""));
281285

282286
var result = client("token", transport).fetchUnresolvedIssues(from, to);
283287

284288
assertThat(transport.uris).anyMatch(uri -> uri.getPath().endsWith("/events/")
285289
&& uri.getPath().contains("/organizations/")
286-
&& uri.getQuery().contains("field=tag[analytics_event,string]")
290+
&& uri.getQuery().contains("field=analytics_event")
287291
&& uri.getQuery().contains("query=has:analytics_event"));
288-
assertThat(transport.uris).anyMatch(uri -> uri.getPath().endsWith("/events-timeseries/")
289-
&& uri.getQuery().contains("groupBy=tag[analytics_event,string]")
292+
assertThat(transport.uris).anyMatch(uri -> uri.getPath().endsWith("/events-stats/")
293+
&& uri.getQuery().contains("field=analytics_event")
290294
&& uri.getQuery().contains("topEvents=2")
295+
&& uri.getQuery().contains("sort=analytics_event")
296+
&& uri.getQuery().contains("partial=1")
291297
&& uri.getQuery().contains("yAxis=count_unique(user)"));
292298
var analytics = result.projects().getFirst().analyticsEvents();
293299
assertThat(analytics.status()).isEqualTo(AnalyticsStatus.CONNECTED);

0 commit comments

Comments
 (0)