@@ -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 ;
0 commit comments