77use Utopia \Fetch \Client ;
88use Utopia \Usage \Metric ;
99use Utopia \Usage \Usage ;
10+ use Utopia \Usage \UsageQuery ;
1011use Utopia \Validator \Hostname ;
1112
1213/**
@@ -1434,6 +1435,11 @@ public function find(array $queries = [], ?string $type = null): array
14341435 /**
14351436 * Find metrics from a specific table.
14361437 *
1438+ * When a `groupByInterval` query is present, switches to aggregated mode:
1439+ * - Events: SELECT metric, SUM(value) as value, toStartOfInterval(time, INTERVAL ...) as time
1440+ * - Gauges: SELECT metric, argMax(value, time) as value, toStartOfInterval(time, INTERVAL ...) as time
1441+ * Results are grouped by metric and time bucket, ordered by time ASC.
1442+ *
14371443 * @param array<Query> $queries
14381444 * @param string $type 'event' or 'gauge'
14391445 * @return array<Metric>
@@ -1446,6 +1452,11 @@ private function findFromTable(array $queries, string $type): array
14461452
14471453 $ parsed = $ this ->parseQueries ($ queries , $ type );
14481454
1455+ // Check if groupByInterval is requested
1456+ if (isset ($ parsed ['groupByInterval ' ])) {
1457+ return $ this ->findAggregatedFromTable ($ parsed , $ fromTable , $ type );
1458+ }
1459+
14491460 $ selectColumns = $ this ->getSelectColumns ($ type );
14501461
14511462 $ whereData = $ this ->buildWhereClause ($ parsed ['filters ' ], $ parsed ['params ' ]);
@@ -1471,6 +1482,115 @@ private function findFromTable(array $queries, string $type): array
14711482 return $ this ->parseResults ($ result , $ type );
14721483 }
14731484
1485+ /**
1486+ * Find aggregated metrics from a table using time-bucketed grouping.
1487+ *
1488+ * Produces SQL like:
1489+ * SELECT metric, SUM(value) as value,
1490+ * toStartOfInterval(time, INTERVAL 1 HOUR) as time
1491+ * FROM table WHERE ... GROUP BY metric, time ORDER BY time ASC
1492+ *
1493+ * @param array<string, mixed> $parsed Parsed query data from parseQueries()
1494+ * @param string $fromTable Fully qualified table reference
1495+ * @param string $type 'event' or 'gauge'
1496+ * @return array<Metric>
1497+ * @throws Exception
1498+ */
1499+ private function findAggregatedFromTable (array $ parsed , string $ fromTable , string $ type ): array
1500+ {
1501+ /** @var string $interval */
1502+ $ interval = $ parsed ['groupByInterval ' ];
1503+ $ intervalSql = UsageQuery::VALID_INTERVALS [$ interval ];
1504+
1505+ // Choose aggregation function based on metric type
1506+ $ valueExpr = $ type === Usage::TYPE_GAUGE
1507+ ? 'argMax(value, time) as value '
1508+ : 'SUM(value) as value ' ;
1509+
1510+ // Use 'bucket' alias to avoid collision with the raw 'time' column,
1511+ // then alias back to 'time' in outer context for consistent Metric parsing.
1512+ $ timeBucketExpr = "toStartOfInterval(time, {$ intervalSql }) " ;
1513+
1514+ $ whereData = $ this ->buildWhereClause ($ parsed ['filters ' ], $ parsed ['params ' ]);
1515+ $ whereClause = $ whereData ['clause ' ];
1516+ $ params = $ whereData ['params ' ];
1517+
1518+ // Use custom ORDER BY if specified, otherwise default to bucket ASC
1519+ $ orderClause = ' ORDER BY bucket ASC ' ;
1520+ if (!empty ($ parsed ['orderBy ' ])) {
1521+ $ orderClause = ' ORDER BY ' . implode (', ' , $ parsed ['orderBy ' ]);
1522+ }
1523+
1524+ $ limitClause = isset ($ parsed ['limit ' ]) ? ' LIMIT {limit:UInt64} ' : '' ;
1525+ $ offsetClause = isset ($ parsed ['offset ' ]) ? ' OFFSET {offset:UInt64} ' : '' ;
1526+
1527+ $ sql = "
1528+ SELECT metric, {$ valueExpr }, {$ timeBucketExpr } as bucket
1529+ FROM {$ fromTable }{$ whereClause }
1530+ GROUP BY metric, bucket {$ orderClause }{$ limitClause }{$ offsetClause }
1531+ FORMAT JSON
1532+ " ;
1533+
1534+ $ result = $ this ->query ($ sql , $ params );
1535+
1536+ return $ this ->parseAggregatedResults ($ result , $ type );
1537+ }
1538+
1539+ /**
1540+ * Parse ClickHouse JSON results from an aggregated (groupByInterval) query into Metric array.
1541+ *
1542+ * Maps the 'bucket' column back to 'time' for consistent Metric objects.
1543+ *
1544+ * @param string $result Raw JSON response from ClickHouse
1545+ * @param string $type 'event' or 'gauge'
1546+ * @return array<Metric>
1547+ */
1548+ private function parseAggregatedResults (string $ result , string $ type = 'event ' ): array
1549+ {
1550+ if (empty (trim ($ result ))) {
1551+ return [];
1552+ }
1553+
1554+ $ json = json_decode ($ result , true );
1555+
1556+ if (!is_array ($ json ) || !isset ($ json ['data ' ]) || !is_array ($ json ['data ' ])) {
1557+ return [];
1558+ }
1559+
1560+ $ rows = $ json ['data ' ];
1561+ $ metrics = [];
1562+
1563+ foreach ($ rows as $ row ) {
1564+ if (!is_array ($ row )) {
1565+ continue ;
1566+ }
1567+
1568+ $ document = [];
1569+
1570+ foreach ($ row as $ key => $ value ) {
1571+ if ($ key === 'bucket ' ) {
1572+ // Map 'bucket' back to 'time' for consistent Metric objects
1573+ $ parsedTime = (string ) $ value ;
1574+ if (strpos ($ parsedTime , 'T ' ) === false ) {
1575+ $ parsedTime = str_replace (' ' , 'T ' , $ parsedTime ) . '+00:00 ' ;
1576+ }
1577+ $ document ['time ' ] = $ parsedTime ;
1578+ } elseif ($ key === 'value ' ) {
1579+ $ document [$ key ] = $ value !== null ? (int ) $ value : null ;
1580+ } else {
1581+ $ document [$ key ] = $ value ;
1582+ }
1583+ }
1584+
1585+ // Set the type based on which table we queried
1586+ $ document ['type ' ] = $ type ;
1587+
1588+ $ metrics [] = new Metric ($ document );
1589+ }
1590+
1591+ return $ metrics ;
1592+ }
1593+
14741594 /**
14751595 * Count metrics using Query objects.
14761596 *
@@ -2204,7 +2324,7 @@ private function getParamType(string $attribute): string
22042324 *
22052325 * @param array<Query> $queries
22062326 * @param string $type 'event' or 'gauge' — used for attribute validation
2207- * @return array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, limit?: int, offset?: int}
2327+ * @return array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, limit?: int, offset?: int, groupByInterval?: string }
22082328 * @throws Exception
22092329 */
22102330 private function parseQueries (array $ queries , string $ type = 'event ' ): array
@@ -2214,6 +2334,7 @@ private function parseQueries(array $queries, string $type = 'event'): array
22142334 $ orderBy = [];
22152335 $ limit = null ;
22162336 $ offset = null ;
2337+ $ groupByInterval = null ;
22172338 $ paramCounter = 0 ;
22182339
22192340 foreach ($ queries as $ query ) {
@@ -2417,6 +2538,18 @@ private function parseQueries(array $queries, string $type = 'event'): array
24172538 $ offset = $ offsetVal ;
24182539 $ params ['offset ' ] = $ offset ;
24192540 break ;
2541+
2542+ case UsageQuery::TYPE_GROUP_BY_INTERVAL :
2543+ $ this ->validateAttributeName ($ attribute , $ type );
2544+ $ interval = $ values [0 ] ?? '1h ' ;
2545+ if (!is_string ($ interval ) || !isset (UsageQuery::VALID_INTERVALS [$ interval ])) {
2546+ throw new \Exception (
2547+ "Invalid groupByInterval interval ' {$ interval }'. Allowed: "
2548+ . implode (', ' , array_keys (UsageQuery::VALID_INTERVALS ))
2549+ );
2550+ }
2551+ $ groupByInterval = $ interval ;
2552+ break ;
24202553 }
24212554 }
24222555
@@ -2437,6 +2570,10 @@ private function parseQueries(array $queries, string $type = 'event'): array
24372570 $ result ['offset ' ] = $ offset ;
24382571 }
24392572
2573+ if ($ groupByInterval !== null ) {
2574+ $ result ['groupByInterval ' ] = $ groupByInterval ;
2575+ }
2576+
24402577 return $ result ;
24412578 }
24422579
0 commit comments