Skip to content

Commit 71bc70e

Browse files
lohanidamodarclaude
andcommitted
feat: add groupByInterval query support for time-bucketed aggregation
Add UsageQuery class extending Query with a custom groupByInterval method that enables time-bucketed aggregated queries. When present in the queries array, the ClickHouse adapter switches from raw row returns to aggregated results grouped by time bucket (SUM for events, argMax for gauges). Supported intervals: 1m, 5m, 15m, 1h, 1d, 1w, 1M. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 08c2817 commit 71bc70e

5 files changed

Lines changed: 499 additions & 1 deletion

File tree

src/Usage/Adapter/ClickHouse.php

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Utopia\Fetch\Client;
88
use Utopia\Usage\Metric;
99
use Utopia\Usage\Usage;
10+
use Utopia\Usage\UsageQuery;
1011
use 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

src/Usage/Adapter/Database.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Utopia\Database\Query as DatabaseQuery;
99
use Utopia\Usage\Metric;
1010
use Utopia\Usage\Usage;
11+
use Utopia\Usage\UsageQuery;
1112
use Utopia\Query\Query;
1213

1314
class Database extends SQL
@@ -392,6 +393,11 @@ private function convertQueriesToDatabase(array $queries): array
392393
$dbQueries[] = DatabaseQuery::offset((int) $val);
393394
}
394395
break;
396+
397+
case UsageQuery::TYPE_GROUP_BY_INTERVAL:
398+
// groupByInterval is not supported by the Database adapter.
399+
// Silently skip — callers get raw (non-aggregated) results.
400+
break;
395401
}
396402
}
397403

src/Usage/UsageQuery.php

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
namespace Utopia\Usage;
4+
5+
use Utopia\Query\Query;
6+
7+
/**
8+
* Usage Query
9+
*
10+
* Extends the base Query class with usage-specific query types.
11+
* Currently adds support for `groupByInterval` which enables time-bucketed
12+
* aggregated queries in the ClickHouse adapter.
13+
*
14+
* Example usage:
15+
* ```php
16+
* $queries = [
17+
* UsageQuery::groupByInterval('time', '1h'),
18+
* Query::equal('metric', ['bandwidth']),
19+
* Query::greaterThanEqual('time', '2026-03-01'),
20+
* Query::lessThanEqual('time', '2026-04-01'),
21+
* ];
22+
* $results = $usage->find($queries, 'event');
23+
* ```
24+
*
25+
* When `groupByInterval` is present in the queries array, the ClickHouse adapter
26+
* switches from raw row returns to aggregated results grouped by time bucket:
27+
* - Events: SUM(value) per bucket
28+
* - Gauges: argMax(value, time) per bucket
29+
*/
30+
class UsageQuery extends Query
31+
{
32+
public const TYPE_GROUP_BY_INTERVAL = 'groupByInterval';
33+
34+
/**
35+
* Valid interval values and their ClickHouse INTERVAL equivalents.
36+
*/
37+
public const VALID_INTERVALS = [
38+
'1m' => 'INTERVAL 1 MINUTE',
39+
'5m' => 'INTERVAL 5 MINUTE',
40+
'15m' => 'INTERVAL 15 MINUTE',
41+
'1h' => 'INTERVAL 1 HOUR',
42+
'1d' => 'INTERVAL 1 DAY',
43+
'1w' => 'INTERVAL 1 WEEK',
44+
'1M' => 'INTERVAL 1 MONTH',
45+
];
46+
47+
/**
48+
* Create a groupByInterval query.
49+
*
50+
* When passed to `find()`, this switches the adapter to return time-bucketed
51+
* aggregated results instead of raw rows.
52+
*
53+
* @param string $attribute The time attribute to bucket (usually 'time')
54+
* @param string $interval The bucket size: '1m', '5m', '15m', '1h', '1d', '1w', '1M'
55+
* @return self
56+
*/
57+
public static function groupByInterval(string $attribute, string $interval): self
58+
{
59+
if (!isset(self::VALID_INTERVALS[$interval])) {
60+
throw new \InvalidArgumentException(
61+
"Invalid interval '{$interval}'. Allowed: " . implode(', ', array_keys(self::VALID_INTERVALS))
62+
);
63+
}
64+
65+
return new self(self::TYPE_GROUP_BY_INTERVAL, $attribute, [$interval]);
66+
}
67+
68+
/**
69+
* Check if a query is a groupByInterval query.
70+
*
71+
* @param Query $query
72+
* @return bool
73+
*/
74+
public static function isGroupByInterval(Query $query): bool
75+
{
76+
return $query->getMethod() === self::TYPE_GROUP_BY_INTERVAL;
77+
}
78+
79+
/**
80+
* Extract the groupByInterval query from an array of queries, if present.
81+
*
82+
* @param array<Query> $queries
83+
* @return self|null The groupByInterval query, or null if not present
84+
*/
85+
public static function extractGroupByInterval(array $queries): ?self
86+
{
87+
foreach ($queries as $query) {
88+
if ($query instanceof self && $query->getMethod() === self::TYPE_GROUP_BY_INTERVAL) {
89+
return $query;
90+
}
91+
}
92+
93+
return null;
94+
}
95+
96+
/**
97+
* Remove groupByInterval queries from an array of queries.
98+
*
99+
* Returns the remaining queries that should be processed normally.
100+
*
101+
* @param array<Query> $queries
102+
* @return array<Query>
103+
*/
104+
public static function removeGroupByInterval(array $queries): array
105+
{
106+
return array_values(array_filter($queries, function (Query $query) {
107+
return !self::isGroupByInterval($query);
108+
}));
109+
}
110+
}

0 commit comments

Comments
 (0)