diff --git a/README.md b/README.md index 9591632..c235105 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ use Utopia\Usage\Usage; use Utopia\Usage\Adapter\ClickHouse; // Configuration is fixed at construction. The adapter is fully stateless — -// the tenant is passed explicitly on every call (see below). -$adapter = new ClickHouse( +// the tenant is passed explicitly on every call (see below). The adapter is +// the entry point; there is no separate facade to wrap it in. +$usage = new ClickHouse( host: 'clickhouse-server', username: 'default', password: '', @@ -48,7 +49,6 @@ $adapter = new ClickHouse( sharedTables: true, ); -$usage = new Usage($adapter); $usage->setup(); // Creates events, gauges, and daily MV tables ``` @@ -57,19 +57,17 @@ $usage->setup(); // Creates events, gauges, and daily MV tables ```php setup(); ``` ## Multi-tenancy -`Usage` is stateless: every query/mutation takes the tenant as its first +The adapter is stateless: every query/mutation takes the tenant as its first argument, and `addBatch` carries a `tenant` on each metric row (so one batch can -span tenants). This makes a single `Usage` instance safe to share across +span tenants). This makes a single adapter instance safe to share across tenants and coroutines. ```php @@ -81,8 +79,8 @@ $usage->addBatch([ ``` Callers that only ever touch one tenant can bind it once with the `Tenant` -decorator, which forwards to `Usage` with the tenant pre-filled (and stamps it -onto every `addBatch` row): +decorator, which forwards to the adapter with the tenant pre-filled (and stamps +it onto every `addBatch` row): ```php use Utopia\Usage\Tenant; @@ -355,7 +353,7 @@ $usage->purge('project_123', [], Usage::TYPE_GAUGE); ### Creating Custom Adapters -Extend `Utopia\Usage\Adapter` and implement: +Extend `Utopia\Usage\Usage` and implement: - `getName()`, `setup()`, `healthCheck()` - `addBatch(array $metrics, string $type, int $batchSize): bool` (each metric carries its own `tenant`) diff --git a/src/Usage/Accumulator.php b/src/Usage/Accumulator.php index 965c229..f200bc5 100644 --- a/src/Usage/Accumulator.php +++ b/src/Usage/Accumulator.php @@ -64,9 +64,7 @@ public function collect(string $tenant, string $metric, int $value, string $type if ($value < 0) { throw new \InvalidArgumentException('Value cannot be negative'); } - if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) { - throw new \InvalidArgumentException("Invalid metric type '{$type}'. Allowed: " . Usage::TYPE_EVENT . ', ' . Usage::TYPE_GAUGE); - } + Usage::assertType($type); // Hash the full identity so distinct (tenant, metric, type, tags) // tuples never collide on the key — a raw `:`-join would let diff --git a/src/Usage/Adapter.php b/src/Usage/Adapter.php deleted file mode 100644 index ddbf659..0000000 --- a/src/Usage/Adapter.php +++ /dev/null @@ -1,174 +0,0 @@ - Health check result with 'healthy' bool and additional adapter-specific information - */ - abstract public function healthCheck(): array; - - /** - * Setup database structure - */ - abstract public function setup(): void; - - /** - * Add metrics in batch (raw append). - * - * Routes rows to the correct table based on the $type parameter. - * For events, path/method/status/resource/resourceId are extracted from tags - * into dedicated columns; remaining tags stay in the tags JSON. - * - * Each metric carries its own `tenant` (shared-tables mode), so a single - * batch may span multiple tenants. - * - * @param array}> $metrics - * @param string $type Metric type: 'event' or 'gauge' — determines which table to write to - * @param int $batchSize Maximum number of metrics per INSERT statement - */ - abstract public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool; - - /** - * Get time series data for metrics with query-time aggregation. - * - * Groups data by the specified interval (1h or 1d) and applies - * SUM for event metrics and argMax for gauge metrics. - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array $metrics List of metric names - * @param string $interval Aggregation interval: '1h' or '1d' - * @param string $startDate Start datetime string - * @param string $endDate End datetime string - * @param array<\Utopia\Query\Query> $queries Additional query filters - * @param bool $zeroFill Whether to fill gaps with zero values - * @param string|null $type Metric type: 'event', 'gauge', or null (query both) - * @return array}> - */ - abstract public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array; - - /** - * Get total value for a single metric. - * - * Returns sum for event metrics, latest value for gauge metrics. - * When $type is null, queries both tables. - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param string $metric Metric name - * @param array<\Utopia\Query\Query> $queries Additional query filters - * @param string|null $type Metric type: 'event', 'gauge', or null (query both) - * @return int - */ - abstract public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int; - - /** - * Get totals for multiple metrics in a single query. - * - * Returns sum for event metrics, latest value for gauge metrics. - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array $metrics List of metric names - * @param array<\Utopia\Query\Query> $queries Additional query filters - * @param string|null $type Metric type: 'event', 'gauge', or null (query both) - * @return array - */ - abstract public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array; - - /** - * Purge usage metrics matching the given queries. - * When no queries are provided, all metrics are deleted. - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array<\Utopia\Query\Query> $queries - * @param string|null $type Metric type: 'event', 'gauge', or null (purge both) - */ - abstract public function purge(string $tenant, array $queries = [], ?string $type = null): bool; - - /** - * Find metrics using Query objects. - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array<\Utopia\Query\Query> $queries - * @param string|null $type Metric type: 'event', 'gauge', or null (query both) - * @return array - */ - abstract public function find(string $tenant, array $queries = [], ?string $type = null): array; - - /** - * Count metrics using Query objects. - * - * When $max is non-null the count is bounded at the database level — - * the adapter must stop counting once $max rows have been matched. - * This keeps large counts cheap for endpoints that only need a capped - * total. When $max is null the count is unbounded. - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array<\Utopia\Query\Query> $queries - * @param string|null $type Metric type: 'event', 'gauge', or null (count both) - * @param int|null $max Optional upper bound for the count (inclusive) - * @return int - */ - abstract public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int; - - /** - * Sum metric values using Query objects. - * - * Events-only by default because summing gauges is semantically meaningless - * (adding point-in-time snapshots doesn't produce a useful total). - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array<\Utopia\Query\Query> $queries - * @param string $attribute Attribute to sum (default: 'value') - * @param string $type Metric type: 'event' or 'gauge' - * @return int - */ - abstract public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int; - - /** - * Find event metrics from the pre-aggregated daily table. - * - * Queries the SummingMergeTree daily materialized view for fast billing/analytics. - * - * Note: Daily MV only stores event metrics. This method always queries - * the daily events table — gauges are never pre-aggregated. - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array<\Utopia\Query\Query> $queries Filters (metric, time range, resource, etc.) - * @return array - */ - abstract public function findDaily(string $tenant, array $queries = []): array; - - /** - * Sum event metric values from the pre-aggregated daily table. - * - * Note: Daily MV only stores event metrics. This method always queries - * the daily events table — gauges are never pre-aggregated. - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array<\Utopia\Query\Query> $queries - * @param string $attribute Attribute to sum (default: 'value') - * @return int - */ - abstract public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int; - - /** - * Sum multiple event metrics from the pre-aggregated daily table in one query. - * - * Note: Daily MV only stores event metrics. This method always queries - * the daily events table — gauges are never pre-aggregated. - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array $metrics List of metric names - * @param array<\Utopia\Query\Query> $queries Additional filters (e.g. date range) - * @return array Metric name => sum value - */ - abstract public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array; -} diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 9cf0cd5..a633f2b 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -13,7 +13,6 @@ use Utopia\Psr7\Request\Factory as RequestFactory; use Utopia\Query\Query; use Utopia\Usage\Metric; -use Utopia\Usage\Usage; use Utopia\Usage\UsageQuery; use Utopia\Validator\Hostname; @@ -417,7 +416,7 @@ private function getEventsDailyTableName(): string */ private function getTableForType(string $type): string { - return $type === Usage::TYPE_GAUGE ? $this->getGaugesTableName() : $this->getEventsTableName(); + return $type === self::TYPE_GAUGE ? $this->getGaugesTableName() : $this->getEventsTableName(); } /** @@ -1031,7 +1030,7 @@ private function validateAttributeName(string $attributeName, string $type = 'ev */ private function validateGroupByAttribute(string $attribute, string $type): bool { - $allowed = $type === Usage::TYPE_GAUGE ? Metric::GAUGE_COLUMNS : Metric::EVENT_COLUMNS; + $allowed = $type === self::TYPE_GAUGE ? Metric::GAUGE_COLUMNS : Metric::EVENT_COLUMNS; if (in_array($attribute, $allowed, true)) { return true; @@ -1203,9 +1202,7 @@ private function validateMetricData(string $metric, int $value, string $type, ar throw new Exception($prefix . 'Value cannot be negative'); } - if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) { - throw new \InvalidArgumentException($prefix . "Invalid type '{$type}'. Allowed: " . Usage::TYPE_EVENT . ', ' . Usage::TYPE_GAUGE); - } + self::assertType($type, $prefix); } /** @@ -1378,11 +1375,11 @@ public function find(string $tenant, array $queries = [], ?string $type = null): // up to 2N rows. Slice the merged result back down to the user's // requested limit. Tables whose schema doesn't support every filter // attribute (e.g. `path` on a gauge query) are skipped. - $events = $this->queriesMatchType($queries, Usage::TYPE_EVENT) - ? $this->findFromTable($tenant, $queries, Usage::TYPE_EVENT) + $events = $this->queriesMatchType($queries, self::TYPE_EVENT) + ? $this->findFromTable($tenant, $queries, self::TYPE_EVENT) : []; - $gauges = $this->queriesMatchType($queries, Usage::TYPE_GAUGE) - ? $this->findFromTable($tenant, $queries, Usage::TYPE_GAUGE) + $gauges = $this->queriesMatchType($queries, self::TYPE_GAUGE) + ? $this->findFromTable($tenant, $queries, self::TYPE_GAUGE) : []; $merged = array_merge($events, $gauges); @@ -1526,7 +1523,7 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin $hasInterval = isset($parsed['groupByInterval']); // Choose aggregation function based on metric type - $valueExpr = $type === Usage::TYPE_GAUGE + $valueExpr = $type === self::TYPE_GAUGE ? 'argMax(value, time) as value' : 'SUM(value) as value'; @@ -1689,11 +1686,11 @@ public function count(string $tenant, array $queries = [], ?string $type = null, // capped at $max, so naively summing them could yield up to 2*$max. // Cap the combined total at $max in PHP to honour the contract. // Skip a table when its schema can't satisfy every filter attribute. - $events = $this->queriesMatchType($queries, Usage::TYPE_EVENT) - ? $this->countFromTable($tenant, $queries, Usage::TYPE_EVENT, $max) + $events = $this->queriesMatchType($queries, self::TYPE_EVENT) + ? $this->countFromTable($tenant, $queries, self::TYPE_EVENT, $max) : 0; - $gauges = $this->queriesMatchType($queries, Usage::TYPE_GAUGE) - ? $this->countFromTable($tenant, $queries, Usage::TYPE_GAUGE, $max) + $gauges = $this->queriesMatchType($queries, self::TYPE_GAUGE) + ? $this->countFromTable($tenant, $queries, self::TYPE_GAUGE, $max) : 0; $total = $events + $gauges; @@ -1764,11 +1761,12 @@ private function countFromTable(string $tenant, array $queries, string $type, ?i * @return int * @throws Exception */ - public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = self::TYPE_EVENT): int { + self::assertType($type); $this->setOperationContext('sum()'); - if ($type === Usage::TYPE_EVENT && $attribute === 'value') { + if ($type === self::TYPE_EVENT && $attribute === 'value') { return $this->routedSum($tenant, $queries, 'sum'); } @@ -1799,7 +1797,7 @@ private function routedSum(string $tenant, array $queries, string $operation): i return $total; } - return $this->sumFromTable($tenant, $queries, 'value', Usage::TYPE_EVENT); + return $this->sumFromTable($tenant, $queries, 'value', self::TYPE_EVENT); } /** @@ -2356,7 +2354,7 @@ private function maybeDualRead(string $tenant, array $queries, string $route, ar } try { - $rawTotal = $this->sumFromTable($tenant, $queries, 'value', Usage::TYPE_EVENT); + $rawTotal = $this->sumFromTable($tenant, $queries, 'value', self::TYPE_EVENT); } catch (Throwable $e) { return; } @@ -2396,9 +2394,9 @@ private function sumHybridDailyAndRaw(string $tenant, array $queries, array $pla $eventsTable = $this->buildTableReference($this->getEventsTableName()); $split = $this->splitTimeQueries($queries); - $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, self::TYPE_EVENT); $dailyParsed = $this->prefixParsedParams( - $this->parseQueries($tenant, $split['nonTime'], Usage::TYPE_EVENT), + $this->parseQueries($tenant, $split['nonTime'], self::TYPE_EVENT), 'd_' ); @@ -2491,7 +2489,7 @@ public function findDaily(string $tenant, array $queries = []): array $this->validateDailyAttributeName($attr); } } - $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, self::TYPE_EVENT); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $groupByColumns = $this->sharedTables ? ['tenant'] : []; @@ -2516,7 +2514,7 @@ public function findDaily(string $tenant, array $queries = []): array $sql = "SELECT {$selectColumns} FROM {$fromTable}{$whereData['clause']} GROUP BY {$groupBySql}{$orderClause}{$limitClause}{$offsetClause} FORMAT JSON"; - return $this->parseResults($this->query($sql, $whereData['params']), Usage::TYPE_EVENT); + return $this->parseResults($this->query($sql, $whereData['params']), self::TYPE_EVENT); } /** @@ -2553,7 +2551,7 @@ private function sumDailyTotal(string $tenant, array $queries, string $attribute $this->validateDailyAttributeName($attr); } } - $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, self::TYPE_EVENT); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $sql = "SELECT sum({$escapedAttribute}) as total FROM {$fromTable}{$whereData['clause']} FORMAT JSON"; @@ -2601,7 +2599,7 @@ public function sumDailyBatch(string $tenant, array $metrics, array $queries = [ } $metricInClause = implode(', ', $metricPlaceholders); - $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, self::TYPE_EVENT); $params = array_merge($metricParams, $parsed['params']); $whereData = $this->buildWhereClause($parsed['filters'], $params); @@ -2668,11 +2666,11 @@ public function getTimeSeries(string $tenant, array $metrics, string $interval, } $typesToQuery = []; - if ($type === Usage::TYPE_EVENT || $type === null) { - $typesToQuery[] = Usage::TYPE_EVENT; + if ($type === self::TYPE_EVENT || $type === null) { + $typesToQuery[] = self::TYPE_EVENT; } - if ($type === Usage::TYPE_GAUGE || $type === null) { - $typesToQuery[] = Usage::TYPE_GAUGE; + if ($type === self::TYPE_GAUGE || $type === null) { + $typesToQuery[] = self::TYPE_GAUGE; } foreach ($typesToQuery as $queryType) { @@ -2758,7 +2756,7 @@ private function getTimeSeriesFromTable(string $tenant, array $metrics, string $ $additionalWhere = ' AND ' . implode(' AND ', $additionalFilters); } - $valueExpr = $type === Usage::TYPE_EVENT + $valueExpr = $type === self::TYPE_EVENT ? 'SUM(value) as agg_value' : 'argMax(value, time) as agg_value'; @@ -2868,11 +2866,11 @@ public function getTotal(string $tenant, string $metric, array $queries = [], ?s { $this->setOperationContext('getTotal()'); - if ($type === Usage::TYPE_EVENT) { + if ($type === self::TYPE_EVENT) { return $this->getTotalFromEvents($tenant, $metric, $queries); } - if ($type === Usage::TYPE_GAUGE) { + if ($type === self::TYPE_GAUGE) { return $this->getTotalFromGauges($tenant, $metric, $queries); } @@ -2924,7 +2922,7 @@ private function getTotalFromGauges(string $tenant, string $metric, array $queri $tableName = $this->getGaugesTableName(); $fromTable = $this->buildTableReference($tableName); - $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_GAUGE); + $parsed = $this->parseQueries($tenant, $queries, self::TYPE_GAUGE); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $sql = " @@ -2973,11 +2971,11 @@ public function getTotalBatch(string $tenant, array $metrics, array $queries = [ $contributingType = []; $typesToQuery = []; - if ($type === Usage::TYPE_EVENT || $type === null) { - $typesToQuery[] = Usage::TYPE_EVENT; + if ($type === self::TYPE_EVENT || $type === null) { + $typesToQuery[] = self::TYPE_EVENT; } - if ($type === Usage::TYPE_GAUGE || $type === null) { - $typesToQuery[] = Usage::TYPE_GAUGE; + if ($type === self::TYPE_GAUGE || $type === null) { + $typesToQuery[] = self::TYPE_GAUGE; } foreach ($typesToQuery as $queryType) { @@ -3006,7 +3004,7 @@ public function getTotalBatch(string $tenant, array $metrics, array $queries = [ ? $whereClause . ' AND ' . $metricFilter : ' WHERE ' . $metricFilter; - $valueExpr = $queryType === Usage::TYPE_EVENT + $valueExpr = $queryType === self::TYPE_EVENT ? 'SUM(value) as agg_val' : 'argMax(value, time) as agg_val'; @@ -3696,11 +3694,11 @@ public function purge(string $tenant, array $queries = [], ?string $type = null) $this->setOperationContext('purge()'); $typesToPurge = []; - if ($type === Usage::TYPE_EVENT || $type === null) { - $typesToPurge[] = Usage::TYPE_EVENT; + if ($type === self::TYPE_EVENT || $type === null) { + $typesToPurge[] = self::TYPE_EVENT; } - if ($type === Usage::TYPE_GAUGE || $type === null) { - $typesToPurge[] = Usage::TYPE_GAUGE; + if ($type === self::TYPE_GAUGE || $type === null) { + $typesToPurge[] = self::TYPE_GAUGE; } foreach ($typesToPurge as $purgeType) { @@ -3719,7 +3717,7 @@ public function purge(string $tenant, array $queries = [], ?string $type = null) $sql = "DELETE FROM {$escapedTable}{$whereClause}"; $this->query($sql, $params); - if ($purgeType === Usage::TYPE_EVENT) { + if ($purgeType === self::TYPE_EVENT) { $this->purgeDaily($tenant, $queries); } } @@ -3813,7 +3811,7 @@ private function purgeDaily(string $tenant, array $queries): void // forward to the rollup. $dailyTable = $this->buildTableReference($this->getEventsDailyTableName()); - $parsed = $this->parseQueries($tenant, $dailyQueries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $dailyQueries, self::TYPE_EVENT); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $whereClause = $whereData['clause']; diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index 4d25bdb..17cc707 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -7,7 +7,6 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Query as DatabaseQuery; use Utopia\Usage\Metric; -use Utopia\Usage\Usage; use Utopia\Usage\UsageQuery; use Utopia\Query\Query; @@ -133,13 +132,11 @@ protected function getColumnDefinition(string $id, string $type = 'event'): stri */ public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool { + self::assertType($type); + $this->db->getAuthorization()->skip(function () use ($metrics, $type, $batchSize) { $entries = []; foreach ($metrics as $metric) { - if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) { - throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: event, gauge"); - } - if ($metric['value'] < 0) { throw new \InvalidArgumentException('Value cannot be negative'); } @@ -214,7 +211,7 @@ public function getTotal(string $tenant, string $metric, array $queries = [], ?s Query::equal('metric', [$metric]), ]); - if ($type === Usage::TYPE_GAUGE) { + if ($type === self::TYPE_GAUGE) { // For gauge, return the most recent value by time. find() does // not guarantee any ordering, so we explicitly sort + limit // here instead of relying on insertion order. @@ -237,7 +234,7 @@ public function getTotal(string $tenant, string $metric, array $queries = [], ?s return 0; } - if ($type === Usage::TYPE_EVENT) { + if ($type === self::TYPE_EVENT) { // For events, SUM all values $sum = 0; foreach ($results as $result) { @@ -250,7 +247,7 @@ public function getTotal(string $tenant, string $metric, array $queries = [], ?s $eventResults = []; $gaugeResults = []; foreach ($results as $result) { - if ($result->getType() === Usage::TYPE_GAUGE) { + if ($result->getType() === self::TYPE_GAUGE) { $gaugeResults[] = $result; } else { $eventResults[] = $result; @@ -318,7 +315,7 @@ public function getTotalBatch(string $tenant, array $metrics, array $queries = [ * @param string $type 'event' or 'gauge' * @return int */ - public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = self::TYPE_EVENT): int { /** @var array $results */ $results = $this->find($tenant, $queries, $type); @@ -340,7 +337,7 @@ public function sum(string $tenant, array $queries = [], string $attribute = 'va */ public function findDaily(string $tenant, array $queries = []): array { - return $this->find($tenant, $queries, Usage::TYPE_EVENT); + return $this->find($tenant, $queries, self::TYPE_EVENT); } /** @@ -370,7 +367,7 @@ public function sumDailyBatch(string $tenant, array $metrics, array $queries = [ */ public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int { - return $this->sum($tenant, $queries, $attribute, Usage::TYPE_EVENT); + return $this->sum($tenant, $queries, $attribute, self::TYPE_EVENT); } /** @@ -632,9 +629,7 @@ private function withTypeFilter(array $queries, ?string $type): array return $queries; } - if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) { - throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: " . Usage::TYPE_EVENT . ', ' . Usage::TYPE_GAUGE); - } + self::assertType($type); return array_merge($queries, [Query::equal('type', [$type])]); } diff --git a/src/Usage/Adapter/SQL.php b/src/Usage/Adapter/SQL.php index f7f2d6b..ec77674 100644 --- a/src/Usage/Adapter/SQL.php +++ b/src/Usage/Adapter/SQL.php @@ -2,7 +2,7 @@ namespace Utopia\Usage\Adapter; -use Utopia\Usage\Adapter; +use Utopia\Usage\Usage; use Utopia\Usage\Metric; use Utopia\Database\Document; @@ -12,7 +12,7 @@ * This is an abstract base class for SQL-based adapters (Database, ClickHouse, etc.) * It provides common functionality and references schema definitions from the Metric class. */ -abstract class SQL extends Adapter +abstract class SQL extends Usage { public const COLLECTION = 'usage'; diff --git a/src/Usage/Usage.php b/src/Usage/Usage.php index 7897158..86d7ad0 100644 --- a/src/Usage/Usage.php +++ b/src/Usage/Usage.php @@ -2,129 +2,104 @@ namespace Utopia\Usage; -/** - * Usage Metrics Manager - * - * This class manages usage metrics using pluggable adapters. - * Adapters can be used to store metrics in different backends (Database, ClickHouse, etc.) - * - * Metrics are stored in two separate tables: - * - Events table: additive metrics (bandwidth, requests, etc.) aggregated with SUM - * - Gauges table: point-in-time snapshots (storage, user count, etc.) aggregated with argMax - */ -class Usage +abstract class Usage { public const TYPE_EVENT = 'event'; public const TYPE_GAUGE = 'gauge'; - private Adapter $adapter; - /** - * Constructor. + * Assert that $type is a valid metric type, throwing otherwise. + * + * Single source of truth for the event/gauge guard shared by the adapters + * and the Accumulator. $prefix lets batch validators add positional context + * (e.g. "Metric #3: ") to the message. * - * @param Adapter $adapter The adapter to use for storing usage metrics + * @throws \InvalidArgumentException */ - public function __construct(Adapter $adapter) + public static function assertType(string $type, string $prefix = ''): void { - $this->adapter = $adapter; + if ($type !== self::TYPE_EVENT && $type !== self::TYPE_GAUGE) { + throw new \InvalidArgumentException($prefix . "Invalid type '{$type}'. Allowed: " . self::TYPE_EVENT . ', ' . self::TYPE_GAUGE); + } } /** - * Get the current adapter. + * Get adapter name */ - public function getAdapter(): Adapter - { - return $this->adapter; - } + abstract public function getName(): string; /** - * Check adapter health and connection status. + * Check adapter health and connection status * * @return array Health check result with 'healthy' bool and additional adapter-specific information */ - public function healthCheck(): array - { - return $this->adapter->healthCheck(); - } + abstract public function healthCheck(): array; /** - * Setup the usage metrics storage. - * - * @throws \Exception + * Setup database structure */ - public function setup(): void - { - $this->adapter->setup(); - } + abstract public function setup(): void; /** * Add metrics in batch (raw append). * - * Callers must explicitly pass the metric type so event and gauge - * writes are never confused at the call site. + * Routes rows to the correct table based on the $type parameter. + * For events, path/method/status/resource/resourceId are extracted from tags + * into dedicated columns; remaining tags stay in the tags JSON. * * Each metric carries its own `tenant` (shared-tables mode), so a single * batch may span multiple tenants. * - * @param array}> $metrics - * @param string $type Metric type: 'event' or 'gauge' - * @param int $batchSize Maximum number of metrics per INSERT statement - * @return bool - * @throws \Exception + * @param array}> $metrics + * @param string $type Metric type: 'event' or 'gauge' — determines which table to write to + * @param int $batchSize Maximum number of metrics per INSERT statement */ - public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool - { - return $this->adapter->addBatch($metrics, $type, $batchSize); - } + abstract public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool; /** - * Get time series data for metrics. - * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array $metrics List of metric names - * @param string $interval '1h' or '1d' - * @param string $startDate Start datetime - * @param string $endDate End datetime - * @param array<\Utopia\Query\Query> $queries Additional filters - * @param bool $zeroFill Whether to fill gaps with zero values - * @param string|null $type Metric type: 'event', 'gauge', or null (query both) + * Get time series data for metrics with query-time aggregation. + * + * Groups data by the specified interval (1h or 1d) and applies + * SUM for event metrics and argMax for gauge metrics. + * + * @param string $tenant Tenant scope (shared-tables mode) + * @param array $metrics List of metric names + * @param string $interval Aggregation interval: '1h' or '1d' + * @param string $startDate Start datetime string + * @param string $endDate End datetime string + * @param array<\Utopia\Query\Query> $queries Additional query filters + * @param bool $zeroFill Whether to fill gaps with zero values + * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return array}> - * @throws \Exception */ - public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array - { - return $this->adapter->getTimeSeries($tenant, $metrics, $interval, $startDate, $endDate, $queries, $zeroFill, $type); - } + abstract public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array; /** * Get total value for a single metric. * - * @param string $tenant Tenant scope (shared-tables mode) - * @param string $metric Metric name - * @param array<\Utopia\Query\Query> $queries Additional filters - * @param string|null $type Metric type: 'event', 'gauge', or null (query both) + * Returns sum for event metrics, latest value for gauge metrics. + * When $type is null, queries both tables. + * + * @param string $tenant Tenant scope (shared-tables mode) + * @param string $metric Metric name + * @param array<\Utopia\Query\Query> $queries Additional query filters + * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return int - * @throws \Exception */ - public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int - { - return $this->adapter->getTotal($tenant, $metric, $queries, $type); - } + abstract public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int; /** * Get totals for multiple metrics in a single query. * - * @param string $tenant Tenant scope (shared-tables mode) - * @param array $metrics List of metric names - * @param array<\Utopia\Query\Query> $queries Additional filters - * @param string|null $type Metric type: 'event', 'gauge', or null (query both) + * Returns sum for event metrics, latest value for gauge metrics. + * + * @param string $tenant Tenant scope (shared-tables mode) + * @param array $metrics List of metric names + * @param array<\Utopia\Query\Query> $queries Additional query filters + * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return array - * @throws \Exception */ - public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array - { - return $this->adapter->getTotalBatch($tenant, $metrics, $queries, $type); - } + abstract public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array; /** * Purge usage metrics matching the given queries. @@ -133,12 +108,8 @@ public function getTotalBatch(string $tenant, array $metrics, array $queries = [ * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string|null $type Metric type: 'event', 'gauge', or null (purge both) - * @throws \Exception */ - public function purge(string $tenant, array $queries = [], ?string $type = null): bool - { - return $this->adapter->purge($tenant, $queries, $type); - } + abstract public function purge(string $tenant, array $queries = [], ?string $type = null): bool; /** * Find metrics using Query objects. @@ -147,80 +118,56 @@ public function purge(string $tenant, array $queries = [], ?string $type = null) * @param array<\Utopia\Query\Query> $queries * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return array - * @throws \Exception */ - public function find(string $tenant, array $queries = [], ?string $type = null): array - { - return $this->adapter->find($tenant, $queries, $type); - } + abstract public function find(string $tenant, array $queries = [], ?string $type = null): array; /** * Count metrics using Query objects. * - * When $max is non-null the count is bounded at the database level. - * Callers that only need a capped total (e.g. to render "5000+") should - * pass $max so the adapter can short-circuit the count for large tables. + * When $max is non-null the count is bounded at the database level — + * the adapter must stop counting once $max rows have been matched. + * This keeps large counts cheap for endpoints that only need a capped + * total. When $max is null the count is unbounded. * * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string|null $type Metric type: 'event', 'gauge', or null (count both) * @param int|null $max Optional upper bound for the count (inclusive) * @return int - * @throws \Exception */ - public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int - { - return $this->adapter->count($tenant, $queries, $type, $max); - } + abstract public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int; /** * Sum metric values using Query objects. * - * Defaults to events because summing gauges (point-in-time snapshots) - * is semantically meaningless — it averages/accumulates snapshots rather - * than producing a useful total. Callers that truly want a gauge sum - * must opt in explicitly. + * Events-only by default because summing gauges is semantically meaningless + * (adding point-in-time snapshots doesn't produce a useful total). * * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string $attribute Attribute to sum (default: 'value') * @param string $type Metric type: 'event' or 'gauge' * @return int - * @throws \Exception */ - public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = self::TYPE_EVENT): int - { - if ($type !== self::TYPE_EVENT && $type !== self::TYPE_GAUGE) { - throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: " . self::TYPE_EVENT . ', ' . self::TYPE_GAUGE); - } - - return $this->adapter->sum($tenant, $queries, $attribute, $type); - } + abstract public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = self::TYPE_EVENT): int; /** * Find event metrics from the pre-aggregated daily table. * - * Queries the SummingMergeTree daily MV for fast billing/analytics. + * Queries the SummingMergeTree daily materialized view for fast billing/analytics. * * Note: Daily MV only stores event metrics. This method always queries * the daily events table — gauges are never pre-aggregated. * * @param string $tenant Tenant scope (shared-tables mode) - * @param array<\Utopia\Query\Query> $queries + * @param array<\Utopia\Query\Query> $queries Filters (metric, time range, resource, etc.) * @return array - * @throws \Exception */ - public function findDaily(string $tenant, array $queries = []): array - { - return $this->adapter->findDaily($tenant, $queries); - } + abstract public function findDaily(string $tenant, array $queries = []): array; /** * Sum event metric values from the pre-aggregated daily table. * - * Use this for billing queries — reads pre-aggregated daily rows - * instead of scanning billions of raw events. - * * Note: Daily MV only stores event metrics. This method always queries * the daily events table — gauges are never pre-aggregated. * @@ -228,12 +175,8 @@ public function findDaily(string $tenant, array $queries = []): array * @param array<\Utopia\Query\Query> $queries * @param string $attribute Attribute to sum (default: 'value') * @return int - * @throws \Exception */ - public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int - { - return $this->adapter->sumDaily($tenant, $queries, $attribute); - } + abstract public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int; /** * Sum multiple event metrics from the pre-aggregated daily table in one query. @@ -245,10 +188,6 @@ public function sumDaily(string $tenant, array $queries = [], string $attribute * @param array $metrics List of metric names * @param array<\Utopia\Query\Query> $queries Additional filters (e.g. date range) * @return array Metric name => sum value - * @throws \Exception */ - public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array - { - return $this->adapter->sumDailyBatch($tenant, $metrics, $queries); - } + abstract public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array; } diff --git a/tests/Benchmark/BenchmarkBase.php b/tests/Benchmark/BenchmarkBase.php index 5500431..d31cbac 100644 --- a/tests/Benchmark/BenchmarkBase.php +++ b/tests/Benchmark/BenchmarkBase.php @@ -2,12 +2,12 @@ namespace Utopia\Tests\Benchmark; +use Utopia\Usage\Usage; use PHPUnit\Framework\TestCase; use ReflectionClass; use RuntimeException; use Throwable; use Utopia\Usage\Adapter\ClickHouse as ClickHouseAdapter; -use Utopia\Usage\Usage; /** * Base class for ClickHouse benchmarks. @@ -66,7 +66,7 @@ protected function setUp(): void sharedTables: true, ); - $this->usage = new Usage($this->adapter); + $this->usage = $this->adapter; $this->usage->setup(); $rowsEnv = getenv('BENCH_ROWS'); diff --git a/tests/Usage/AccumulatorTest.php b/tests/Usage/AccumulatorTest.php index f63808a..a4951ed 100644 --- a/tests/Usage/AccumulatorTest.php +++ b/tests/Usage/AccumulatorTest.php @@ -4,7 +4,6 @@ use PHPUnit\Framework\TestCase; use Utopia\Usage\Accumulator; -use Utopia\Usage\Adapter; use Utopia\Usage\Usage; /** @@ -12,7 +11,7 @@ * addBatch() returns whatever $succeed is set to, letting tests drive the * partial-failure path. */ -class RecordingAdapter extends Adapter +class RecordingAdapter extends Usage { /** @var array>, type: string}> */ public array $batches = []; @@ -122,7 +121,7 @@ class AccumulatorTest extends TestCase protected function setUp(): void { $this->adapter = new RecordingAdapter(); - $this->accumulator = new Accumulator(new Usage($this->adapter)); + $this->accumulator = new Accumulator($this->adapter); } public function testEventsSumByKey(): void diff --git a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php index b3b6912..c916a68 100644 --- a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php @@ -38,7 +38,7 @@ protected function setUp(): void database: getenv('CLICKHOUSE_DATABASE') ?: 'default', sharedTables: true, ); - $this->usage = new Usage($this->adapter); + $this->usage = $this->adapter; $this->usage->setup(); $this->usage->purge('1'); diff --git a/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php b/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php index 562a81b..efe0d07 100644 --- a/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php @@ -36,7 +36,7 @@ protected function setUp(): void database: getenv('CLICKHOUSE_DATABASE') ?: 'default', sharedTables: true, ); - $this->usage = new Usage($this->adapter); + $this->usage = $this->adapter; $this->usage->setup(); $this->usage->purge('1'); diff --git a/tests/Usage/Adapter/ClickHouseRoutingTest.php b/tests/Usage/Adapter/ClickHouseRoutingTest.php index 862eee4..8db2e0c 100644 --- a/tests/Usage/Adapter/ClickHouseRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseRoutingTest.php @@ -30,7 +30,7 @@ protected function setUp(): void database: getenv('CLICKHOUSE_DATABASE') ?: 'default', sharedTables: true, ); - $this->usage = new Usage($this->adapter); + $this->usage = $this->adapter; $this->usage->setup(); $this->usage->purge('1'); diff --git a/tests/Usage/Adapter/ClickHouseSchemaTest.php b/tests/Usage/Adapter/ClickHouseSchemaTest.php index 37c9e84..7ded98f 100644 --- a/tests/Usage/Adapter/ClickHouseSchemaTest.php +++ b/tests/Usage/Adapter/ClickHouseSchemaTest.php @@ -4,7 +4,6 @@ use Utopia\Tests\Usage\Adapter\ClickHouseTestCase; use Utopia\Usage\Adapter\ClickHouse as ClickHouseAdapter; -use Utopia\Usage\Usage; /** * Asserts the events / gauges / daily / dim-rollup tables emerge from @@ -27,7 +26,7 @@ protected function setUp(): void database: getenv('CLICKHOUSE_DATABASE') ?: 'default', sharedTables: true, ); - $usage = new Usage($this->adapter); + $usage = $this->adapter; $usage->setup(); } @@ -72,7 +71,7 @@ public function testDailyTableMatchesPrePrSchema(): void $this->queryRaw($this->adapter, "DROP TABLE IF EXISTS {$mvName}"); $this->queryRaw($this->adapter, "DROP TABLE IF EXISTS {$fullName}"); - $usage = new Usage($this->adapter); + $usage = $this->adapter; $usage->setup(); $ddl = $this->showCreate($dailyTable); @@ -144,7 +143,7 @@ public function testSetupBackfillsServiceResourceOnLegacyGaugesTable(): void SETTINGS allow_nullable_key = 1 "); - $usage = new Usage($legacyAdapter); + $usage = $legacyAdapter; $usage->setup(); $rawString = $this->queryRaw($legacyAdapter, "SHOW CREATE TABLE {$fullName} FORMAT JSON"); diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index aa7e031..9a7710c 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -32,7 +32,7 @@ protected function initializeUsage(): void database: getenv('CLICKHOUSE_DATABASE') ?: 'default', ); - $this->usage = new Usage($adapter); + $this->usage = $adapter; $this->usage->setup(); } @@ -55,7 +55,7 @@ public function testPerMetricTenantInBatch(): void sharedTables: true, ); - $usage = new Usage($adapter); + $usage = $adapter; $usage->setup(); $usage->purge('2'); @@ -687,7 +687,7 @@ public function testFindComprehensive(): void */ public function testHealthCheck(): void { - $adapter = $this->usage->getAdapter(); + $adapter = $this->usage; $health = $adapter->healthCheck(); @@ -763,7 +763,7 @@ public function testConnectionPooling(): void database: getenv('CLICKHOUSE_DATABASE') ?: 'default', ); - $usage = new Usage($adapter); + $usage = $adapter; $usage->setup(); // Connection reuse is always on (the transport client holds the cURL @@ -808,7 +808,7 @@ public function testErrorMessagesIncludeContext(): void database: 'nonexistent_db_for_testing_errors_12345', ); - $usage = new Usage($adapter); + $usage = $adapter; try { // This should fail because database doesn't exist @@ -859,7 +859,7 @@ public function testAsyncInsertConfiguration(): void $this->assertTrue($stats['async_insert_wait']); // Verify it works with async inserts enabled - $usage = new Usage($adapter); + $usage = $adapter; $usage->setup(); $usage->purge('1'); diff --git a/tests/Usage/Adapter/DatabaseTest.php b/tests/Usage/Adapter/DatabaseTest.php index 5c0ee1e..a927028 100644 --- a/tests/Usage/Adapter/DatabaseTest.php +++ b/tests/Usage/Adapter/DatabaseTest.php @@ -36,7 +36,7 @@ protected function initializeUsage(): void $this->database->setDatabase('utopiaTests'); $this->database->setNamespace('utopia_usage'); - $this->usage = new Usage(new AdapterDatabase($this->database)); + $this->usage = new AdapterDatabase($this->database); // Create database if missing try { @@ -204,7 +204,7 @@ public function testEmptyStringCoercedToNull(): void */ public function testHealthCheck(): void { - $adapter = $this->usage->getAdapter(); + $adapter = $this->usage; $health = $adapter->healthCheck(); diff --git a/tests/Usage/TenantTest.php b/tests/Usage/TenantTest.php index 4f813cd..3cb26f7 100644 --- a/tests/Usage/TenantTest.php +++ b/tests/Usage/TenantTest.php @@ -3,15 +3,14 @@ namespace Utopia\Tests\Usage; use PHPUnit\Framework\TestCase; -use Utopia\Usage\Adapter; -use Utopia\Usage\Tenant; use Utopia\Usage\Usage; +use Utopia\Usage\Tenant; /** * Records the tenant passed to each method so the Tenant decorator can be * tested without a backend. */ -class TenantRecordingAdapter extends Adapter +class TenantRecordingAdapter extends Usage { public ?string $lastTenant = null; @@ -129,14 +128,14 @@ class TenantTest extends TestCase protected function setUp(): void { $this->adapter = new TenantRecordingAdapter(); - $this->tenant = new Tenant(new Usage($this->adapter), 'p1'); + $this->tenant = new Tenant($this->adapter, 'p1'); } public function testEmptyTenantThrows(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Tenant cannot be empty'); - new Tenant(new Usage($this->adapter), ''); + new Tenant($this->adapter, ''); } public function testAddBatchStampsBoundTenantOntoEveryMetric(): void