diff --git a/README.md b/README.md index 1d3c1f9..9591632 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ composer require utopia-php/usage use Utopia\Usage\Usage; use Utopia\Usage\Adapter\ClickHouse; -// Configuration is fixed at construction; only the tenant changes per request. +// Configuration is fixed at construction. The adapter is fully stateless — +// the tenant is passed explicitly on every call (see below). $adapter = new ClickHouse( host: 'clickhouse-server', username: 'default', @@ -46,7 +47,6 @@ $adapter = new ClickHouse( namespace: 'my_app', sharedTables: true, ); -$adapter->setTenant('project_123'); $usage = new Usage($adapter); $usage->setup(); // Creates events, gauges, and daily MV tables @@ -65,6 +65,35 @@ $usage = new Usage($adapter); $usage->setup(); ``` +## Multi-tenancy + +`Usage` 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 +tenants and coroutines. + +```php +$usage->getTotal('project_123', 'bandwidth'); // read for one tenant +$usage->addBatch([ + ['tenant' => 'project_123', 'metric' => 'bandwidth', 'value' => 5000, 'tags' => []], + ['tenant' => 'project_456', 'metric' => 'bandwidth', 'value' => 2000, 'tags' => []], +], Usage::TYPE_EVENT); +``` + +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): + +```php +use Utopia\Usage\Tenant; + +$tenant = new Tenant($usage, 'project_123'); +$tenant->getTotal('bandwidth'); // no tenant argument +$tenant->addBatch([ + ['metric' => 'bandwidth', 'value' => 5000, 'tags' => []], // tenant stamped automatically +], Usage::TYPE_EVENT); +``` + ## Metric Types ### Events (Additive) @@ -79,8 +108,13 @@ Event-specific columns (see `Metric::EVENT_COLUMNS`): `path`, `method`, `status` `deviceModel`. ```php -// Collect events — values accumulate in-memory buffer (summed per metric) -$usage->collect('bandwidth', 5000, Usage::TYPE_EVENT, [ +use Utopia\Usage\Accumulator; + +// Buffer metrics in memory and flush them in batch +$accumulator = new Accumulator($usage); + +// Collect events — tenant first; values accumulate in-memory (summed per tenant+metric) +$accumulator->collect('project_123', 'bandwidth', 5000, Usage::TYPE_EVENT, [ 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', @@ -114,9 +148,9 @@ Gauge-specific columns (see `Metric::GAUGE_COLUMNS`): `teamId`, `teamInternalId`, `resourceId`, `resourceInternalId`. ```php -// Collect gauges — last value wins per metric in buffer -$usage->collect('users', 1500, Usage::TYPE_GAUGE); -$usage->collect('storage.size', 1048576, Usage::TYPE_GAUGE, [ +// Collect gauges — last value wins per tenant+metric in buffer +$accumulator->collect('project_123', 'users', 1500, Usage::TYPE_GAUGE); +$accumulator->collect('project_123', 'storage.size', 1048576, Usage::TYPE_GAUGE, [ 'teamId' => 'team_x', 'teamInternalId' => '7', 'resourceId' => 'abc123', @@ -126,28 +160,28 @@ $usage->collect('storage.size', 1048576, Usage::TYPE_GAUGE, [ ### Flushing +The accumulator exposes raw signals — `count()` (buffered entries) and +`elapsedSeconds()` (seconds since last flush) — and leaves the flush policy to +the caller. + ```php -// Check if flush is recommended (threshold or interval reached) -if ($usage->shouldFlush()) { - $usage->flush(); // Writes events to events table, gauges to gauges table +// Flush when the buffer grows large or enough time has passed +if ($accumulator->count() >= 5000 || $accumulator->elapsedSeconds() >= 10) { + $accumulator->flush(); // Writes events to events table, gauges to gauges table } - -// Configure thresholds -$usage->setFlushThreshold(5000); // Flush after 5000 collect() calls (default: 10,000) -$usage->setFlushInterval(10); // Flush after 10 seconds (default: 20) ``` ### Batch Writes ```php -// Write directly without buffering +// Write directly without buffering — each row carries its tenant $usage->addBatch([ - ['metric' => 'requests', 'value' => 100, 'tags' => ['path' => '/v1/users', 'method' => 'GET']], - ['metric' => 'bandwidth', 'value' => 50000, 'tags' => ['country' => 'DE', 'region' => 'fra']], + ['tenant' => 'project_123', 'metric' => 'requests', 'value' => 100, 'tags' => ['path' => '/v1/users', 'method' => 'GET']], + ['tenant' => 'project_123', 'metric' => 'bandwidth', 'value' => 50000, 'tags' => ['country' => 'DE', 'region' => 'fra']], ], Usage::TYPE_EVENT); $usage->addBatch([ - ['metric' => 'users', 'value' => 42, 'tags' => ['teamId' => 'team_x']], + ['tenant' => 'project_123', 'metric' => 'users', 'value' => 42, 'tags' => ['teamId' => 'team_x']], ], Usage::TYPE_GAUGE); ``` @@ -158,8 +192,8 @@ $usage->addBatch([ ```php use Utopia\Query\Query; -// Find events filtered by metric and time range -$metrics = $usage->find([ +// Find events filtered by metric and time range (tenant first) +$metrics = $usage->find('project_123', [ Query::equal('metric', ['bandwidth']), Query::equal('country', ['US']), Query::greaterThanEqual('time', '2026-01-01'), @@ -168,12 +202,12 @@ $metrics = $usage->find([ ], Usage::TYPE_EVENT); // Find gauges -$gauges = $usage->find([ +$gauges = $usage->find('project_123', [ Query::equal('metric', ['users', 'storage.size']), ], Usage::TYPE_GAUGE); // Query both tables (type = null) -$all = $usage->find([ +$all = $usage->find('project_123', [ Query::equal('metric', ['bandwidth']), ]); ``` @@ -182,12 +216,13 @@ $all = $usage->find([ ```php // Get total for a single metric (SUM for events, latest for gauges) -$total = $usage->getTotal('bandwidth', [ +$total = $usage->getTotal('project_123', 'bandwidth', [ Query::greaterThanEqual('time', '2026-03-01'), ], Usage::TYPE_EVENT); // Batch totals — single query with GROUP BY $totals = $usage->getTotalBatch( + 'project_123', ['bandwidth', 'executions', 'requests'], [Query::greaterThanEqual('time', '2026-03-01')], Usage::TYPE_EVENT @@ -200,6 +235,7 @@ $totals = $usage->getTotalBatch( // Get time series with query-time aggregation // Events: SUM per bucket, Gauges: argMax per bucket $series = $usage->getTimeSeries( + tenant: 'project_123', metrics: ['bandwidth', 'requests'], interval: '1d', // '1h' or '1d' startDate: '2026-03-01', @@ -217,20 +253,21 @@ The daily materialized view pre-aggregates events by `metric + tenant + day` for ```php // Sum a single metric from the daily table -$total = $usage->sumDaily([ +$total = $usage->sumDaily('project_123', [ Query::equal('metric', ['bandwidth']), Query::between('time', '2026-03-01', '2026-04-01'), ]); // Sum multiple metrics in one query $totals = $usage->sumDailyBatch( + 'project_123', ['bandwidth', 'executions', 'storage.size'], [Query::between('time', '2026-03-01', '2026-04-01')] ); // Returns: ['bandwidth' => 5000000, 'executions' => 12345, 'storage.size' => 0] // Find daily aggregated rows -$rows = $usage->findDaily([ +$rows = $usage->findDaily('project_123', [ Query::equal('metric', ['bandwidth']), Query::orderDesc('time'), Query::limit(30), @@ -241,12 +278,12 @@ $rows = $usage->findDaily([ ```php // Purge all event metrics older than 90 days -$usage->purge([ +$usage->purge('project_123', [ Query::lessThan('time', '2026-01-01'), ], Usage::TYPE_EVENT); // Purge all gauge metrics -$usage->purge([], Usage::TYPE_GAUGE); +$usage->purge('project_123', [], Usage::TYPE_GAUGE); ``` ## Architecture @@ -321,22 +358,23 @@ $usage->purge([], Usage::TYPE_GAUGE); Extend `Utopia\Usage\Adapter` and implement: - `getName()`, `setup()`, `healthCheck()` -- `addBatch(array $metrics, string $type, int $batchSize): bool` -- `find(array $queries, ?string $type): array` -- `count(array $queries, ?string $type): int` -- `sum(array $queries, string $attribute, ?string $type): int` -- `getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries, bool $zeroFill, ?string $type): array` -- `getTotal(string $metric, array $queries, ?string $type): int` -- `getTotalBatch(array $metrics, array $queries, ?string $type): array` -- `findDaily(array $queries): array` -- `sumDaily(array $queries, string $attribute): int` -- `sumDailyBatch(array $metrics, array $queries): array` -- `purge(array $queries, ?string $type): bool` -- `setTenant(?string $tenant): self` - -Namespace, database, shared-tables mode, async inserts, query logging and the -dual-read sample rate are set once via the adapter constructor (see "Using -ClickHouse Adapter" above). Only the tenant changes over an instance's lifetime. +- `addBatch(array $metrics, string $type, int $batchSize): bool` (each metric carries its own `tenant`) +- `find(string $tenant, array $queries, ?string $type): array` +- `count(string $tenant, array $queries, ?string $type, ?int $max): int` +- `sum(string $tenant, array $queries, string $attribute, string $type): int` +- `getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries, bool $zeroFill, ?string $type): array` +- `getTotal(string $tenant, string $metric, array $queries, ?string $type): int` +- `getTotalBatch(string $tenant, array $metrics, array $queries, ?string $type): array` +- `findDaily(string $tenant, array $queries): array` +- `sumDaily(string $tenant, array $queries, string $attribute): int` +- `sumDailyBatch(string $tenant, array $metrics, array $queries): array` +- `purge(string $tenant, array $queries, ?string $type): bool` + +All configuration — namespace, database, shared-tables mode, async inserts, +query logging — is set once via the adapter constructor (see "Using ClickHouse +Adapter" above). Adapters hold no per-request state; the tenant is passed +explicitly on every call, so one instance is safe to share across tenants and +coroutines. ## System Requirements diff --git a/src/Usage/Accumulator.php b/src/Usage/Accumulator.php new file mode 100644 index 0000000..965c229 --- /dev/null +++ b/src/Usage/Accumulator.php @@ -0,0 +1,188 @@ +}> + */ + private array $buffer = []; + + /** @var float Timestamp of the last flush */ + private float $flushedAt; + + /** + * @param Usage $usage The Usage instance to flush buffered metrics to + */ + public function __construct(Usage $usage) + { + $this->usage = $usage; + $this->flushedAt = microtime(true); + } + + /** + * Collect a metric into the in-memory buffer for deferred flushing. + * + * For event type: multiple collect() calls for the same metric are summed. + * For gauge type: last-write-wins semantics. + * No period fan-out — raw timestamps are used. + * + * @param string $tenant Tenant scope (shared-tables mode) + * @param string $metric Metric name + * @param int $value Value + * @param string $type Metric type: 'event' or 'gauge' + * @param array $tags Optional tags + * @return self + */ + public function collect(string $tenant, string $metric, int $value, string $type, array $tags = []): self + { + // Compare against '' rather than empty(): the string "0" is a valid + // tenant/metric id but empty("0") is true in PHP. + if ($tenant === '') { + throw new \InvalidArgumentException('Tenant cannot be empty'); + } + if ($metric === '') { + throw new \InvalidArgumentException('Metric name cannot be empty'); + } + 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); + } + + // Hash the full identity so distinct (tenant, metric, type, tags) + // tuples never collide on the key — a raw `:`-join would let + // e.g. tenant "a"/metric "b:c" and tenant "a:b"/metric "c" share one + // entry. Tags are sorted first so key order doesn't matter. + $canonicalTags = $tags; + ksort($canonicalTags); + $key = md5(json_encode([$tenant, $metric, $type, $canonicalTags], JSON_THROW_ON_ERROR)); + + if ($type === Usage::TYPE_EVENT && isset($this->buffer[$key])) { + // Additive: sum values for the same tenant + metric + tags combination + $this->buffer[$key]['value'] += $value; + } else { + // New event entry, or gauge (last-write-wins) + $this->buffer[$key] = [ + 'tenant' => $tenant, + 'metric' => $metric, + 'value' => $value, + 'type' => $type, + 'tags' => $tags, + ]; + } + + return $this; + } + + /** + * Flush the in-memory buffer to storage. + * + * Separates buffered metrics into events and gauges, then writes each batch + * to the appropriate table via addBatch(). Only entries whose batch write + * succeeds are removed from the buffer, so a partial failure preserves + * the unwritten metrics for retry on the next flush. + * + * If addBatch() throws mid-flush, any earlier successful batches have + * already been cleared from the buffer — the exception is allowed to + * propagate so the caller can observe the failure. + * + * @return bool True if all batches succeeded (or buffer was empty) + * @throws \Exception + */ + public function flush(): bool + { + if (empty($this->buffer)) { + $this->flushedAt = microtime(true); + return true; + } + + // Separate events and gauges; keep track of buffer keys so we can + // selectively unset only the entries whose write succeeded. + $eventKeys = []; + $gaugeKeys = []; + $events = []; + $gauges = []; + + foreach ($this->buffer as $key => $entry) { + if ($entry['type'] === Usage::TYPE_EVENT) { + $events[] = $entry; + $eventKeys[] = $key; + } else { + $gauges[] = $entry; + $gaugeKeys[] = $key; + } + } + + $overallResult = true; + + // Flush events — clear buffer entries only on success. + if (!empty($events)) { + if ($this->usage->addBatch($events, Usage::TYPE_EVENT)) { + foreach ($eventKeys as $key) { + unset($this->buffer[$key]); + } + } else { + $overallResult = false; + } + } + + // Flush gauges — clear buffer entries only on success. + if (!empty($gauges)) { + if ($this->usage->addBatch($gauges, Usage::TYPE_GAUGE)) { + foreach ($gaugeKeys as $key) { + unset($this->buffer[$key]); + } + } else { + $overallResult = false; + } + } + + // Only restart the timer when nothing is left pending. On a partial + // failure the retained entries keep aging so elapsedSeconds() reflects + // how overdue they are instead of resetting to a fresh interval. + if (empty($this->buffer)) { + $this->flushedAt = microtime(true); + } + + return $overallResult; + } + + /** + * Number of unique metric entries currently buffered. + * + * @return int + */ + public function count(): int + { + return count($this->buffer); + } + + /** + * Seconds elapsed since the last flush. + * + * @return float + */ + public function elapsedSeconds(): float + { + return microtime(true) - $this->flushedAt; + } +} diff --git a/src/Usage/Adapter.php b/src/Usage/Adapter.php index 20c53ed..ddbf659 100644 --- a/src/Usage/Adapter.php +++ b/src/Usage/Adapter.php @@ -28,7 +28,10 @@ abstract public function setup(): void; * For events, path/method/status/resource/resourceId are extracted from tags * into dedicated columns; remaining tags stay in the tags JSON. * - * @param array}> $metrics + * 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 */ @@ -40,6 +43,7 @@ abstract public function addBatch(array $metrics, string $type, int $batchSize = * 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 @@ -49,7 +53,7 @@ abstract public function addBatch(array $metrics, string $type, int $batchSize = * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return array}> */ - abstract public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): 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. @@ -57,42 +61,46 @@ abstract public function getTimeSeries(array $metrics, string $interval, string * 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 $metric, array $queries = [], ?string $type = null): 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(array $metrics, array $queries = [], ?string $type = null): 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(array $queries = [], ?string $type = null): bool; + 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(array $queries = [], ?string $type = null): array; + abstract public function find(string $tenant, array $queries = [], ?string $type = null): array; /** * Count metrics using Query objects. @@ -102,12 +110,13 @@ abstract public function find(array $queries = [], ?string $type = null): array; * 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(array $queries = [], ?string $type = null, ?int $max = null): int; + abstract public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int; /** * Sum metric values using Query objects. @@ -115,12 +124,13 @@ abstract public function count(array $queries = [], ?string $type = null, ?int $ * 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(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): 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. @@ -130,10 +140,11 @@ abstract public function sum(array $queries = [], string $attribute = 'value', s * 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(array $queries = []): array; + abstract public function findDaily(string $tenant, array $queries = []): array; /** * Sum event metric values from the pre-aggregated daily table. @@ -141,11 +152,12 @@ abstract public function findDaily(array $queries = []): array; * 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(array $queries = [], string $attribute = 'value'): 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. @@ -153,9 +165,10 @@ abstract public function sumDaily(array $queries = [], string $attribute = 'valu * 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(array $metrics, array $queries = []): array; + 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 6b87e7b..9cf0cd5 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -98,8 +98,6 @@ class ClickHouse extends SQL private readonly RequestFactory $requestFactory; - protected ?string $tenant = null; - protected readonly bool $sharedTables; protected readonly string $namespace; @@ -371,23 +369,6 @@ private function escapeIdentifier(string $identifier): string return '`' . str_replace('`', '``', $identifier) . '`'; } - /** - * Set the tenant ID for multi-tenant support. - * - * Tenant is the one piece of adapter configuration that legitimately - * changes over an instance's lifetime — a single adapter serves many - * tenants across requests — so it stays a mutable setter while the rest - * of the configuration is fixed at construction. - * - * @param string|null $tenant - * @return self - */ - public function setTenant(?string $tenant): self - { - $this->tenant = $tenant; - return $this; - } - /** * Get the base table name with namespace prefix. * @@ -1258,12 +1239,17 @@ private function validateMetricsBatch(array $metrics, string $type): void $tags = $metricData['tags'] ?? []; $this->validateMetricData($metric, $value, $type, $tags, $index); - if (array_key_exists('$tenant', $metricData)) { - $tenantValue = $metricData['$tenant']; + $hasTenant = array_key_exists('tenant', $metricData); - if ($tenantValue !== null && !is_string($tenantValue)) { - throw new Exception("Metric #{$index}: '\$tenant' must be a string or null, got " . gettype($tenantValue)); - } + // Shared tables filter every read by tenant, so a row written + // without one would be invisible to normal tenant-scoped reads. + // Reject it at write time rather than silently storing dead data. + if ($this->sharedTables && (!$hasTenant || !is_string($metricData['tenant']) || $metricData['tenant'] === '')) { + throw new Exception("Metric #{$index}: 'tenant' is required (non-empty string) when shared tables are enabled"); + } + + if ($hasTenant && $metricData['tenant'] !== null && !is_string($metricData['tenant'])) { + throw new Exception("Metric #{$index}: 'tenant' must be a string or null, got " . gettype($metricData['tenant'])); } } } @@ -1341,11 +1327,7 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN */ private function resolveTenantFromMetric(array $metricData): ?string { - $tenant = array_key_exists('$tenant', $metricData) ? $metricData['$tenant'] : $this->tenant; - - if ($tenant === null) { - return null; - } + $tenant = $metricData['tenant'] ?? null; if (is_string($tenant)) { return $tenant; @@ -1367,12 +1349,12 @@ private function resolveTenantFromMetric(array $metricData): ?string * @return array * @throws Exception */ - public function find(array $queries = [], ?string $type = null): array + public function find(string $tenant, array $queries = [], ?string $type = null): array { $this->setOperationContext('find()'); if ($type !== null) { - return $this->findFromTable($queries, $type); + return $this->findFromTable($tenant, $queries, $type); } // Cursor pagination is per-table — paginating across both events and @@ -1397,10 +1379,10 @@ public function find(array $queries = [], ?string $type = null): array // 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($queries, Usage::TYPE_EVENT) + ? $this->findFromTable($tenant, $queries, Usage::TYPE_EVENT) : []; $gauges = $this->queriesMatchType($queries, Usage::TYPE_GAUGE) - ? $this->findFromTable($queries, Usage::TYPE_GAUGE) + ? $this->findFromTable($tenant, $queries, Usage::TYPE_GAUGE) : []; $merged = array_merge($events, $gauges); @@ -1457,12 +1439,12 @@ private function queriesMatchType(array $queries, string $type): bool * @return array * @throws Exception */ - private function findFromTable(array $queries, string $type): array + private function findFromTable(string $tenant, array $queries, string $type): array { $tableName = $this->getTableForType($type); $fromTable = $this->buildTableReference($tableName); - $parsed = $this->parseQueries($queries, $type); + $parsed = $this->parseQueries($tenant, $queries, $type); // Cursor pagination is incompatible with time-bucketed aggregation — // aggregated rows have no stable identity to anchor a keyset cursor on. @@ -1695,12 +1677,12 @@ private function parseAggregatedResults(string $result, string $type = 'event'): * @return int * @throws Exception */ - public function count(array $queries = [], ?string $type = null, ?int $max = null): int + public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int { $this->setOperationContext('count()'); if ($type !== null) { - return $this->countFromTable($queries, $type, $max); + return $this->countFromTable($tenant, $queries, $type, $max); } // Count from both tables. Each per-table count is independently @@ -1708,10 +1690,10 @@ public function count(array $queries = [], ?string $type = null, ?int $max = nul // 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($queries, Usage::TYPE_EVENT, $max) + ? $this->countFromTable($tenant, $queries, Usage::TYPE_EVENT, $max) : 0; $gauges = $this->queriesMatchType($queries, Usage::TYPE_GAUGE) - ? $this->countFromTable($queries, Usage::TYPE_GAUGE, $max) + ? $this->countFromTable($tenant, $queries, Usage::TYPE_GAUGE, $max) : 0; $total = $events + $gauges; @@ -1732,12 +1714,12 @@ public function count(array $queries = [], ?string $type = null, ?int $max = nul * @return int * @throws Exception */ - private function countFromTable(array $queries, string $type, ?int $max = null): int + private function countFromTable(string $tenant, array $queries, string $type, ?int $max = null): int { $tableName = $this->getTableForType($type); $fromTable = $this->buildTableReference($tableName); - $parsed = $this->parseQueries($queries, $type); + $parsed = $this->parseQueries($tenant, $queries, $type); $params = $parsed['params']; unset($params['limit'], $params['offset']); @@ -1782,15 +1764,15 @@ private function countFromTable(array $queries, string $type, ?int $max = null): * @return int * @throws Exception */ - public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int { $this->setOperationContext('sum()'); if ($type === Usage::TYPE_EVENT && $attribute === 'value') { - return $this->routedSum($queries, 'sum'); + return $this->routedSum($tenant, $queries, 'sum'); } - return $this->sumFromTable($queries, $attribute, $type); + return $this->sumFromTable($tenant, $queries, $attribute, $type); } /** @@ -1800,24 +1782,24 @@ public function sum(array $queries = [], string $attribute = 'value', string $ty * * @param array $queries */ - private function routedSum(array $queries, string $operation): int + private function routedSum(string $tenant, array $queries, string $operation): int { $plan = $this->extractRoutingPlan($queries); $route = $this->selectAggregateSource($plan); $this->recordRoute($operation, $plan, $route); if ($route === 'daily') { - $total = $this->sumDaily($this->translateInclusiveMidnightForDaily($queries), 'value'); - $this->maybeDualRead($queries, $route, $plan, $total); + $total = $this->sumDailyTotal($tenant, $this->translateInclusiveMidnightForDaily($queries), 'value'); + $this->maybeDualRead($tenant, $queries, $route, $plan, $total); return $total; } if ($route === 'hybrid') { - $total = $this->sumHybridDailyAndRaw($queries, $plan); - $this->maybeDualRead($queries, $route, $plan, $total); + $total = $this->sumHybridDailyAndRaw($tenant, $queries, $plan); + $this->maybeDualRead($tenant, $queries, $route, $plan, $total); return $total; } - return $this->sumFromTable($queries, 'value', Usage::TYPE_EVENT); + return $this->sumFromTable($tenant, $queries, 'value', Usage::TYPE_EVENT); } /** @@ -2364,7 +2346,7 @@ private function appendRouteLogEntry(array $entry): void * @param string $route * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string, orderColumns?: array, hasCursor?: bool} $plan */ - private function maybeDualRead(array $queries, string $route, array $plan, int $rolledTotal): void + private function maybeDualRead(string $tenant, array $queries, string $route, array $plan, int $rolledTotal): void { if ($this->dualReadSampleRate <= 0.0) { return; @@ -2374,7 +2356,7 @@ private function maybeDualRead(array $queries, string $route, array $plan, int $ } try { - $rawTotal = $this->sumFromTable($queries, 'value', Usage::TYPE_EVENT); + $rawTotal = $this->sumFromTable($tenant, $queries, 'value', Usage::TYPE_EVENT); } catch (Throwable $e) { return; } @@ -2406,7 +2388,7 @@ private function maybeDualRead(array $queries, string $route, array $plan, int $ * @param array $queries * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string} $plan */ - private function sumHybridDailyAndRaw(array $queries, array $plan): int + private function sumHybridDailyAndRaw(string $tenant, array $queries, array $plan): int { $startOfToday = (new DateTime('today', new DateTimeZone('UTC')))->format('Y-m-d H:i:s.v'); @@ -2414,9 +2396,9 @@ private function sumHybridDailyAndRaw(array $queries, array $plan): int $eventsTable = $this->buildTableReference($this->getEventsTableName()); $split = $this->splitTimeQueries($queries); - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); $dailyParsed = $this->prefixParsedParams( - $this->parseQueries($split['nonTime'], Usage::TYPE_EVENT), + $this->parseQueries($tenant, $split['nonTime'], Usage::TYPE_EVENT), 'd_' ); @@ -2460,7 +2442,7 @@ private function sumHybridDailyAndRaw(array $queries, array $plan): int * @return int * @throws Exception */ - private function sumFromTable(array $queries, string $attribute, string $type): int + private function sumFromTable(string $tenant, array $queries, string $attribute, string $type): int { $tableName = $this->getTableForType($type); $fromTable = $this->buildTableReference($tableName); @@ -2468,7 +2450,7 @@ private function sumFromTable(array $queries, string $attribute, string $type): $this->validateAttributeName($attribute, $type); $escapedAttribute = $this->escapeIdentifier($attribute); - $parsed = $this->parseQueries($queries, $type); + $parsed = $this->parseQueries($tenant, $queries, $type); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $whereClause = $whereData['clause']; @@ -2497,7 +2479,7 @@ private function sumFromTable(array $queries, string $attribute, string $type): * @return array * @throws Exception */ - public function findDaily(array $queries = []): array + public function findDaily(string $tenant, array $queries = []): array { $this->setOperationContext('findDaily()'); @@ -2509,7 +2491,7 @@ public function findDaily(array $queries = []): array $this->validateDailyAttributeName($attr); } } - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $groupByColumns = $this->sharedTables ? ['tenant'] : []; @@ -2545,10 +2527,22 @@ public function findDaily(array $queries = []): array * @return int * @throws Exception */ - public function sumDaily(array $queries = [], string $attribute = 'value'): int + public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int { $this->setOperationContext('sumDaily()'); + return $this->sumDailyTotal($tenant, $queries, $attribute); + } + + /** + * Sum the daily table. Split out from sumDaily() so internal callers + * (e.g. routedSum) can reuse it without re-setting the operation context. + * + * @param array $queries + * @throws Exception + */ + private function sumDailyTotal(string $tenant, array $queries, string $attribute = 'value'): int + { $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); $this->validateDailyAttributeName($attribute); $escapedAttribute = $this->escapeIdentifier($attribute); @@ -2559,7 +2553,7 @@ public function sumDaily(array $queries = [], string $attribute = 'value'): int $this->validateDailyAttributeName($attr); } } - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $sql = "SELECT sum({$escapedAttribute}) as total FROM {$fromTable}{$whereData['clause']} FORMAT JSON"; @@ -2578,7 +2572,7 @@ public function sumDaily(array $queries = [], string $attribute = 'value'): int * @return array * @throws Exception */ - public function sumDailyBatch(array $metrics, array $queries = []): array + public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array { if (empty($metrics)) { return []; @@ -2607,7 +2601,7 @@ public function sumDailyBatch(array $metrics, array $queries = []): array } $metricInClause = implode(', ', $metricPlaceholders); - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); $params = array_merge($metricParams, $parsed['params']); $whereData = $this->buildWhereClause($parsed['filters'], $params); @@ -2655,7 +2649,7 @@ public function sumDailyBatch(array $metrics, array $queries = []): array * @return array}> * @throws Exception */ - public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array { if (empty($metrics)) { return []; @@ -2689,7 +2683,7 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat continue; } - $typeResult = $this->getTimeSeriesFromTable($metrics, $interval, $startDate, $endDate, $queries, $queryType); + $typeResult = $this->getTimeSeriesFromTable($tenant, $metrics, $interval, $startDate, $endDate, $queries, $queryType); // Merge results foreach ($typeResult as $metricName => $metricData) { @@ -2733,7 +2727,7 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat * @return array}> * @throws Exception */ - private function getTimeSeriesFromTable(array $metrics, string $interval, string $startDate, string $endDate, array $queries, string $type): array + private function getTimeSeriesFromTable(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries, string $type): array { $timeFunction = self::INTERVAL_FUNCTIONS[$interval]; $tableName = $this->getTableForType($type); @@ -2750,21 +2744,15 @@ private function getTimeSeriesFromTable(array $metrics, string $interval, string $metricInClause = implode(', ', $metricPlaceholders); - // Build additional WHERE conditions from queries - $parsed = $this->parseQueries($queries, $type); + // Build additional WHERE conditions from queries (tenant baked in) + $parsed = $this->parseQueries($tenant, $queries, $type); $additionalFilters = $parsed['filters']; $params = array_merge($metricParams, $parsed['params']); $params['start_date'] = $this->formatDateTime($startDate); $params['end_date'] = $this->formatDateTime($endDate); - // Build tenant filter - $tenantFilter = ''; - if ($this->sharedTables && $this->tenant !== null) { - $tenantFilter = ' AND tenant = {tenant:Nullable(String)}'; - $params['tenant'] = $this->tenant; - } - + // Tenant scoping is already folded into $additionalFilters by parseQueries(). $additionalWhere = ''; if (!empty($additionalFilters)) { $additionalWhere = ' AND ' . implode(' AND ', $additionalFilters); @@ -2782,7 +2770,7 @@ private function getTimeSeriesFromTable(array $metrics, string $interval, string FROM {$fromTable} WHERE metric IN ({$metricInClause}) AND time BETWEEN {start_date:DateTime64(3, 'UTC')} AND {end_date:DateTime64(3, 'UTC')} - {$tenantFilter}{$additionalWhere} + {$additionalWhere} GROUP BY metric, bucket ORDER BY bucket ASC FORMAT JSON @@ -2876,21 +2864,21 @@ private function zeroFillTimeSeries(array $data, string $interval, string $start * @return int * @throws Exception */ - public function getTotal(string $metric, array $queries = [], ?string $type = null): int + public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int { $this->setOperationContext('getTotal()'); if ($type === Usage::TYPE_EVENT) { - return $this->getTotalFromEvents($metric, $queries); + return $this->getTotalFromEvents($tenant, $metric, $queries); } if ($type === Usage::TYPE_GAUGE) { - return $this->getTotalFromGauges($metric, $queries); + return $this->getTotalFromGauges($tenant, $metric, $queries); } // Query both tables — event uses SUM, gauge uses argMax - $eventTotal = $this->getTotalFromEvents($metric, $queries); - $gaugeTotal = $this->getTotalFromGauges($metric, $queries); + $eventTotal = $this->getTotalFromEvents($tenant, $metric, $queries); + $gaugeTotal = $this->getTotalFromGauges($tenant, $metric, $queries); if ($eventTotal > 0 && $gaugeTotal > 0) { throw new Exception( @@ -2911,10 +2899,10 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu * @return int * @throws Exception */ - private function getTotalFromEvents(string $metric, array $queries): int + private function getTotalFromEvents(string $tenant, string $metric, array $queries): int { $queries[] = Query::equal('metric', [$metric]); - return $this->routedSum($queries, 'getTotal'); + return $this->routedSum($tenant, $queries, 'getTotal'); } /** @@ -2926,7 +2914,7 @@ private function getTotalFromEvents(string $metric, array $queries): int * @return int * @throws Exception */ - private function getTotalFromGauges(string $metric, array $queries): int + private function getTotalFromGauges(string $tenant, string $metric, array $queries): int { $queries[] = Query::equal('metric', [$metric]); @@ -2936,7 +2924,7 @@ private function getTotalFromGauges(string $metric, array $queries): int $tableName = $this->getGaugesTableName(); $fromTable = $this->buildTableReference($tableName); - $parsed = $this->parseQueries($queries, Usage::TYPE_GAUGE); + $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_GAUGE); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $sql = " @@ -2970,7 +2958,7 @@ private function getTotalFromGauges(string $metric, array $queries): int * @return array * @throws Exception */ - public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array { if (empty($metrics)) { return []; @@ -3006,7 +2994,7 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type } $metricInClause = implode(', ', $metricPlaceholders); - $parsed = $this->parseQueries($queries, $queryType); + $parsed = $this->parseQueries($tenant, $queries, $queryType); $params = array_merge($metricParams, $parsed['params']); $whereData = $this->buildWhereClause($parsed['filters'], $params); @@ -3064,31 +3052,23 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type } /** - * Build WHERE clause from filters with optional tenant filtering. + * Build a WHERE clause from already-parsed filters. + * + * Tenant scoping is baked into parseQueries(), so by the time filters + * reach here they are already tenant-scoped — there is no separate step + * to forget. * * @param array $filters * @param array $params - * @param bool $includeTenant * @return array{clause: string, params: array} */ - private function buildWhereClause(array $filters, array $params = [], bool $includeTenant = true): array + private function buildWhereClause(array $filters, array $params = []): array { - $conditions = $filters; - $whereParams = $params; - - if ($includeTenant) { - $tenantFilter = $this->getTenantFilter(); - if ($tenantFilter) { - $conditions[] = $tenantFilter; - $whereParams['tenant'] = $this->tenant; - } - } - - $clause = !empty($conditions) ? ' WHERE ' . implode(' AND ', $conditions) : ''; + $clause = !empty($filters) ? ' WHERE ' . implode(' AND ', $filters) : ''; return [ 'clause' => $clause, - 'params' => $whereParams + 'params' => $params ]; } @@ -3304,13 +3284,30 @@ private function buildOrderBySql(array $orderAttributes, bool $flip = false): ar /** * Parse Query objects into SQL clauses. * + * Tenant scoping is baked in here rather than left to callers: in + * shared-tables mode a `tenant = …` filter is prepended before parsing, + * so there is no way to produce a WHERE clause that isn't tenant-scoped. + * Every read/delete path funnels through this method, which is why it + * takes the tenant as a required first argument. + * + * @param string $tenant Tenant scope (shared-tables mode) * @param array $queries * @param string $type 'event' or 'gauge' — used for attribute validation * @return array{filters: array, params: array, orderBy?: array, orderAttributes?: array, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array, cursor?: array, cursorDirection?: string} * @throws Exception */ - private function parseQueries(array $queries, string $type = 'event'): array + private function parseQueries(string $tenant, array $queries, string $type = 'event'): array { + if ($this->sharedTables) { + // An empty tenant would compile to `tenant = ''` and silently read + // an empty scope. Fail fast instead, like the write side. ("0" is + // a valid tenant id, so check for '' specifically.) + if ($tenant === '') { + throw new Exception('Tenant cannot be empty in shared-tables mode'); + } + array_unshift($queries, Query::equal('tenant', [$tenant])); + } + $filters = []; $params = []; $orderBy = []; @@ -3679,20 +3676,6 @@ private function getSelectColumns(string $type = 'event'): string return implode(', ', $columns); } - /** - * Build tenant filter clause. - * - * @return string - */ - private function getTenantFilter(): string - { - if (!$this->sharedTables || $this->tenant === null) { - return ''; - } - - return "tenant = {tenant:Nullable(String)}"; - } - /** * Purge usage metrics matching the given queries. * Deletes from the specified table(s). @@ -3708,7 +3691,7 @@ private function getTenantFilter(): string * @param string|null $type 'event', 'gauge', or null (purge both) * @throws Exception */ - public function purge(array $queries = [], ?string $type = null): bool + public function purge(string $tenant, array $queries = [], ?string $type = null): bool { $this->setOperationContext('purge()'); @@ -3724,7 +3707,7 @@ public function purge(array $queries = [], ?string $type = null): bool $tableName = $this->getTableForType($purgeType); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - $parsed = $this->parseQueries($queries, $purgeType); + $parsed = $this->parseQueries($tenant, $queries, $purgeType); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $whereClause = $whereData['clause']; $params = $whereData['params']; @@ -3737,7 +3720,7 @@ public function purge(array $queries = [], ?string $type = null): bool $this->query($sql, $params); if ($purgeType === Usage::TYPE_EVENT) { - $this->purgeDaily($queries); + $this->purgeDaily($tenant, $queries); } } @@ -3789,10 +3772,11 @@ private function buildStaleRollupPurgeQueries(array $queries, array $safeAttribu * unrelated metrics. The next ingest cycle will overwrite any rows * the caller's raw-table purge left stale. * - * @param array $queries + * @param array $queries The caller's filters (tenant-free); the + * tenant is applied as a scope on the resulting delete. * @throws Exception */ - private function purgeDaily(array $queries): void + private function purgeDaily(string $tenant, array $queries): void { $safeAttributes = array_values(array_filter( self::DAILY_COLUMNS, @@ -3823,9 +3807,13 @@ private function purgeDaily(array $queries): void return; } + // parseQueries() folds the tenant into the WHERE. The compatibility + // decision above deliberately runs on the caller's tenant-free filters, + // so the tenant scope can never make an imprecise purge look safe to + // forward to the rollup. $dailyTable = $this->buildTableReference($this->getEventsDailyTableName()); - $parsed = $this->parseQueries($dailyQueries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $dailyQueries, Usage::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 b008be6..4d25bdb 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -122,7 +122,10 @@ protected function getColumnDefinition(string $id, string $type = 'event'): stri * Database adapter uses a single collection for both types. The $type parameter * is stored as a field in each document for query-time differentiation. * - * @param array}> $metrics + * 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 * @return bool @@ -131,7 +134,7 @@ protected function getColumnDefinition(string $id, string $type = 'event'): stri public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool { $this->db->getAuthorization()->skip(function () use ($metrics, $type, $batchSize) { - $documents = []; + $entries = []; foreach ($metrics as $metric) { if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) { throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: event, gauge"); @@ -155,12 +158,13 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b 'time' => (new \DateTime())->format('Y-m-d H:i:s.v'), ], $columns); - $documents[] = new Document($docData); + $entries[] = ['tenant' => $metric['tenant'], 'doc' => new Document($docData)]; } - foreach (array_chunk($documents, max(1, $batchSize)) as $chunk) { - foreach ($chunk as $doc) { - $this->db->createDocument($this->collection, $doc); + foreach (array_chunk($entries, max(1, $batchSize)) as $chunk) { + foreach ($chunk as $entry) { + $this->setDbTenant($entry['tenant']); + $this->db->createDocument($this->collection, $entry['doc']); } } }); @@ -173,6 +177,7 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b * * Stub implementation for Database adapter. * + * @param string $tenant * @param array $metrics * @param string $interval * @param string $startDate @@ -182,7 +187,7 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b * @param string|null $type * @return array}> */ - public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array { // Stub: Database adapter time series not yet implemented $output = []; @@ -197,12 +202,13 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat * * Returns SUM for event metrics, latest value for gauge metrics. * + * @param string $tenant * @param string $metric * @param array $queries * @param string|null $type * @return int */ - public function getTotal(string $metric, array $queries = [], ?string $type = null): int + public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int { $allQueries = array_merge($queries, [ Query::equal('metric', [$metric]), @@ -217,7 +223,7 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu Query::limit(1), ]); /** @var array $gaugeResults */ - $gaugeResults = $this->find($gaugeQueries, $type); + $gaugeResults = $this->find($tenant, $gaugeQueries, $type); if (empty($gaugeResults)) { return 0; } @@ -225,7 +231,7 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu } /** @var array $results */ - $results = $this->find($allQueries, $type); + $results = $this->find($tenant, $allQueries, $type); if (empty($results)) { return 0; @@ -280,12 +286,13 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu /** * Get totals for multiple metrics. * + * @param string $tenant * @param array $metrics * @param array $queries * @param string|null $type * @return array */ - public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array { if (empty($metrics)) { return []; @@ -294,7 +301,7 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type $totals = \array_fill_keys($metrics, 0); foreach ($metrics as $metric) { - $totals[$metric] = $this->getTotal($metric, $queries, $type); + $totals[$metric] = $this->getTotal($tenant, $metric, $queries, $type); } return $totals; @@ -305,15 +312,16 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type * * Events-only by default — summing gauges is semantically meaningless. * + * @param string $tenant * @param array $queries * @param string $attribute * @param string $type 'event' or 'gauge' * @return int */ - public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int { /** @var array $results */ - $results = $this->find($queries, $type); + $results = $this->find($tenant, $queries, $type); $sum = 0; foreach ($results as $result) { @@ -326,26 +334,28 @@ public function sum(array $queries = [], string $attribute = 'value', string $ty /** * Find from daily table — Database adapter falls back to regular find for events. * + * @param string $tenant * @param array $queries * @return array */ - public function findDaily(array $queries = []): array + public function findDaily(string $tenant, array $queries = []): array { - return $this->find($queries, Usage::TYPE_EVENT); + return $this->find($tenant, $queries, Usage::TYPE_EVENT); } /** * Sum multiple metrics from daily table — falls back to individual sumDaily calls. * + * @param string $tenant * @param array<\Utopia\Query\Query> $queries * @return array */ - public function sumDailyBatch(array $metrics, array $queries = []): array + public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array { $totals = \array_fill_keys($metrics, 0); foreach ($metrics as $metric) { $metricQueries = array_merge($queries, [Query::equal('metric', [$metric])]); - $totals[$metric] = $this->sumDaily($metricQueries); + $totals[$metric] = $this->sumDaily($tenant, $metricQueries); } return $totals; } @@ -353,13 +363,14 @@ public function sumDailyBatch(array $metrics, array $queries = []): array /** * Sum from daily table — Database adapter falls back to regular sum for events. * + * @param string $tenant * @param array $queries * @param string $attribute * @return int */ - public function sumDaily(array $queries = [], string $attribute = 'value'): int + public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int { - return $this->sum($queries, $attribute, Usage::TYPE_EVENT); + return $this->sum($tenant, $queries, $attribute, Usage::TYPE_EVENT); } /** @@ -520,11 +531,13 @@ private function validateGroupByQueries(array $queries): void } /** + * @param string $tenant * @param array $queries * @param string|null $type */ - public function purge(array $queries = [], ?string $type = null): bool + public function purge(string $tenant, array $queries = [], ?string $type = null): bool { + $this->setDbTenant($tenant); $queries = $this->withTypeFilter($queries, $type); $this->db->getAuthorization()->skip(function () use ($queries) { @@ -553,12 +566,14 @@ public function purge(array $queries = [], ?string $type = null): bool * so callers can isolate event vs gauge rows. When $type is null both * are returned (caller distinguishes via Metric::getType()). * + * @param string $tenant * @param array $queries * @param string|null $type * @return array */ - public function find(array $queries = [], ?string $type = null): array + public function find(string $tenant, array $queries = [], ?string $type = null): array { + $this->setDbTenant($tenant); $queries = $this->withTypeFilter($queries, $type); /** @var array $result */ @@ -580,13 +595,15 @@ public function find(array $queries = [], ?string $type = null): array * utopia-php/database accepts a `$max` argument that pushes the cap * down into the underlying SQL. * + * @param string $tenant * @param array $queries * @param string|null $type * @param int|null $max Optional upper bound (inclusive) for the count * @return int */ - public function count(array $queries = [], ?string $type = null, ?int $max = null): int + public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int { + $this->setDbTenant($tenant); $queries = $this->withTypeFilter($queries, $type); /** @var int $count */ @@ -623,17 +640,18 @@ private function withTypeFilter(array $queries, ?string $type): array } /** - * Set the tenant ID for multi-tenant support. + * Scope the underlying Utopia\Database instance to a tenant for the next + * operation. The Database adapter requires a numeric tenant ID. * - * Tenant is the one piece of configuration that changes over an - * instance's lifetime (a single adapter serves many tenants across - * requests), so it stays a mutable setter. Namespace and shared-tables - * mode are configured directly on the injected Utopia\Database instance. + * The adapter takes ownership of the injected db's tenant: every call + * sets it and none restores it (Utopia\Database has no per-call tenant — + * only the mutable instance tenant — and save/restore around each call is + * its own footgun). Hand this adapter a db dedicated to usage, not one + * whose tenant other code depends on between calls. * * @param string|null $tenant - * @return self */ - public function setTenant(?string $tenant): self + private function setDbTenant(?string $tenant): void { if ($tenant !== null && !is_numeric($tenant)) { throw new \InvalidArgumentException( @@ -642,6 +660,5 @@ public function setTenant(?string $tenant): self } $this->db->setTenant($tenant !== null ? (int) $tenant : null); - return $this; } } diff --git a/src/Usage/Tenant.php b/src/Usage/Tenant.php new file mode 100644 index 0000000..fe5a6f6 --- /dev/null +++ b/src/Usage/Tenant.php @@ -0,0 +1,150 @@ +usage = $usage; + $this->tenant = $tenant; + } + + /** + * Add metrics in batch, stamping this view's tenant onto each entry. + * + * @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 + */ + public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool + { + foreach ($metrics as &$metric) { + $metric['tenant'] = $this->tenant; + } + unset($metric); + + return $this->usage->addBatch($metrics, $type, $batchSize); + } + + /** + * @param array $metrics + * @param array<\Utopia\Query\Query> $queries + * @return array}> + * @throws \Exception + */ + public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + { + return $this->usage->getTimeSeries($this->tenant, $metrics, $interval, $startDate, $endDate, $queries, $zeroFill, $type); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @throws \Exception + */ + public function getTotal(string $metric, array $queries = [], ?string $type = null): int + { + return $this->usage->getTotal($this->tenant, $metric, $queries, $type); + } + + /** + * @param array $metrics + * @param array<\Utopia\Query\Query> $queries + * @return array + * @throws \Exception + */ + public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + { + return $this->usage->getTotalBatch($this->tenant, $metrics, $queries, $type); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @throws \Exception + */ + public function purge(array $queries = [], ?string $type = null): bool + { + return $this->usage->purge($this->tenant, $queries, $type); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @return array + * @throws \Exception + */ + public function find(array $queries = [], ?string $type = null): array + { + return $this->usage->find($this->tenant, $queries, $type); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @throws \Exception + */ + public function count(array $queries = [], ?string $type = null, ?int $max = null): int + { + return $this->usage->count($this->tenant, $queries, $type, $max); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @throws \Exception + */ + public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + { + return $this->usage->sum($this->tenant, $queries, $attribute, $type); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @return array + * @throws \Exception + */ + public function findDaily(array $queries = []): array + { + return $this->usage->findDaily($this->tenant, $queries); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @throws \Exception + */ + public function sumDaily(array $queries = [], string $attribute = 'value'): int + { + return $this->usage->sumDaily($this->tenant, $queries, $attribute); + } + + /** + * @param array $metrics + * @param array<\Utopia\Query\Query> $queries + * @return array + * @throws \Exception + */ + public function sumDailyBatch(array $metrics, array $queries = []): array + { + return $this->usage->sumDailyBatch($this->tenant, $metrics, $queries); + } +} diff --git a/src/Usage/Usage.php b/src/Usage/Usage.php index 3097de1..7897158 100644 --- a/src/Usage/Usage.php +++ b/src/Usage/Usage.php @@ -17,31 +17,8 @@ class Usage public const TYPE_EVENT = 'event'; public const TYPE_GAUGE = 'gauge'; - private const DEFAULT_FLUSH_THRESHOLD = 10_000; - private const DEFAULT_FLUSH_INTERVAL = 20; - private Adapter $adapter; - /** - * In-memory buffer for metrics. - * Keyed by "{metric}:{type}" — events are summed, gauges use last-write-wins. - * - * @var array}> - */ - private array $buffer = []; - - /** @var int Number of collect() calls since last flush */ - private int $bufferCount = 0; - - /** @var int Flush when buffer reaches this many entries */ - private int $flushThreshold = self::DEFAULT_FLUSH_THRESHOLD; - - /** @var int Flush when this many seconds have elapsed since last flush */ - private int $flushInterval = self::DEFAULT_FLUSH_INTERVAL; - - /** @var float Timestamp of the last flush */ - private float $lastFlushTime; - /** * Constructor. * @@ -50,7 +27,6 @@ class Usage public function __construct(Adapter $adapter) { $this->adapter = $adapter; - $this->lastFlushTime = microtime(true); } /** @@ -87,7 +63,10 @@ public function setup(): void * Callers must explicitly pass the metric type so event and gauge * writes are never confused at the call site. * - * @param array}> $metrics + * 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 @@ -101,6 +80,7 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b /** * 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 @@ -111,63 +91,67 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b * @return array}> * @throws \Exception */ - public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + 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($metrics, $interval, $startDate, $endDate, $queries, $zeroFill, $type); + return $this->adapter->getTimeSeries($tenant, $metrics, $interval, $startDate, $endDate, $queries, $zeroFill, $type); } /** * 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) * @return int * @throws \Exception */ - public function getTotal(string $metric, array $queries = [], ?string $type = null): int + public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int { - return $this->adapter->getTotal($metric, $queries, $type); + return $this->adapter->getTotal($tenant, $metric, $queries, $type); } /** * 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) * @return array * @throws \Exception */ - public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array { - return $this->adapter->getTotalBatch($metrics, $queries, $type); + return $this->adapter->getTotalBatch($tenant, $metrics, $queries, $type); } /** * 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) * @throws \Exception */ - public function purge(array $queries = [], ?string $type = null): bool + public function purge(string $tenant, array $queries = [], ?string $type = null): bool { - return $this->adapter->purge($queries, $type); + return $this->adapter->purge($tenant, $queries, $type); } /** * 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 * @throws \Exception */ - public function find(array $queries = [], ?string $type = null): array + public function find(string $tenant, array $queries = [], ?string $type = null): array { - return $this->adapter->find($queries, $type); + return $this->adapter->find($tenant, $queries, $type); } /** @@ -177,15 +161,16 @@ public function find(array $queries = [], ?string $type = null): array * 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. * + * @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(array $queries = [], ?string $type = null, ?int $max = null): int + public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int { - return $this->adapter->count($queries, $type, $max); + return $this->adapter->count($tenant, $queries, $type, $max); } /** @@ -196,19 +181,20 @@ public function count(array $queries = [], ?string $type = null, ?int $max = nul * than producing a useful total. Callers that truly want a gauge sum * must opt in explicitly. * + * @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(array $queries = [], string $attribute = 'value', string $type = self::TYPE_EVENT): int + 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($queries, $attribute, $type); + return $this->adapter->sum($tenant, $queries, $attribute, $type); } /** @@ -219,13 +205,14 @@ public function sum(array $queries = [], string $attribute = 'value', string $ty * 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 * @return array * @throws \Exception */ - public function findDaily(array $queries = []): array + public function findDaily(string $tenant, array $queries = []): array { - return $this->adapter->findDaily($queries); + return $this->adapter->findDaily($tenant, $queries); } /** @@ -237,14 +224,15 @@ public function findDaily(array $queries = []): array * 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 * @throws \Exception */ - public function sumDaily(array $queries = [], string $attribute = 'value'): int + public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int { - return $this->adapter->sumDaily($queries, $attribute); + return $this->adapter->sumDaily($tenant, $queries, $attribute); } /** @@ -253,249 +241,14 @@ public function sumDaily(array $queries = [], string $attribute = 'value'): int * 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 * @throws \Exception */ - public function sumDailyBatch(array $metrics, array $queries = []): array - { - return $this->adapter->sumDailyBatch($metrics, $queries); - } - - /** - * Set the tenant ID for multi-tenant support. - * - * Flushes the buffer first so any pending metrics are written under the - * previous tenant — buffered entries don't carry adapter context. - * - * @param string|null $tenant - * @return $this - * @throws \Exception - */ - public function setTenant(?string $tenant): self - { - $this->flush(); - if (method_exists($this->adapter, 'setTenant')) { - $this->adapter->setTenant($tenant); - } - return $this; - } - - /** - * Collect a metric into the in-memory buffer for deferred flushing. - * - * For event type: multiple collect() calls for the same metric are summed. - * For gauge type: last-write-wins semantics. - * No period fan-out — raw timestamps are used. - * - * @param string $metric Metric name - * @param int $value Value - * @param string $type Metric type: 'event' or 'gauge' - * @param array $tags Optional tags - * @return self - */ - public function collect(string $metric, int $value, string $type, array $tags = []): self - { - if (empty($metric)) { - throw new \InvalidArgumentException('Metric name cannot be empty'); - } - if ($value < 0) { - throw new \InvalidArgumentException('Value cannot be negative'); - } - if ($type !== self::TYPE_EVENT && $type !== self::TYPE_GAUGE) { - throw new \InvalidArgumentException("Invalid metric type '{$type}'. Allowed: " . self::TYPE_EVENT . ', ' . self::TYPE_GAUGE); - } - - $tagsHash = !empty($tags) ? md5(json_encode($tags, JSON_THROW_ON_ERROR)) : ''; - $key = $metric . ':' . $type . ':' . $tagsHash; - - if ($type === self::TYPE_EVENT) { - // Additive: sum values for the same metric + tags combination - if (isset($this->buffer[$key])) { - $this->buffer[$key]['value'] += $value; - } else { - $this->buffer[$key] = [ - 'metric' => $metric, - 'value' => $value, - 'type' => $type, - 'tags' => $tags, - ]; - } - } else { - // Gauge: last-write-wins - $this->buffer[$key] = [ - 'metric' => $metric, - 'value' => $value, - 'type' => $type, - 'tags' => $tags, - ]; - } - - $this->bufferCount++; - - return $this; - } - - /** - * Flush the in-memory buffer to storage. - * - * Separates buffered metrics into events and gauges, then writes each batch - * to the appropriate table via addBatch(). Only entries whose batch write - * succeeds are removed from the buffer, so a partial failure preserves - * the unwritten metrics for retry on the next flush. - * - * If addBatch() throws mid-flush, any earlier successful batches have - * already been cleared from the buffer — the exception is allowed to - * propagate so the caller can observe the failure. - * - * @return bool True if all batches succeeded (or buffer was empty) - * @throws \Exception - */ - public function flush(): bool - { - if (empty($this->buffer)) { - $this->lastFlushTime = microtime(true); - return true; - } - - // Separate events and gauges; keep track of buffer keys so we can - // selectively unset only the entries whose write succeeded. - $eventKeys = []; - $gaugeKeys = []; - $events = []; - $gauges = []; - - foreach ($this->buffer as $key => $entry) { - if ($entry['type'] === self::TYPE_EVENT) { - $events[] = $entry; - $eventKeys[] = $key; - } else { - $gauges[] = $entry; - $gaugeKeys[] = $key; - } - } - - $overallResult = true; - - // Flush events — clear buffer entries only on success. - if (!empty($events)) { - if ($this->adapter->addBatch($events, self::TYPE_EVENT)) { - foreach ($eventKeys as $key) { - unset($this->buffer[$key]); - } - } else { - $overallResult = false; - } - } - - // Flush gauges — clear buffer entries only on success. - if (!empty($gauges)) { - if ($this->adapter->addBatch($gauges, self::TYPE_GAUGE)) { - foreach ($gaugeKeys as $key) { - unset($this->buffer[$key]); - } - } else { - $overallResult = false; - } - } - - $this->bufferCount = count($this->buffer); - $this->lastFlushTime = microtime(true); - - return $overallResult; - } - - /** - * Check if the buffer should be flushed based on thresholds. - * - * Returns true if either: - * - The number of collect() calls meets the flush threshold - * - The time since last flush exceeds the flush interval - * - * @return bool - */ - public function shouldFlush(): bool - { - if ($this->bufferCount >= $this->flushThreshold) { - return true; - } - - $elapsed = microtime(true) - $this->lastFlushTime; - if ($elapsed >= $this->flushInterval) { - return true; - } - - return false; - } - - /** - * Get the number of collect() calls since the last flush. - * - * @return int - */ - public function getBufferCount(): int - { - return $this->bufferCount; - } - - /** - * Get the number of unique metric entries in the buffer. - * - * @return int - */ - public function getBufferSize(): int - { - return count($this->buffer); - } - - /** - * Set the flush threshold (number of collect() calls before flush is recommended). - * - * @param int $threshold Must be >= 1 - * @return self - */ - public function setFlushThreshold(int $threshold): self - { - if ($threshold < 1) { - throw new \InvalidArgumentException('Flush threshold must be at least 1'); - } - $this->flushThreshold = $threshold; - return $this; - } - - /** - * Set the flush interval in seconds. - * - * @param int $seconds Must be >= 1 - * @return self - */ - public function setFlushInterval(int $seconds): self - { - if ($seconds < 1) { - throw new \InvalidArgumentException('Flush interval must be at least 1 second'); - } - $this->flushInterval = $seconds; - return $this; - } - - /** - * Get the flush threshold. - * - * @return int - */ - public function getFlushThreshold(): int - { - return $this->flushThreshold; - } - - /** - * Get the flush interval in seconds. - * - * @return int - */ - public function getFlushInterval(): int + public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array { - return $this->flushInterval; + return $this->adapter->sumDailyBatch($tenant, $metrics, $queries); } } diff --git a/tests/Benchmark/BenchmarkBase.php b/tests/Benchmark/BenchmarkBase.php index c86acce..5500431 100644 --- a/tests/Benchmark/BenchmarkBase.php +++ b/tests/Benchmark/BenchmarkBase.php @@ -65,7 +65,6 @@ protected function setUp(): void database: getenv('CLICKHOUSE_DATABASE') ?: 'default', sharedTables: true, ); - $this->adapter->setTenant($this->tenant); $this->usage = new Usage($this->adapter); $this->usage->setup(); @@ -171,7 +170,7 @@ protected function runRawSql(string $sql): void */ protected function purgeAll(): void { - $this->usage->purge(); + $this->usage->purge($this->tenant); } /** diff --git a/tests/Benchmark/EventsBench.php b/tests/Benchmark/EventsBench.php index 62c9dec..1e4c1e3 100644 --- a/tests/Benchmark/EventsBench.php +++ b/tests/Benchmark/EventsBench.php @@ -25,7 +25,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_sum_30d', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->sum([ + $this->usage->sum($this->tenant, [ Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -34,7 +34,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_timeseries_30d_1h', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupByInterval('time', '1h'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), @@ -45,7 +45,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_count_max_5k', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->count([ + $this->usage->count($this->tenant, [ Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -56,6 +56,7 @@ public function testBenchmarks(): void $batch = []; for ($i = 0; $i < 10000; $i++) { $batch[] = [ + 'tenant' => $this->tenant, 'metric' => 'bench.insert.10k', 'value' => $i, 'tags' => [ @@ -72,7 +73,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_topN_path_30d', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), @@ -83,7 +84,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_topN_country_30d', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('country'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), @@ -94,7 +95,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_topN_service_30d', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), @@ -107,7 +108,7 @@ public function testBenchmarks(): void $todayEnd = (new DateTime('+2 hour'))->format('Y-m-d H:i:s'); $this->runBench('bench_events_topN_path_today_partial', function (string $queryId) use ($todayStart, $todayEnd): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $todayStart), @@ -118,7 +119,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_topN_path_30d_filtered_resource', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), Query::equal('resource', ['function']), @@ -130,7 +131,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_topN_path_country', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('path'), UsageQuery::groupBy('country'), Query::equal('metric', [$this->metric]), @@ -144,6 +145,7 @@ public function testBenchmarks(): void $batch = []; for ($i = 0; $i < 10000; $i++) { $batch[] = [ + 'tenant' => $this->tenant, 'metric' => 'bench.insert.with_projections', 'value' => $i, 'tags' => [ diff --git a/tests/Benchmark/GaugesBench.php b/tests/Benchmark/GaugesBench.php index 94cc4b0..f84eee2 100644 --- a/tests/Benchmark/GaugesBench.php +++ b/tests/Benchmark/GaugesBench.php @@ -28,6 +28,7 @@ public function testBenchmarks(): void $this->runBench('bench_gauges_latest_in_window', function (string $queryId) use ($start30d, $endPartial): void { $this->adapter->setNextQueryId($queryId); $this->usage->getTotal( + $this->tenant, $this->metric, [ Query::greaterThanEqual('time', $start30d), @@ -39,7 +40,7 @@ public function testBenchmarks(): void $this->runBench('bench_gauges_topN_service_30d', function (string $queryId) use ($start30d, $endClosed): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start30d), @@ -50,7 +51,7 @@ public function testBenchmarks(): void $this->runBench('bench_gauges_topN_resource_30d', function (string $queryId) use ($start30d, $endClosed): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('resource'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start30d), @@ -61,7 +62,7 @@ public function testBenchmarks(): void $this->runBench('bench_gauges_topN_service_today_partial', function (string $queryId) use ($start30d, $endPartial): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start30d), diff --git a/tests/Usage/AccumulatorTest.php b/tests/Usage/AccumulatorTest.php new file mode 100644 index 0000000..f63808a --- /dev/null +++ b/tests/Usage/AccumulatorTest.php @@ -0,0 +1,290 @@ +>, type: string}> */ + public array $batches = []; + + public bool $succeed = true; + + public function getName(): string + { + return 'recording'; + } + + /** + * @return array + */ + public function healthCheck(): array + { + return ['healthy' => true]; + } + + public function setup(): void + { + } + + /** + * @param array> $metrics + */ + public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool + { + if ($this->succeed) { + $this->batches[] = ['metrics' => $metrics, 'type' => $type]; + } + return $this->succeed; + } + + /** + * @return array + */ + public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + { + return []; + } + + public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int + { + return 0; + } + + /** + * @return array + */ + public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array + { + return []; + } + + public function purge(string $tenant, array $queries = [], ?string $type = null): bool + { + return true; + } + + /** + * @return array + */ + public function find(string $tenant, array $queries = [], ?string $type = null): array + { + return []; + } + + public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int + { + return 0; + } + + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + { + return 0; + } + + /** + * @return array + */ + public function findDaily(string $tenant, array $queries = []): array + { + return []; + } + + public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int + { + return 0; + } + + /** + * @return array + */ + public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array + { + return []; + } +} + +class AccumulatorTest extends TestCase +{ + private RecordingAdapter $adapter; + + private Accumulator $accumulator; + + protected function setUp(): void + { + $this->adapter = new RecordingAdapter(); + $this->accumulator = new Accumulator(new Usage($this->adapter)); + } + + public function testEventsSumByKey(): void + { + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('t1', 'requests', 20, Usage::TYPE_EVENT); + $this->accumulator->collect('t1', 'requests', 30, Usage::TYPE_EVENT); + + // Same metric + tags = 1 entry, values summed + $this->assertEquals(1, $this->accumulator->count()); + + $this->assertTrue($this->accumulator->flush()); + + $this->assertCount(1, $this->adapter->batches); + $this->assertEquals(Usage::TYPE_EVENT, $this->adapter->batches[0]['type']); + $this->assertEquals(60, $this->adapter->batches[0]['metrics'][0]['value']); + } + + public function testTagsPartitionEntries(): void + { + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT, ['region' => 'us']); + $this->accumulator->collect('t1', 'requests', 20, Usage::TYPE_EVENT, ['region' => 'eu']); + + // Distinct tags = distinct entries + $this->assertEquals(2, $this->accumulator->count()); + } + + public function testTenantPartitionsEntries(): void + { + // Same metric + tags but different tenants must not collapse + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('t2', 'requests', 20, Usage::TYPE_EVENT); + + $this->assertEquals(2, $this->accumulator->count()); + + $this->assertTrue($this->accumulator->flush()); + + $tenants = array_column($this->adapter->batches[0]['metrics'], 'tenant'); + sort($tenants); + $this->assertEquals(['t1', 't2'], $tenants); + } + + public function testGaugesUseLastWriteWins(): void + { + $this->accumulator->collect('t1', 'storage', 100, Usage::TYPE_GAUGE); + $this->accumulator->collect('t1', 'storage', 200, Usage::TYPE_GAUGE); + $this->accumulator->collect('t1', 'storage', 300, Usage::TYPE_GAUGE); + + $this->assertEquals(1, $this->accumulator->count()); + + $this->assertTrue($this->accumulator->flush()); + + $this->assertEquals(Usage::TYPE_GAUGE, $this->adapter->batches[0]['type']); + $this->assertEquals(300, $this->adapter->batches[0]['metrics'][0]['value']); + } + + public function testFlushSeparatesEventsAndGauges(): void + { + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('t1', 'storage', 100, Usage::TYPE_GAUGE); + + $this->assertTrue($this->accumulator->flush()); + + // One batch per type + $this->assertCount(2, $this->adapter->batches); + $types = [$this->adapter->batches[0]['type'], $this->adapter->batches[1]['type']]; + $this->assertContains(Usage::TYPE_EVENT, $types); + $this->assertContains(Usage::TYPE_GAUGE, $types); + + // Buffer cleared on success + $this->assertEquals(0, $this->accumulator->count()); + } + + public function testFailedFlushRetainsBuffer(): void + { + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT); + + $this->adapter->succeed = false; + $this->assertFalse($this->accumulator->flush()); + + // Nothing written, entry preserved for retry + $this->assertCount(0, $this->adapter->batches); + $this->assertEquals(1, $this->accumulator->count()); + + // A later successful flush drains the buffer + $this->adapter->succeed = true; + $this->assertTrue($this->accumulator->flush()); + $this->assertEquals(0, $this->accumulator->count()); + } + + public function testFlushEmptyBuffer(): void + { + $this->assertTrue($this->accumulator->flush()); + $this->assertCount(0, $this->adapter->batches); + $this->assertEquals(0, $this->accumulator->count()); + } + + public function testElapsedSignal(): void + { + $this->assertLessThan(1.0, $this->accumulator->elapsedSeconds()); + + sleep(1); + + $this->assertGreaterThanOrEqual(1.0, $this->accumulator->elapsedSeconds()); + } + + public function testEmptyTenantThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Tenant cannot be empty'); + $this->accumulator->collect('', 'requests', 10, Usage::TYPE_EVENT); + } + + public function testTenantZeroIsAccepted(): void + { + // "0" is a valid tenant id even though empty("0") is true in PHP + $this->accumulator->collect('0', 'requests', 10, Usage::TYPE_EVENT); + + $this->assertEquals(1, $this->accumulator->count()); + + $this->assertTrue($this->accumulator->flush()); + $this->assertEquals('0', $this->adapter->batches[0]['metrics'][0]['tenant']); + } + + public function testEmptyMetricNameThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Metric name cannot be empty'); + $this->accumulator->collect('t1', '', 10, Usage::TYPE_EVENT); + } + + public function testKeyDistinguishesAmbiguousTenantMetricSplits(): void + { + // tenant "a" + metric "b:c" must not collide with tenant "a:b" + metric "c" + $this->accumulator->collect('a', 'b:c', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('a:b', 'c', 20, Usage::TYPE_EVENT); + + $this->assertEquals(2, $this->accumulator->count()); + } + + public function testTagOrderDoesNotSplitEntries(): void + { + // Same logical tags in different insertion order must sum into one entry + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT, ['teamId' => 't', 'resourceId' => 'r']); + $this->accumulator->collect('t1', 'requests', 20, Usage::TYPE_EVENT, ['resourceId' => 'r', 'teamId' => 't']); + + $this->assertEquals(1, $this->accumulator->count()); + + $this->assertTrue($this->accumulator->flush()); + $this->assertEquals(30, $this->adapter->batches[0]['metrics'][0]['value']); + } + + public function testNegativeValueThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value cannot be negative'); + $this->accumulator->collect('t1', 'requests', -1, Usage::TYPE_EVENT); + } + + public function testInvalidTypeThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->accumulator->collect('t1', 'requests', 10, 'invalid'); + } +} diff --git a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php index 2f26f02..b3b6912 100644 --- a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php @@ -28,23 +28,32 @@ class ClickHouseDimRoutingTest extends ClickHouseTestCase protected function setUp(): void { - $this->adapter = $this->makeAdapter('utopia_usage_dim_routing'); + $this->adapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_dim_routing', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); $this->usage = new Usage($this->adapter); $this->usage->setup(); - $this->usage->purge(); + $this->usage->purge('1'); $this->seedHistoricalRow($this->metric, 10, '-5 days', ['path' => '/v1/a', 'method' => 'GET', 'status' => '200', 'service' => 'storage', 'country' => 'us']); $this->seedHistoricalRow($this->metric, 20, '-4 days', ['path' => '/v1/a', 'method' => 'GET', 'status' => '200', 'service' => 'storage', 'country' => 'us']); $this->seedHistoricalRow($this->metric, 30, '-3 days', ['path' => '/v1/b', 'method' => 'POST', 'status' => '201', 'service' => 'databases', 'country' => 'de']); $this->seedHistoricalRow($this->metric, 40, '-3 days', ['path' => '/v1/c', 'method' => 'POST', 'status' => '500', 'service' => 'functions', 'country' => 'fr']); $this->usage->addBatch([ - ['metric' => $this->metric, 'value' => 5, 'tags' => ['path' => '/v1/a', 'method' => 'GET', 'status' => '200', 'service' => 'storage', 'country' => 'us']], + ['tenant' => '1', 'metric' => $this->metric, 'value' => 5, 'tags' => ['path' => '/v1/a', 'method' => 'GET', 'status' => '200', 'service' => 'storage', 'country' => 'us']], ], Usage::TYPE_EVENT); } protected function tearDown(): void { - $this->usage->purge(); + $this->usage->purge('1'); } /** @@ -109,7 +118,7 @@ public function testTopNGroupedQueryRoutesToMatchingProjection(array $dims, stri $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $rolled = $this->usage->find($queries, Usage::TYPE_EVENT); + $rolled = $this->usage->find('1', $queries, Usage::TYPE_EVENT); $this->assertSame($this->rawTotal($start, $end), $this->totalOf($rolled)); $this->assertProjectionUsed($queryId, $expectedProjection); @@ -122,7 +131,7 @@ public function testMultiDimNotInAnyProjectionFallsBackToTable(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupBy('path'), UsageQuery::groupBy('country'), Query::equal('metric', [$this->metric]), @@ -143,7 +152,7 @@ public function testFilterOnExtraColumnStillRoutesToProjectionWhenColumnPresent( $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), Query::equal('resource', ['function']), @@ -165,7 +174,7 @@ public function testSubDayIntervalStillRoutesToProjection(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), @@ -183,7 +192,7 @@ public function testWindowStraddlesTodayStillRoutesToProjection(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $rolled = $this->usage->find([ + $rolled = $this->usage->find('1', [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), @@ -218,7 +227,7 @@ private function rawTotal(string $start, string $end): int $reflection = new ReflectionClass($this->adapter); $sumFromTable = $reflection->getMethod('sumFromTable'); $sumFromTable->setAccessible(true); - $result = $sumFromTable->invoke($this->adapter, [ + $result = $sumFromTable->invoke($this->adapter, '1', [ Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), diff --git a/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php b/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php index d3c7c01..562a81b 100644 --- a/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php @@ -26,10 +26,19 @@ class ClickHouseGaugeDimRoutingTest extends ClickHouseTestCase protected function setUp(): void { - $this->adapter = $this->makeAdapter('utopia_usage_gauge_dim_routing'); + $this->adapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_gauge_dim_routing', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); $this->usage = new Usage($this->adapter); $this->usage->setup(); - $this->usage->purge(); + $this->usage->purge('1'); $this->seedHistoricalRow($this->metric, 100, '-5 days', ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f1']); $this->seedHistoricalRow($this->metric, 200, '-4 days', ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f2']); @@ -37,13 +46,13 @@ protected function setUp(): void $this->seedHistoricalRow($this->metric, 80, '-3 days', ['service' => 'functions', 'resource' => 'function', 'resourceId' => 'fn1']); $this->usage->addBatch([ - ['metric' => $this->metric, 'value' => 999, 'tags' => ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f1']], + ['tenant' => '1', 'metric' => $this->metric, 'value' => 999, 'tags' => ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f1']], ], Usage::TYPE_GAUGE); } protected function tearDown(): void { - $this->usage->purge(); + $this->usage->purge('1'); } /** @@ -109,7 +118,7 @@ public function testTopGaugesGroupedQueryRoutesToMatchingProjection(array $dims, $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find($queries, Usage::TYPE_GAUGE); + $this->usage->find('1', $queries, Usage::TYPE_GAUGE); $this->assertProjectionUsed($queryId, $expectedProjection); } @@ -123,7 +132,7 @@ public function testGaugesSubDayIntervalStillRoutesToProjection(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), @@ -141,7 +150,7 @@ public function testGaugesFilterOnNonProjectionColumnFallsBackToBaseTable(): voi $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), Query::equal('resourceId', ['x']), @@ -159,7 +168,7 @@ public function testGaugesUngroupedFallsBackToBaseTable(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -175,7 +184,7 @@ public function testGaugesWindowStraddlesTodayStillRoutesToProjection(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), diff --git a/tests/Usage/Adapter/ClickHouseRoutingTest.php b/tests/Usage/Adapter/ClickHouseRoutingTest.php index 4a71e90..862eee4 100644 --- a/tests/Usage/Adapter/ClickHouseRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseRoutingTest.php @@ -20,21 +20,30 @@ class ClickHouseRoutingTest extends ClickHouseTestCase protected function setUp(): void { - $this->adapter = $this->makeAdapter('utopia_usage_routing'); + $this->adapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_routing', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); $this->usage = new Usage($this->adapter); $this->usage->setup(); - $this->usage->purge(); + $this->usage->purge('1'); $this->seedHistoricalRow('routed.metric', 100, '-5 days', ['path' => '/v1/a']); $this->seedHistoricalRow('routed.metric', 200, '-3 days', ['path' => '/v1/b']); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'routed.metric', 'value' => 50, 'tags' => ['path' => '/v1/c']], + ['tenant' => '1', 'metric' => 'routed.metric', 'value' => 50, 'tags' => ['path' => '/v1/c']], ], Usage::TYPE_EVENT)); } protected function tearDown(): void { - $this->usage->purge(); + $this->usage->purge('1'); } /** @@ -74,7 +83,7 @@ public function testClosedDayWindowRoutesToDaily(): void $rawSum = $this->sumRaw('routed.metric', $start, $end); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -97,7 +106,7 @@ public function testInclusiveMidnightUpperBoundExcludesEndDayOnDailyRoute(): voi $rawSum = $this->sumRaw('routed.metric', $start, $end); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -121,7 +130,7 @@ public function testMidDayClosedWindowFallsBackToRaw(): void $start = (new DateTime('-7 days 12:30:00', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); $end = (new DateTime('-2 days 12:30:00', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -141,7 +150,7 @@ public function testWindowStraddlesTodayRoutesHybrid(): void $rawSum = $this->sumRaw('routed.metric', $start, $end); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -162,7 +171,7 @@ public function testHybridSumKeepsParamsDistinctWhenMetricFollowsTimeBounds(): v $rawSum = $this->sumRaw('routed.metric', $start, $end); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), Query::equal('metric', ['routed.metric']), @@ -181,7 +190,7 @@ public function testDimensionPresentForcesRaw(): void $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::equal('path', ['/v1/a']), Query::greaterThanEqual('time', $start), @@ -200,7 +209,7 @@ public function testFilterOnNonDailyColumnForcesRaw(): void $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::equal('country', ['us']), Query::greaterThanEqual('time', $start), @@ -222,7 +231,7 @@ public function testMidDayStartWithHybridWindowFallsBackToRaw(): void $start = (new DateTime('-2 days 14:00:00', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); $end = (new DateTime('+1 hour', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', [$metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -241,7 +250,7 @@ public function testIntervalPresentForcesRaw(): void $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ UsageQuery::groupByInterval('time', '1h'), Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), @@ -262,7 +271,7 @@ public function testDuplicateTimeFiltersTakeTightestBound(): void $endLoose = (new DateTime('+5 days', new DateTimeZone('UTC')))->setTime(0, 0, 0)->format('Y-m-d H:i:s'); $endTighter = (new DateTime('-1 day', new DateTimeZone('UTC')))->setTime(0, 0, 0)->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), Query::greaterThanEqual('time', $startTighter), @@ -283,7 +292,7 @@ public function testOpenEndedWindowForcesRaw(): void $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), ], 'value', Usage::TYPE_EVENT); @@ -298,7 +307,7 @@ public function testMalformedTimeStringForcesRaw(): void $this->adapter->clearRouteLog(); try { - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', 'not-a-date'), Query::lessThanEqual('time', 'not-a-date-either'), @@ -321,7 +330,7 @@ public function testMalformedTimeStringForcesRaw(): void */ public function testNarrowPurgeWithNoTimeBoundDoesNotWipeDailyMv(): void { - $this->usage->purge(); + $this->usage->purge('1'); // Seed two metrics on two different days; let the daily MV // capture both as fully closed-day rows. @@ -335,12 +344,12 @@ public function testNarrowPurgeWithNoTimeBoundDoesNotWipeDailyMv(): void // bound. The raw delete narrows on path; the daily side has // no path column so the legacy logic would fall through to // DELETE WHERE 1=1 and wipe both metrics' daily rows. - $this->usage->purge([ + $this->usage->purge('1', [ Query::equal('path', ['/v1/remove']), ], Usage::TYPE_EVENT); $this->adapter->clearRouteLog(); - $keepSum = $this->usage->sum([ + $keepSum = $this->usage->sum('1', [ Query::equal('metric', ['purge.keep']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -358,7 +367,7 @@ public function testNarrowPurgeWithNoTimeBoundDoesNotWipeDailyMv(): void */ public function testValueFilterPurgeDoesNotMatchAggregateDailyRows(): void { - $this->usage->purge(); + $this->usage->purge('1'); // Seed two raw rows whose values sum to 10 on the same day, // so the daily MV row has value = 10. A naive purge with @@ -369,14 +378,14 @@ public function testValueFilterPurgeDoesNotMatchAggregateDailyRows(): void $start = (new DateTime('-7 days', new DateTimeZone('UTC')))->setTime(0, 0, 0)->format('Y-m-d H:i:s'); $end = (new DateTime('-1 day', new DateTimeZone('UTC')))->setTime(0, 0, 0)->format('Y-m-d H:i:s'); - $this->usage->purge([ + $this->usage->purge('1', [ Query::equal('value', [10]), ], Usage::TYPE_EVENT); // value is raw-only, so the routed sum stays on raw — it // sees the still-present rows. The point of this test is the // daily MV; check it directly via sumDaily. - $dailySum = $this->usage->sumDaily([ + $dailySum = $this->usage->sumDaily('1', [ Query::equal('metric', ['purge.value']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -390,7 +399,7 @@ private function sumRaw(string $metric, string $start, string $end): int $reflection = new ReflectionClass($this->adapter); $sumFromTable = $reflection->getMethod('sumFromTable'); $sumFromTable->setAccessible(true); - $result = $sumFromTable->invoke($this->adapter, [ + $result = $sumFromTable->invoke($this->adapter, '1', [ Query::equal('metric', [$metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), diff --git a/tests/Usage/Adapter/ClickHouseSchemaTest.php b/tests/Usage/Adapter/ClickHouseSchemaTest.php index 50049b6..37c9e84 100644 --- a/tests/Usage/Adapter/ClickHouseSchemaTest.php +++ b/tests/Usage/Adapter/ClickHouseSchemaTest.php @@ -17,7 +17,16 @@ class ClickHouseSchemaTest extends ClickHouseTestCase protected function setUp(): void { - $this->adapter = $this->makeAdapter('utopia_usage_schema'); + $this->adapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_schema', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); $usage = new Usage($this->adapter); $usage->setup(); } @@ -102,7 +111,16 @@ public function testGaugesTableSwapsBloomForSetOnLowCardinality(): void public function testSetupBackfillsServiceResourceOnLegacyGaugesTable(): void { - $legacyAdapter = $this->makeAdapter('utopia_usage_schema_legacy_gauge'); + $legacyAdapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_schema_legacy_gauge', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); $database = $this->databaseName($legacyAdapter); $gaugesTable = $this->resolveTableName($legacyAdapter, 'getGaugesTableName'); $fullName = "`{$database}`.`{$gaugesTable}`"; diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index 28b2abb..aa7e031 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -31,13 +31,12 @@ protected function initializeUsage(): void namespace: 'utopia_usage', database: getenv('CLICKHOUSE_DATABASE') ?: 'default', ); - $adapter->setTenant('1'); $this->usage = new Usage($adapter); $this->usage->setup(); } - public function testMetricTenantOverridesAdapterTenantInBatch(): void + public function testPerMetricTenantInBatch(): void { $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse'; $username = getenv('CLICKHOUSE_USER') ?: 'default'; @@ -55,34 +54,36 @@ public function testMetricTenantOverridesAdapterTenantInBatch(): void database: getenv('CLICKHOUSE_DATABASE') ?: 'default', sharedTables: true, ); - $adapter->setTenant('1'); $usage = new Usage($adapter); $usage->setup(); - $usage->purge(); + $usage->purge('2'); + // The metric carries its own tenant; the batch writes it under tenant '2'. $metrics = [ [ + 'tenant' => '2', 'metric' => 'tenant-override', 'value' => 5, - '$tenant' => '2', 'tags' => [], ], ]; $this->assertTrue($usage->addBatch($metrics, Usage::TYPE_EVENT)); - // Switch adapter scope to the metric tenant to verify the row was stored under the override - $adapter->setTenant('2'); - - $results = $usage->find([ + // Reading under tenant '2' returns the row; tenant '1' must not see it. + $results = $usage->find('2', [ \Utopia\Query\Query::equal('metric', ['tenant-override']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); $this->assertEquals('2', $results[0]->getTenant()); - $usage->purge(); + $this->assertCount(0, $usage->find('1', [ + \Utopia\Query\Query::equal('metric', ['tenant-override']), + ], Usage::TYPE_EVENT)); + + $usage->purge('2'); } /** @@ -91,17 +92,17 @@ public function testMetricTenantOverridesAdapterTenantInBatch(): void public function testAddBatchWithBatchSize(): void { $metrics = [ - ['metric' => 'metric-1', 'value' => 10, 'tags' => []], - ['metric' => 'metric-2', 'value' => 20, 'tags' => []], - ['metric' => 'metric-3', 'value' => 30, 'tags' => []], - ['metric' => 'metric-4', 'value' => 40, 'tags' => []], + ['tenant' => '1', 'metric' => 'metric-1', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'metric-2', 'value' => 20, 'tags' => []], + ['tenant' => '1', 'metric' => 'metric-3', 'value' => 30, 'tags' => []], + ['tenant' => '1', 'metric' => 'metric-4', 'value' => 40, 'tags' => []], ]; // Process with batch size of 2 $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 2)); // Verify all metrics were inserted - $results = $this->usage->find([], Usage::TYPE_EVENT); + $results = $this->usage->find('1', [], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(4, count($results)); } @@ -111,16 +112,16 @@ public function testAddBatchWithBatchSize(): void public function testAddBatchGaugeWithBatchSize(): void { $metrics = [ - ['metric' => 'counter-1', 'value' => 100, 'tags' => []], - ['metric' => 'counter-2', 'value' => 200, 'tags' => []], - ['metric' => 'counter-3', 'value' => 300, 'tags' => []], + ['tenant' => '1', 'metric' => 'counter-1', 'value' => 100, 'tags' => []], + ['tenant' => '1', 'metric' => 'counter-2', 'value' => 200, 'tags' => []], + ['tenant' => '1', 'metric' => 'counter-3', 'value' => 300, 'tags' => []], ]; // Process with batch size of 2 $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_GAUGE, 2)); // Verify gauge metrics were inserted - $results = $this->usage->find([], Usage::TYPE_GAUGE); + $results = $this->usage->find('1', [], Usage::TYPE_GAUGE); $this->assertGreaterThanOrEqual(3, count($results)); } @@ -132,6 +133,7 @@ public function testLargeBatchWithSmallBatchSize(): void $metrics = []; for ($i = 0; $i < 100; $i++) { $metrics[] = [ + 'tenant' => '1', 'metric' => 'large-batch-metric', 'value' => $i, 'tags' => ['resourceId' => (string) $i], @@ -141,7 +143,7 @@ public function testLargeBatchWithSmallBatchSize(): void $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 10)); // Verify metrics were processed - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['large-batch-metric']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); @@ -152,18 +154,18 @@ public function testLargeBatchWithSmallBatchSize(): void */ public function testGaugeMetricsLastValueWins(): void { - $this->usage->purge([], Usage::TYPE_GAUGE); + $this->usage->purge('1', [], Usage::TYPE_GAUGE); $metrics = [ - ['metric' => 'gauge-test', 'value' => 5, 'tags' => []], - ['metric' => 'gauge-test', 'value' => 10, 'tags' => []], - ['metric' => 'gauge-test', 'value' => 15, 'tags' => []], + ['tenant' => '1', 'metric' => 'gauge-test', 'value' => 5, 'tags' => []], + ['tenant' => '1', 'metric' => 'gauge-test', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'gauge-test', 'value' => 15, 'tags' => []], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_GAUGE)); // Gauge total returns argMax (latest value) - $total = $this->usage->getTotal('gauge-test', [], Usage::TYPE_GAUGE); + $total = $this->usage->getTotal('1', 'gauge-test', [], Usage::TYPE_GAUGE); $this->assertGreaterThanOrEqual(5, $total); } @@ -172,18 +174,18 @@ public function testGaugeMetricsLastValueWins(): void */ public function testEventMetricsAggregate(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $metrics = [ - ['metric' => 'agg-test', 'value' => 5, 'tags' => []], - ['metric' => 'agg-test', 'value' => 10, 'tags' => []], - ['metric' => 'agg-test', 'value' => 15, 'tags' => []], + ['tenant' => '1', 'metric' => 'agg-test', 'value' => 5, 'tags' => []], + ['tenant' => '1', 'metric' => 'agg-test', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'agg-test', 'value' => 15, 'tags' => []], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT)); // Event metrics should sum: 5 + 10 + 15 = 30 - $total = $this->usage->getTotal('agg-test', [], Usage::TYPE_EVENT); + $total = $this->usage->getTotal('1', 'agg-test', [], Usage::TYPE_EVENT); $this->assertEquals(30, $total); } @@ -201,14 +203,14 @@ public function testEmptyBatchClickHouse(): void public function testBatchWithTagsClickHouse(): void { $metrics = [ - ['metric' => 'tagged', 'value' => 10, 'tags' => ['region' => 'us-east', 'path' => '/v1/test', 'method' => 'GET', 'status' => '200']], - ['metric' => 'tagged', 'value' => 20, 'tags' => ['region' => 'us-west', 'path' => '/v1/test', 'method' => 'POST', 'status' => '201']], - ['metric' => 'tagged', 'value' => 15, 'tags' => ['region' => 'eu-west', 'path' => '/v1/test', 'method' => 'GET', 'status' => '200']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 10, 'tags' => ['region' => 'us-east', 'path' => '/v1/test', 'method' => 'GET', 'status' => '200']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 20, 'tags' => ['region' => 'us-west', 'path' => '/v1/test', 'method' => 'POST', 'status' => '201']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 15, 'tags' => ['region' => 'eu-west', 'path' => '/v1/test', 'method' => 'GET', 'status' => '200']], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['tagged']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); @@ -221,10 +223,11 @@ public function testBatchWithTagsClickHouse(): void */ public function testEventColumnsExtractedFromTags(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $metrics = [ [ + 'tenant' => '1', 'metric' => 'event-cols-test', 'value' => 42, 'tags' => [ @@ -258,7 +261,7 @@ public function testEventColumnsExtractedFromTags(): void $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['event-cols-test']), ], Usage::TYPE_EVENT); @@ -297,10 +300,11 @@ public function testEventColumnsExtractedFromTags(): void */ public function testGaugeColumnsRoundTrip(): void { - $this->usage->purge([], Usage::TYPE_GAUGE); + $this->usage->purge('1', [], Usage::TYPE_GAUGE); $this->assertTrue($this->usage->addBatch([ [ + 'tenant' => '1', 'metric' => 'gauge-cols-test', 'value' => 500, 'tags' => [ @@ -314,7 +318,7 @@ public function testGaugeColumnsRoundTrip(): void ], ], Usage::TYPE_GAUGE)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['gauge-cols-test']), ], Usage::TYPE_GAUGE); @@ -333,18 +337,18 @@ public function testUnknownTagKeyThrows(): void $this->expectException(\Exception::class); $this->expectExceptionMessageMatches("/Unknown column 'bogus'/"); $this->usage->addBatch([ - ['metric' => 'x', 'value' => 1, 'tags' => ['bogus' => 'v']], + ['tenant' => '1', 'metric' => 'x', 'value' => 1, 'tags' => ['bogus' => 'v']], ], Usage::TYPE_EVENT); } public function testCountryLowercased(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'lc-country', 'value' => 1, 'tags' => ['country' => 'US']], + ['tenant' => '1', 'metric' => 'lc-country', 'value' => 1, 'tags' => ['country' => 'US']], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['lc-country']), ], Usage::TYPE_EVENT); @@ -354,12 +358,12 @@ public function testCountryLowercased(): void public function testRegionLowercased(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'lc-region', 'value' => 1, 'tags' => ['region' => 'FR']], + ['tenant' => '1', 'metric' => 'lc-region', 'value' => 1, 'tags' => ['region' => 'FR']], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['lc-region']), ], Usage::TYPE_EVENT); @@ -369,12 +373,12 @@ public function testRegionLowercased(): void public function testEmptyStringCoercedToNull(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'empty-string', 'value' => 1, 'tags' => ['osName' => '']], + ['tenant' => '1', 'metric' => 'empty-string', 'value' => 1, 'tags' => ['osName' => '']], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['empty-string']), ], Usage::TYPE_EVENT); @@ -388,7 +392,7 @@ public function testEmptyStringCoercedToNull(): void */ public function testEventsSchemaPersistsAllNewColumns(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $tags = [ 'path' => '/v1/x', 'method' => 'GET', 'status' => '200', @@ -405,14 +409,14 @@ public function testEventsSchemaPersistsAllNewColumns(): void ]; $this->assertTrue($this->usage->addBatch([ - ['metric' => 'schema-roundtrip', 'value' => 1, 'tags' => $tags], + ['tenant' => '1', 'metric' => 'schema-roundtrip', 'value' => 1, 'tags' => $tags], ], Usage::TYPE_EVENT)); // Filtering on each indexed dimension should be schema-valid. foreach (['service', 'resourceInternalId', 'teamId', 'teamInternalId', 'region', 'hostname', 'osName', 'clientName', 'deviceName'] as $col) { $value = $tags[$col]; $expected = $col === 'region' ? strtolower($value) : $value; - $rows = $this->usage->find([ + $rows = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['schema-roundtrip']), \Utopia\Query\Query::equal($col, [$expected]), ], Usage::TYPE_EVENT); @@ -425,42 +429,42 @@ public function testEventsSchemaPersistsAllNewColumns(): void */ public function testQueryEventsByColumns(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'req', 'value' => 10, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resource' => 'project', 'resourceId' => 'p1']], - ['metric' => 'req', 'value' => 20, 'tags' => ['path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resource' => 'database', 'resourceId' => 'db1']], - ['metric' => 'req', 'value' => 30, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '404', 'resource' => 'project', 'resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'req', 'value' => 10, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resource' => 'project', 'resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'req', 'value' => 20, 'tags' => ['path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resource' => 'database', 'resourceId' => 'db1']], + ['tenant' => '1', 'metric' => 'req', 'value' => 30, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '404', 'resource' => 'project', 'resourceId' => 'p1']], ], Usage::TYPE_EVENT)); // Filter by path - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('path', ['/v1/storage']), ], Usage::TYPE_EVENT); $this->assertCount(2, $results); // Filter by method - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('method', ['POST']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); $this->assertEquals(20, $results[0]->getValue()); // Filter by status - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('status', ['404']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); $this->assertEquals(30, $results[0]->getValue()); // Filter by resource - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('resource', ['database']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); // Filter by resourceId - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('resourceId', ['db1']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); @@ -471,13 +475,13 @@ public function testQueryEventsByColumns(): void */ public function testGaugeTableSimpleSchema(): void { - $this->usage->purge([], Usage::TYPE_GAUGE); + $this->usage->purge('1', [], Usage::TYPE_GAUGE); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gauge-simple', 'value' => 500, 'tags' => ['resourceId' => 'r1']], + ['tenant' => '1', 'metric' => 'gauge-simple', 'value' => 500, 'tags' => ['resourceId' => 'r1']], ], Usage::TYPE_GAUGE)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['gauge-simple']), ], Usage::TYPE_GAUGE); @@ -498,18 +502,18 @@ public function testGaugeTableSimpleSchema(): void */ public function testFindBothTables(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'both-test-event', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'both-test-event', 'value' => 10, 'tags' => []], ], Usage::TYPE_EVENT)); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'both-test-gauge', 'value' => 100, 'tags' => []], + ['tenant' => '1', 'metric' => 'both-test-gauge', 'value' => 100, 'tags' => []], ], Usage::TYPE_GAUGE)); // Find from both tables - $results = $this->usage->find([], null); + $results = $this->usage->find('1', [], null); $this->assertGreaterThanOrEqual(2, count($results)); $metricNames = array_map(fn ($m) => $m->getMetric(), $results); @@ -525,6 +529,7 @@ public function testBatchSizeAtMaximum(): void $metrics = []; for ($i = 0; $i < 500; $i++) { $metrics[] = [ + 'tenant' => '1', 'metric' => 'boundary-test', 'value' => 1, 'tags' => [], @@ -533,7 +538,7 @@ public function testBatchSizeAtMaximum(): void $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 1000)); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ \Utopia\Query\Query::equal('metric', ['boundary-test']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(500, $sum); @@ -545,15 +550,15 @@ public function testBatchSizeAtMaximum(): void public function testBatchSizeOfOne(): void { $metrics = [ - ['metric' => 'size-one-1', 'value' => 10, 'tags' => []], - ['metric' => 'size-one-2', 'value' => 20, 'tags' => []], - ['metric' => 'size-one-3', 'value' => 30, 'tags' => []], + ['tenant' => '1', 'metric' => 'size-one-1', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'size-one-2', 'value' => 20, 'tags' => []], + ['tenant' => '1', 'metric' => 'size-one-3', 'value' => 30, 'tags' => []], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 1)); // All metrics should be inserted - $results = $this->usage->find([], Usage::TYPE_EVENT); + $results = $this->usage->find('1', [], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(3, count($results)); } @@ -565,6 +570,7 @@ public function testDefaultBatchSize(): void $metrics = []; for ($i = 0; $i < 50; $i++) { $metrics[] = [ + 'tenant' => '1', 'metric' => 'default-batch-test', 'value' => 1, 'tags' => [], @@ -574,7 +580,7 @@ public function testDefaultBatchSize(): void // Use default batch size $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT)); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ \Utopia\Query\Query::equal('metric', ['default-batch-test']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(50, $sum); @@ -587,10 +593,10 @@ public function testMetricsWithSpecialCharacters(): void { $specialVal = "Text with \n newline, \t tab, \"quote\", and unicode \u{1F600}"; $this->assertTrue($this->usage->addBatch([ - ['metric' => 'special-metric', 'value' => 1, 'tags' => ['hostname' => $specialVal]], + ['tenant' => '1', 'metric' => 'special-metric', 'value' => 1, 'tags' => ['hostname' => $specialVal]], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['special-metric']), ], Usage::TYPE_EVENT); @@ -605,31 +611,31 @@ public function testMetricsWithSpecialCharacters(): void public function testFindComprehensive(): void { // Cleanup - $this->usage->purge(); + $this->usage->purge('1'); // Setup test data $this->usage->addBatch([ - ['metric' => 'metric-A', 'value' => 10, 'tags' => ['service' => 'cat1']], + ['tenant' => '1', 'metric' => 'metric-A', 'value' => 10, 'tags' => ['service' => 'cat1']], ], Usage::TYPE_EVENT); $this->usage->addBatch([ - ['metric' => 'metric-B', 'value' => 20, 'tags' => ['service' => 'cat2']], + ['tenant' => '1', 'metric' => 'metric-B', 'value' => 20, 'tags' => ['service' => 'cat2']], ], Usage::TYPE_EVENT); // 1. Array Equal (IN) - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(2, count($results)); // 2. Scalar Equal - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('value', [20]), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); $this->assertEquals(20, $results[0]->getValue()); // 3. Less Than - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::lessThan('value', 20), \Utopia\Query\Query::equal('metric', ['metric-A']), ], Usage::TYPE_EVENT); @@ -637,7 +643,7 @@ public function testFindComprehensive(): void $this->assertEquals(10, $results[0]->getValue()); // 4. Greater Than - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::greaterThan('value', 10), \Utopia\Query\Query::equal('metric', ['metric-B']), ], Usage::TYPE_EVENT); @@ -645,20 +651,20 @@ public function testFindComprehensive(): void $this->assertEquals(20, $results[0]->getValue()); // 5. Between - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::between('value', 5, 25), \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(2, count($results)); // 6. Contains (IN alias) - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::contains('metric', ['metric-A']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); // 7. Order Desc - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']), \Utopia\Query\Query::orderDesc('value'), \Utopia\Query\Query::limit(2), @@ -667,7 +673,7 @@ public function testFindComprehensive(): void $this->assertTrue($results[0]->getValue() >= $results[1]->getValue()); // 8. Order Asc - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']), \Utopia\Query\Query::orderAsc('value'), \Utopia\Query\Query::limit(2), @@ -756,7 +762,6 @@ public function testConnectionPooling(): void namespace: 'utopia_usage_pooling_test', database: getenv('CLICKHOUSE_DATABASE') ?: 'default', ); - $adapter->setTenant('1'); $usage = new Usage($adapter); $usage->setup(); @@ -771,10 +776,10 @@ public function testConnectionPooling(): void // Make some requests $usage->addBatch([ - ['metric' => 'pooling.test', 'value' => 100, 'tags' => ['service' => 'value']], + ['tenant' => '1', 'metric' => 'pooling.test', 'value' => 100, 'tags' => ['service' => 'value']], ], Usage::TYPE_EVENT); - $usage->find([], Usage::TYPE_EVENT); - $usage->count([], Usage::TYPE_EVENT); + $usage->find('1', [], Usage::TYPE_EVENT); + $usage->count('1', [], Usage::TYPE_EVENT); // Verify request count increased $newStats = $adapter->getConnectionStats(); @@ -802,13 +807,12 @@ public function testErrorMessagesIncludeContext(): void namespace: 'utopia_usage_error_test', database: 'nonexistent_db_for_testing_errors_12345', ); - $adapter->setTenant('1'); $usage = new Usage($adapter); try { // This should fail because database doesn't exist - $usage->find([], Usage::TYPE_EVENT); + $usage->find('1', [], Usage::TYPE_EVENT); $this->fail('Expected exception was not thrown'); } catch (\Exception $e) { $errorMessage = $e->getMessage(); @@ -849,7 +853,6 @@ public function testAsyncInsertConfiguration(): void // Async inserts enabled, waiting for confirmation. $adapter = $makeAdapter(true, true); - $adapter->setTenant('1'); $stats = $adapter->getConnectionStats(); $this->assertTrue($stats['async_inserts']); @@ -858,13 +861,13 @@ public function testAsyncInsertConfiguration(): void // Verify it works with async inserts enabled $usage = new Usage($adapter); $usage->setup(); - $usage->purge(); + $usage->purge('1'); $this->assertTrue($usage->addBatch([ - ['metric' => 'async-test', 'value' => 42, 'tags' => []], + ['tenant' => '1', 'metric' => 'async-test', 'value' => 42, 'tags' => []], ], Usage::TYPE_EVENT)); - $total = $usage->getTotal('async-test', [], Usage::TYPE_EVENT); + $total = $usage->getTotal('1', 'async-test', [], Usage::TYPE_EVENT); $this->assertEquals(42, $total); // Fire-and-forget mode: async on, no wait for confirmation. @@ -876,22 +879,22 @@ public function testAsyncInsertConfiguration(): void $stats = $makeAdapter(false, true)->getConnectionStats(); $this->assertFalse($stats['async_inserts']); - $usage->purge(); + $usage->purge('1'); } public function testCursorAfterPaginatesEvents(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'cursor-events', 'value' => 1, 'tags' => []], - ['metric' => 'cursor-events', 'value' => 2, 'tags' => []], - ['metric' => 'cursor-events', 'value' => 3, 'tags' => []], - ['metric' => 'cursor-events', 'value' => 4, 'tags' => []], - ['metric' => 'cursor-events', 'value' => 5, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-events', 'value' => 1, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-events', 'value' => 2, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-events', 'value' => 3, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-events', 'value' => 4, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-events', 'value' => 5, 'tags' => []], ], Usage::TYPE_EVENT)); - $page1 = $this->usage->find([ + $page1 = $this->usage->find('1', [ Query::equal('metric', ['cursor-events']), Query::orderAsc('id'), Query::limit(2), @@ -901,7 +904,7 @@ public function testCursorAfterPaginatesEvents(): void $cursor = $page1[count($page1) - 1]; - $page2 = $this->usage->find([ + $page2 = $this->usage->find('1', [ Query::equal('metric', ['cursor-events']), Query::orderAsc('id'), Query::limit(2), @@ -915,16 +918,16 @@ public function testCursorAfterPaginatesEvents(): void public function testCursorBeforeReversesPagination(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'cursor-before', 'value' => 1, 'tags' => []], - ['metric' => 'cursor-before', 'value' => 2, 'tags' => []], - ['metric' => 'cursor-before', 'value' => 3, 'tags' => []], - ['metric' => 'cursor-before', 'value' => 4, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-before', 'value' => 1, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-before', 'value' => 2, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-before', 'value' => 3, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-before', 'value' => 4, 'tags' => []], ], Usage::TYPE_EVENT)); - $all = $this->usage->find([ + $all = $this->usage->find('1', [ Query::equal('metric', ['cursor-before']), Query::orderAsc('id'), Query::limit(10), @@ -932,7 +935,7 @@ public function testCursorBeforeReversesPagination(): void $this->assertCount(4, $all); - $before = $this->usage->find([ + $before = $this->usage->find('1', [ Query::equal('metric', ['cursor-before']), Query::orderAsc('id'), Query::limit(2), @@ -946,21 +949,21 @@ public function testCursorBeforeReversesPagination(): void public function testCursorAcceptsAssociativeArray(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'cursor-array', 'value' => 1, 'tags' => []], - ['metric' => 'cursor-array', 'value' => 2, 'tags' => []], - ['metric' => 'cursor-array', 'value' => 3, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-array', 'value' => 1, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-array', 'value' => 2, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-array', 'value' => 3, 'tags' => []], ], Usage::TYPE_EVENT)); - $all = $this->usage->find([ + $all = $this->usage->find('1', [ Query::equal('metric', ['cursor-array']), Query::orderAsc('id'), Query::limit(10), ], Usage::TYPE_EVENT); - $page = $this->usage->find([ + $page = $this->usage->find('1', [ Query::equal('metric', ['cursor-array']), Query::orderAsc('id'), Query::limit(10), @@ -975,7 +978,7 @@ public function testCursorWithoutTypeThrows(): void { $this->expectException(\Exception::class); - $this->usage->find([ + $this->usage->find('1', [ Query::cursorAfter(['id' => 'whatever']), ]); } @@ -987,7 +990,7 @@ public function testCursorWithGroupByIntervalThrows(): void $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -997,18 +1000,18 @@ public function testCursorWithGroupByIntervalThrows(): void public function testGroupByServiceDailyAggregates(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gb-service', 'value' => 10, 'tags' => ['service' => 'storage']], - ['metric' => 'gb-service', 'value' => 25, 'tags' => ['service' => 'storage']], - ['metric' => 'gb-service', 'value' => 5, 'tags' => ['service' => 'databases']], + ['tenant' => '1', 'metric' => 'gb-service', 'value' => 10, 'tags' => ['service' => 'storage']], + ['tenant' => '1', 'metric' => 'gb-service', 'value' => 25, 'tags' => ['service' => 'storage']], + ['tenant' => '1', 'metric' => 'gb-service', 'value' => 5, 'tags' => ['service' => 'databases']], ], Usage::TYPE_EVENT)); $start = (new \DateTime())->modify('-1 day')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 day')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1d'), UsageQuery::groupBy('service'), Query::equal('metric', ['gb-service']), @@ -1031,19 +1034,19 @@ public function testGroupByServiceDailyAggregates(): void public function testGroupByMultipleDimensionsHourly(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gb-multi', 'value' => 1, 'tags' => ['service' => 'storage', 'path' => '/v1/a']], - ['metric' => 'gb-multi', 'value' => 2, 'tags' => ['service' => 'storage', 'path' => '/v1/a']], - ['metric' => 'gb-multi', 'value' => 4, 'tags' => ['service' => 'storage', 'path' => '/v1/b']], - ['metric' => 'gb-multi', 'value' => 8, 'tags' => ['service' => 'databases', 'path' => '/v1/a']], + ['tenant' => '1', 'metric' => 'gb-multi', 'value' => 1, 'tags' => ['service' => 'storage', 'path' => '/v1/a']], + ['tenant' => '1', 'metric' => 'gb-multi', 'value' => 2, 'tags' => ['service' => 'storage', 'path' => '/v1/a']], + ['tenant' => '1', 'metric' => 'gb-multi', 'value' => 4, 'tags' => ['service' => 'storage', 'path' => '/v1/b']], + ['tenant' => '1', 'metric' => 'gb-multi', 'value' => 8, 'tags' => ['service' => 'databases', 'path' => '/v1/a']], ], Usage::TYPE_EVENT)); $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), UsageQuery::groupBy('service'), UsageQuery::groupBy('path'), @@ -1071,7 +1074,7 @@ public function testGroupByMultipleDimensionsHourly(): void public function testNotEqualQuery(): void { // Fixture: requests x2, bandwidth x1 in events - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::notEqual('metric', 'requests'), ], Usage::TYPE_EVENT); // bandwidth row only @@ -1083,7 +1086,7 @@ public function testNotEqualQuery(): void public function testNotContainsQuery(): void { - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::notContains('metric', ['requests', 'bandwidth']), ], Usage::TYPE_EVENT); $this->assertCount(0, $results); @@ -1094,7 +1097,7 @@ public function testNotBetweenQuery(): void $past = (new \DateTime())->modify('-2 hour')->format('Y-m-d H:i:s'); $oldPast = (new \DateTime())->modify('-3 hour')->format('Y-m-d H:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::notBetween('time', $oldPast, $past), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(3, count($results)); @@ -1104,13 +1107,13 @@ public function testIsNullAndIsNotNullQueries(): void { // 'country' is a nullable column; fixture rows have no country tag set // so depending on how addBatch persists tags, country may be null or empty. - $isNotNull = $this->usage->find([ + $isNotNull = $this->usage->find('1', [ Query::isNotNull('metric'), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(3, count($isNotNull)); // metric is required so isNull returns nothing - $isNull = $this->usage->find([ + $isNull = $this->usage->find('1', [ Query::isNull('metric'), ], Usage::TYPE_EVENT); $this->assertCount(0, $isNull); @@ -1119,12 +1122,12 @@ public function testIsNullAndIsNotNullQueries(): void public function testStartsWithAndEndsWithQueries(): void { // Fixture: paths /v1/storage, /v1/databases, /v1/storage/files - $startsWith = $this->usage->find([ + $startsWith = $this->usage->find('1', [ Query::startsWith('path', '/v1/'), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(3, count($startsWith)); - $endsWith = $this->usage->find([ + $endsWith = $this->usage->find('1', [ Query::endsWith('path', '/files'), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($endsWith)); @@ -1140,7 +1143,7 @@ public function testContainsRejectsEmptyValues(): void $this->expectException(\Exception::class); $this->expectExceptionMessage('Contains queries require at least one value.'); - $this->usage->find([ + $this->usage->find('1', [ Query::contains('metric', []), ], Usage::TYPE_EVENT); } @@ -1150,7 +1153,7 @@ public function testNotContainsRejectsEmptyValues(): void $this->expectException(\Exception::class); $this->expectExceptionMessage('NotContains queries require at least one value.'); - $this->usage->find([ + $this->usage->find('1', [ Query::notContains('metric', []), ], Usage::TYPE_EVENT); } @@ -1160,7 +1163,7 @@ public function testEqualRejectsEmptyValues(): void $this->expectException(\Exception::class); $this->expectExceptionMessage('Equal queries require at least one value.'); - $this->usage->find([ + $this->usage->find('1', [ new Query(Query::TYPE_EQUAL, 'metric', []), ], Usage::TYPE_EVENT); } diff --git a/tests/Usage/Adapter/ClickHouseTestCase.php b/tests/Usage/Adapter/ClickHouseTestCase.php index b9c2df0..24d9ed4 100644 --- a/tests/Usage/Adapter/ClickHouseTestCase.php +++ b/tests/Usage/Adapter/ClickHouseTestCase.php @@ -9,43 +9,12 @@ /** * Shared base for ClickHouse adapter integration tests. * - * Centralizes the env-driven adapter wiring and a handful of reflection - * helpers that every test in this directory needs (database name lookup, - * private query() / table-name resolver invocation). + * Holds only the reflection helpers that need access to private adapter + * internals (database name lookup, private query() / table-name resolver). + * Adapter construction is intentionally inlined in each test for clarity. */ abstract class ClickHouseTestCase extends TestCase { - /** - * Build a ClickHouse adapter from the standard test env variables. - * Subclasses override namespace and tenant to keep test data isolated. - */ - protected function makeAdapter(string $namespace, ?string $tenant = '1'): ClickHouseAdapter - { - $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse'; - $username = getenv('CLICKHOUSE_USER') ?: 'default'; - $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse'; - $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123); - $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false); - - $database = getenv('CLICKHOUSE_DATABASE') ?: 'default'; - - $adapter = new ClickHouseAdapter( - $host, - $username, - $password, - $port, - $secure, - namespace: $namespace, - database: $database, - sharedTables: true, - ); - if ($tenant !== null) { - $adapter->setTenant($tenant); - } - - return $adapter; - } - /** * Read the adapter's `database` property (private). */ diff --git a/tests/Usage/Adapter/DatabaseTest.php b/tests/Usage/Adapter/DatabaseTest.php index 2d142f8..5c0ee1e 100644 --- a/tests/Usage/Adapter/DatabaseTest.php +++ b/tests/Usage/Adapter/DatabaseTest.php @@ -64,10 +64,11 @@ public function testEventColumnsExtractedFromTags(): void $this->markTestSkipped('pdo_mysql extension is not installed'); } - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ [ + 'tenant' => '1', 'metric' => 'event-cols-db', 'value' => 42, 'tags' => [ @@ -90,7 +91,7 @@ public function testEventColumnsExtractedFromTags(): void ], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['event-cols-db']), ], Usage::TYPE_EVENT); @@ -118,10 +119,11 @@ public function testGaugeColumnsRoundTrip(): void $this->markTestSkipped('pdo_mysql extension is not installed'); } - $this->usage->purge([], Usage::TYPE_GAUGE); + $this->usage->purge('1', [], Usage::TYPE_GAUGE); $this->assertTrue($this->usage->addBatch([ [ + 'tenant' => '1', 'metric' => 'gauge-cols-db', 'value' => 500, 'tags' => [ @@ -133,7 +135,7 @@ public function testGaugeColumnsRoundTrip(): void ], ], Usage::TYPE_GAUGE)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['gauge-cols-db']), ], Usage::TYPE_GAUGE); @@ -154,7 +156,7 @@ public function testUnknownTagKeyThrows(): void $this->expectException(\Exception::class); $this->expectExceptionMessageMatches("/Unknown column 'bogus'/"); $this->usage->addBatch([ - ['metric' => 'x', 'value' => 1, 'tags' => ['bogus' => 'v']], + ['tenant' => '1', 'metric' => 'x', 'value' => 1, 'tags' => ['bogus' => 'v']], ], Usage::TYPE_EVENT); } @@ -164,12 +166,12 @@ public function testCountryAndRegionLowercased(): void $this->markTestSkipped('pdo_mysql extension is not installed'); } - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'lc-db', 'value' => 1, 'tags' => ['country' => 'US', 'region' => 'FR']], + ['tenant' => '1', 'metric' => 'lc-db', 'value' => 1, 'tags' => ['country' => 'US', 'region' => 'FR']], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['lc-db']), ], Usage::TYPE_EVENT); @@ -184,12 +186,12 @@ public function testEmptyStringCoercedToNull(): void $this->markTestSkipped('pdo_mysql extension is not installed'); } - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'empty-db', 'value' => 1, 'tags' => ['osName' => '']], + ['tenant' => '1', 'metric' => 'empty-db', 'value' => 1, 'tags' => ['osName' => '']], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['empty-db']), ], Usage::TYPE_EVENT); diff --git a/tests/Usage/TenantTest.php b/tests/Usage/TenantTest.php new file mode 100644 index 0000000..4f813cd --- /dev/null +++ b/tests/Usage/TenantTest.php @@ -0,0 +1,212 @@ +> */ + public array $lastMetrics = []; + + public function getName(): string + { + return 'tenant-recording'; + } + + /** + * @return array + */ + public function healthCheck(): array + { + return ['healthy' => true]; + } + + public function setup(): void + { + } + + /** + * @param array> $metrics + */ + public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool + { + $this->lastMetrics = $metrics; + return true; + } + + /** + * @return array + */ + public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + { + $this->lastTenant = $tenant; + return []; + } + + public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int + { + $this->lastTenant = $tenant; + return 0; + } + + /** + * @return array + */ + public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array + { + $this->lastTenant = $tenant; + return []; + } + + public function purge(string $tenant, array $queries = [], ?string $type = null): bool + { + $this->lastTenant = $tenant; + return true; + } + + /** + * @return array + */ + public function find(string $tenant, array $queries = [], ?string $type = null): array + { + $this->lastTenant = $tenant; + return []; + } + + public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int + { + $this->lastTenant = $tenant; + return 0; + } + + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + { + $this->lastTenant = $tenant; + return 0; + } + + /** + * @return array + */ + public function findDaily(string $tenant, array $queries = []): array + { + $this->lastTenant = $tenant; + return []; + } + + public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int + { + $this->lastTenant = $tenant; + return 0; + } + + /** + * @return array + */ + public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array + { + $this->lastTenant = $tenant; + return []; + } +} + +class TenantTest extends TestCase +{ + private TenantRecordingAdapter $adapter; + + private Tenant $tenant; + + protected function setUp(): void + { + $this->adapter = new TenantRecordingAdapter(); + $this->tenant = new Tenant(new Usage($this->adapter), 'p1'); + } + + public function testEmptyTenantThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Tenant cannot be empty'); + new Tenant(new Usage($this->adapter), ''); + } + + public function testAddBatchStampsBoundTenantOntoEveryMetric(): void + { + $this->tenant->addBatch([ + ['metric' => 'requests', 'value' => 10, 'tags' => []], + ['metric' => 'bandwidth', 'value' => 20, 'tags' => []], + ], Usage::TYPE_EVENT); + + $this->assertEquals('p1', $this->adapter->lastMetrics[0]['tenant']); + $this->assertEquals('p1', $this->adapter->lastMetrics[1]['tenant']); + } + + public function testFindForwardsBoundTenant(): void + { + $this->tenant->find([], Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testCountForwardsBoundTenant(): void + { + $this->tenant->count([], Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testSumForwardsBoundTenant(): void + { + $this->tenant->sum([], 'value', Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testGetTotalForwardsBoundTenant(): void + { + $this->tenant->getTotal('requests', [], Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testGetTotalBatchForwardsBoundTenant(): void + { + $this->tenant->getTotalBatch(['requests'], [], Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testGetTimeSeriesForwardsBoundTenant(): void + { + $this->tenant->getTimeSeries(['requests'], '1h', '2026-01-01 00:00:00', '2026-01-02 00:00:00'); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testPurgeForwardsBoundTenant(): void + { + $this->tenant->purge([], Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testFindDailyForwardsBoundTenant(): void + { + $this->tenant->findDaily([]); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testSumDailyForwardsBoundTenant(): void + { + $this->tenant->sumDaily([]); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testSumDailyBatchForwardsBoundTenant(): void + { + $this->tenant->sumDailyBatch(['requests'], []); + $this->assertEquals('p1', $this->adapter->lastTenant); + } +} diff --git a/tests/Usage/UsageBase.php b/tests/Usage/UsageBase.php index 1dcbd3f..a104b7e 100644 --- a/tests/Usage/UsageBase.php +++ b/tests/Usage/UsageBase.php @@ -20,35 +20,35 @@ public function setUp(): void public function tearDown(): void { - $this->usage->purge(); + $this->usage->purge('1'); } public function createUsageMetrics(): void { // Events: additive metrics $this->assertTrue($this->usage->addBatch([ - ['metric' => 'requests', 'value' => 100, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resource' => 'project', 'resourceId' => 'p1']], - ['metric' => 'requests', 'value' => 150, 'tags' => ['region' => 'us-west', 'path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resource' => 'database', 'resourceId' => 'db1']], - ['metric' => 'bandwidth', 'value' => 5000, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', 'resource' => 'bucket', 'resourceId' => 'b1']], + ['tenant' => '1', 'metric' => 'requests', 'value' => 100, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resource' => 'project', 'resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'requests', 'value' => 150, 'tags' => ['region' => 'us-west', 'path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resource' => 'database', 'resourceId' => 'db1']], + ['tenant' => '1', 'metric' => 'bandwidth', 'value' => 5000, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', 'resource' => 'bucket', 'resourceId' => 'b1']], ], Usage::TYPE_EVENT)); // Gauges: point-in-time snapshots $this->assertTrue($this->usage->addBatch([ - ['metric' => 'storage', 'value' => 10000, 'tags' => ['resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'storage', 'value' => 10000, 'tags' => ['resourceId' => 'p1']], ], Usage::TYPE_GAUGE)); } public function testAddBatchEvent(): void { - $this->usage->purge(); + $this->usage->purge('1'); // addBatch with event type -- values should sum $this->assertTrue($this->usage->addBatch([ - ['metric' => 'add-metric', 'value' => 10, 'tags' => []], - ['metric' => 'add-metric', 'value' => 5, 'tags' => []], + ['tenant' => '1', 'metric' => 'add-metric', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'add-metric', 'value' => 5, 'tags' => []], ], Usage::TYPE_EVENT)); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['add-metric']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(15, $sum); @@ -56,32 +56,32 @@ public function testAddBatchEvent(): void public function testAddBatchGauge(): void { - $this->usage->purge(); + $this->usage->purge('1'); // addBatch with gauge type $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gauge-metric', 'value' => 100, 'tags' => []], - ['metric' => 'gauge-metric', 'value' => 200, 'tags' => []], + ['tenant' => '1', 'metric' => 'gauge-metric', 'value' => 100, 'tags' => []], + ['tenant' => '1', 'metric' => 'gauge-metric', 'value' => 200, 'tags' => []], ], Usage::TYPE_GAUGE)); // getTotal for gauge returns latest value (argMax) - $total = $this->usage->getTotal('gauge-metric', [], Usage::TYPE_GAUGE); + $total = $this->usage->getTotal('1', 'gauge-metric', [], Usage::TYPE_GAUGE); $this->assertGreaterThanOrEqual(100, $total); } public function testAddBatchWithBatchSize(): void { - $this->usage->purge(); + $this->usage->purge('1'); $metrics = [ - ['metric' => 'batch-requests', 'value' => 100, 'tags' => ['region' => 'eu-west']], - ['metric' => 'batch-requests', 'value' => 150, 'tags' => ['region' => 'eu-east']], - ['metric' => 'batch-bandwidth', 'value' => 3000, 'tags' => ['region' => 'eu-west']], + ['tenant' => '1', 'metric' => 'batch-requests', 'value' => 100, 'tags' => ['region' => 'eu-west']], + ['tenant' => '1', 'metric' => 'batch-requests', 'value' => 150, 'tags' => ['region' => 'eu-east']], + ['tenant' => '1', 'metric' => 'batch-bandwidth', 'value' => 3000, 'tags' => ['region' => 'eu-west']], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 2)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['batch-requests']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); @@ -89,7 +89,7 @@ public function testAddBatchWithBatchSize(): void public function testFind(): void { - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['requests']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); @@ -100,7 +100,7 @@ public function testFindWithTimeRange(): void $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), ], Usage::TYPE_EVENT); @@ -109,7 +109,7 @@ public function testFindWithTimeRange(): void public function testCount(): void { - $count = $this->usage->count([ + $count = $this->usage->count('1', [ Query::equal('metric', ['requests']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, $count); @@ -117,7 +117,7 @@ public function testCount(): void public function testSum(): void { - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['requests']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(250, $sum); // 100 + 150 @@ -125,17 +125,17 @@ public function testSum(): void public function testGetTotal(): void { - $total = $this->usage->getTotal('requests', [], Usage::TYPE_EVENT); + $total = $this->usage->getTotal('1', 'requests', [], Usage::TYPE_EVENT); $this->assertEquals(250, $total); // event: SUM - $total = $this->usage->getTotal('storage', [], Usage::TYPE_GAUGE); + $total = $this->usage->getTotal('1', 'storage', [], Usage::TYPE_GAUGE); $this->assertEquals(10000, $total); // gauge: argMax (latest) } public function testGetTotalBatch(): void { // Event metrics batch - $totals = $this->usage->getTotalBatch(['requests', 'bandwidth'], [], Usage::TYPE_EVENT); + $totals = $this->usage->getTotalBatch('1', ['requests', 'bandwidth'], [], Usage::TYPE_EVENT); $this->assertArrayHasKey('requests', $totals); $this->assertArrayHasKey('bandwidth', $totals); @@ -144,13 +144,13 @@ public function testGetTotalBatch(): void $this->assertEquals(5000, $totals['bandwidth']); // Gauge metrics batch - $gaugeTotals = $this->usage->getTotalBatch(['storage'], [], Usage::TYPE_GAUGE); + $gaugeTotals = $this->usage->getTotalBatch('1', ['storage'], [], Usage::TYPE_GAUGE); $this->assertEquals(10000, $gaugeTotals['storage']); } public function testGetTotalBatchWithMissingMetric(): void { - $totals = $this->usage->getTotalBatch(['requests', 'nonexistent-metric'], [], Usage::TYPE_EVENT); + $totals = $this->usage->getTotalBatch('1', ['requests', 'nonexistent-metric'], [], Usage::TYPE_EVENT); $this->assertEquals(250, $totals['requests']); $this->assertEquals(0, $totals['nonexistent-metric']); @@ -158,7 +158,7 @@ public function testGetTotalBatchWithMissingMetric(): void public function testGetTotalBatchEmpty(): void { - $totals = $this->usage->getTotalBatch([]); + $totals = $this->usage->getTotalBatch('1', []); $this->assertEmpty($totals); } @@ -168,6 +168,7 @@ public function testGetTimeSeries(): void $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d H:i:s'); $results = $this->usage->getTimeSeries( + '1', ['requests'], '1h', $start, @@ -189,6 +190,7 @@ public function testGetTimeSeriesMultipleMetrics(): void $end = (new \DateTime())->modify('+1 day')->format('Y-m-d H:i:s'); $results = $this->usage->getTimeSeries( + '1', ['requests', 'bandwidth'], '1d', $start, @@ -205,7 +207,7 @@ public function testGetTimeSeriesMultipleMetrics(): void public function testEqualWithArrayValues(): void { // Test equal query with array of values (IN clause) - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['requests', 'bandwidth']), ], Usage::TYPE_EVENT); @@ -216,7 +218,7 @@ public function testEqualWithArrayValues(): void public function testContainsQuery(): void { // Test contains query with multiple values from events - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::contains('metric', ['requests', 'bandwidth']), ], Usage::TYPE_EVENT); @@ -227,7 +229,7 @@ public function testContainsQuery(): void public function testLessThanEqualQuery(): void { $now = (new \DateTime())->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::lessThanEqual('time', $now), ], Usage::TYPE_EVENT); @@ -237,7 +239,7 @@ public function testLessThanEqualQuery(): void public function testGreaterThanEqualQuery(): void { $past = (new \DateTime())->modify('-24 hours')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::greaterThanEqual('time', $past), ], Usage::TYPE_EVENT); @@ -249,15 +251,15 @@ public function testPurge(): void sleep(2); $this->usage->addBatch([ - ['metric' => 'purge-test', 'value' => 999, 'tags' => []], + ['tenant' => '1', 'metric' => 'purge-test', 'value' => 999, 'tags' => []], ], Usage::TYPE_EVENT); sleep(2); - $status = $this->usage->purge([], Usage::TYPE_EVENT); + $status = $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($status); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['purge-test']), ], Usage::TYPE_EVENT); $this->assertEquals(0, count($results)); @@ -265,224 +267,42 @@ public function testPurge(): void public function testPurgeWithQueries(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'purge-keep', 'value' => 10, 'tags' => []], - ['metric' => 'purge-remove', 'value' => 20, 'tags' => []], + ['tenant' => '1', 'metric' => 'purge-keep', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'purge-remove', 'value' => 20, 'tags' => []], ], Usage::TYPE_EVENT)); // Purge only the 'purge-remove' metric - $status = $this->usage->purge([ + $status = $this->usage->purge('1', [ Query::equal('metric', ['purge-remove']), ], Usage::TYPE_EVENT); $this->assertTrue($status); // 'purge-remove' should be gone - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['purge-remove']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(0, $sum); // 'purge-keep' should still exist - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['purge-keep']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(10, $sum); } - public function testCollectAndFlush(): void - { - $this->usage->purge(); - - // collect() accumulates in memory, nothing written yet - $this->usage->collect('collect-metric', 10, Usage::TYPE_EVENT); - $this->usage->collect('collect-metric', 20, Usage::TYPE_EVENT); - $this->usage->collect('collect-metric', 30, Usage::TYPE_EVENT); - - // Buffer should have accumulated values - $this->assertEquals(3, $this->usage->getBufferCount()); - // 1 unique metric:type key = 1 buffer entry (events sum) - $this->assertEquals(1, $this->usage->getBufferSize()); - - // Nothing in storage yet - $sum = $this->usage->sum([ - Query::equal('metric', ['collect-metric']), - ], 'value', Usage::TYPE_EVENT); - $this->assertEquals(0, $sum); - - // Flush writes to storage - $this->assertTrue($this->usage->flush()); - - // Buffer should be empty after flush - $this->assertEquals(0, $this->usage->getBufferCount()); - $this->assertEquals(0, $this->usage->getBufferSize()); - - // Storage should have accumulated value (10 + 20 + 30 = 60) - $sum = $this->usage->sum([ - Query::equal('metric', ['collect-metric']), - ], 'value', Usage::TYPE_EVENT); - $this->assertEquals(60, $sum); - } - - public function testCollectMultipleMetrics(): void - { - $this->usage->purge(); - - $this->usage->collect('metric-a', 10, Usage::TYPE_EVENT); - $this->usage->collect('metric-b', 20, Usage::TYPE_EVENT); - $this->usage->collect('metric-a', 5, Usage::TYPE_EVENT); - - // 2 unique metric:type keys = 2 buffer entries - $this->assertEquals(2, $this->usage->getBufferSize()); - $this->assertEquals(3, $this->usage->getBufferCount()); - - $this->assertTrue($this->usage->flush()); - - $sumA = $this->usage->sum([ - Query::equal('metric', ['metric-a']), - ], 'value', Usage::TYPE_EVENT); - $sumB = $this->usage->sum([ - Query::equal('metric', ['metric-b']), - ], 'value', Usage::TYPE_EVENT); - - $this->assertEquals(15, $sumA); - $this->assertEquals(20, $sumB); - } - - public function testCollectGaugeAndFlush(): void - { - $this->usage->purge(); - - // collect with gauge type uses last-write-wins semantics - $this->usage->collect('gauge-collect', 100, Usage::TYPE_GAUGE); - $this->usage->collect('gauge-collect', 200, Usage::TYPE_GAUGE); - $this->usage->collect('gauge-collect', 300, Usage::TYPE_GAUGE); - - // 1 unique metric:type key = 1 buffer entry (gauge: last-write-wins) - $this->assertEquals(1, $this->usage->getBufferSize()); - $this->assertEquals(3, $this->usage->getBufferCount()); - - $this->assertTrue($this->usage->flush()); - - // Should have last value (300), not summed - $total = $this->usage->getTotal('gauge-collect', [], Usage::TYPE_GAUGE); - $this->assertEquals(300, $total); - } - - public function testMixedCollectEventAndGauge(): void - { - $this->usage->purge(); - - // Mix both types in the same buffer - $this->usage->collect('inc-mixed', 10, Usage::TYPE_EVENT); - $this->usage->collect('inc-mixed', 20, Usage::TYPE_EVENT); - $this->usage->collect('set-mixed', 100, Usage::TYPE_GAUGE); - $this->usage->collect('set-mixed', 200, Usage::TYPE_GAUGE); - - // inc: 1 metric:event key = 1, gauge: 1 metric:gauge key = 1 - $this->assertEquals(2, $this->usage->getBufferSize()); - $this->assertEquals(4, $this->usage->getBufferCount()); - - $this->assertTrue($this->usage->flush()); - - // Event: summed (10 + 20 = 30) - $this->assertEquals(30, $this->usage->getTotal('inc-mixed', [], Usage::TYPE_EVENT)); - - // Gauge: last value (200) - $this->assertEquals(200, $this->usage->getTotal('set-mixed', [], Usage::TYPE_GAUGE)); - } - - public function testShouldFlushByThreshold(): void - { - $this->usage->setFlushThreshold(3); - - $this->assertFalse($this->usage->shouldFlush()); - - $this->usage->collect('threshold-test', 1, Usage::TYPE_EVENT); - $this->usage->collect('threshold-test', 1, Usage::TYPE_EVENT); - - $this->assertFalse($this->usage->shouldFlush()); - - $this->usage->collect('threshold-test', 1, Usage::TYPE_EVENT); - - $this->assertTrue($this->usage->shouldFlush()); - - // Clean up - $this->usage->flush(); - $this->usage->setFlushThreshold(10_000); // reset - } - - public function testShouldFlushByInterval(): void - { - $this->usage->setFlushInterval(1); - - $this->usage->collect('interval-test', 1, Usage::TYPE_EVENT); - - // Right after collect, interval hasn't elapsed - $this->assertFalse($this->usage->shouldFlush()); - - // Wait for interval to elapse - sleep(2); - - $this->assertTrue($this->usage->shouldFlush()); - - // Clean up - $this->usage->flush(); - $this->usage->setFlushInterval(20); // reset - } - - public function testFlushEmptyBuffer(): void - { - // Flushing an empty buffer should succeed - $this->assertTrue($this->usage->flush()); - $this->assertEquals(0, $this->usage->getBufferCount()); - $this->assertEquals(0, $this->usage->getBufferSize()); - } - - public function testFlushThresholdConfiguration(): void - { - $this->usage->setFlushThreshold(500); - $this->assertEquals(500, $this->usage->getFlushThreshold()); - - $this->usage->setFlushInterval(30); - $this->assertEquals(30, $this->usage->getFlushInterval()); - - // Invalid values - $this->expectException(\InvalidArgumentException::class); - $this->usage->setFlushThreshold(0); - } - - public function testCollectValidation(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Metric name cannot be empty'); - $this->usage->collect('', 10, Usage::TYPE_EVENT); - } - - public function testCollectNegativeValueValidation(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Value cannot be negative'); - $this->usage->collect('test', -1, Usage::TYPE_EVENT); - } - - public function testCollectInvalidTypeValidation(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->usage->collect('test', 10, 'invalid'); - } - public function testWithQueries(): void { - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['requests']), Query::limit(1), ], Usage::TYPE_EVENT); $this->assertEquals(1, count($results)); - $results2 = $this->usage->find([ + $results2 = $this->usage->find('1', [ Query::equal('metric', ['requests']), Query::limit(1), Query::offset(1), @@ -499,14 +319,14 @@ public function testEmptyBatch(): void public function testAddBatchWithTags(): void { $metrics = [ - ['metric' => 'tagged', 'value' => 10, 'tags' => ['region' => 'us-east']], - ['metric' => 'tagged', 'value' => 20, 'tags' => ['region' => 'us-west']], - ['metric' => 'tagged', 'value' => 15, 'tags' => ['region' => 'eu-west']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 10, 'tags' => ['region' => 'us-east']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 20, 'tags' => ['region' => 'us-west']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 15, 'tags' => ['region' => 'eu-west']], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['tagged']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); @@ -514,21 +334,21 @@ public function testAddBatchWithTags(): void public function testGroupByIntervalHourly(): void { - $this->usage->purge(); + $this->usage->purge('1'); // Insert metrics spread across the current hour $now = new \DateTime(); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gbi-requests', 'value' => 100, 'tags' => []], - ['metric' => 'gbi-requests', 'value' => 50, 'tags' => []], - ['metric' => 'gbi-bandwidth', 'value' => 3000, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-requests', 'value' => 100, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-requests', 'value' => 50, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-bandwidth', 'value' => 3000, 'tags' => []], ], Usage::TYPE_EVENT)); $start = (clone $now)->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (clone $now)->modify('+1 hour')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), Query::equal('metric', ['gbi-requests']), Query::greaterThanEqual('time', $start), @@ -551,17 +371,17 @@ public function testGroupByIntervalHourly(): void public function testGroupByIntervalDaily(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gbi-daily', 'value' => 200, 'tags' => []], - ['metric' => 'gbi-daily', 'value' => 300, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-daily', 'value' => 200, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-daily', 'value' => 300, 'tags' => []], ], Usage::TYPE_EVENT)); $start = (new \DateTime())->modify('-1 day')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 day')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1d'), Query::equal('metric', ['gbi-daily']), Query::greaterThanEqual('time', $start), @@ -581,18 +401,18 @@ public function testGroupByIntervalDaily(): void public function testGroupByIntervalGauge(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gbi-storage', 'value' => 1000, 'tags' => []], - ['metric' => 'gbi-storage', 'value' => 2000, 'tags' => []], - ['metric' => 'gbi-storage', 'value' => 3000, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-storage', 'value' => 1000, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-storage', 'value' => 2000, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-storage', 'value' => 3000, 'tags' => []], ], Usage::TYPE_GAUGE)); $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), Query::equal('metric', ['gbi-storage']), Query::greaterThanEqual('time', $start), @@ -616,17 +436,17 @@ public function testGroupByIntervalInvalidInterval(): void public function testGroupByIntervalWithLimitOffset(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gbi-limit', 'value' => 10, 'tags' => []], - ['metric' => 'gbi-limit', 'value' => 20, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-limit', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-limit', 'value' => 20, 'tags' => []], ], Usage::TYPE_EVENT)); $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), Query::equal('metric', ['gbi-limit']), Query::greaterThanEqual('time', $start), @@ -642,7 +462,7 @@ public function testGroupByUnknownAttributeThrows(): void $this->expectException(\Exception::class); $this->expectExceptionMessageMatches("/groupBy attribute 'not_a_column'/"); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), UsageQuery::groupBy('not_a_column'), Query::equal('metric', ['gbi-requests']), @@ -654,7 +474,7 @@ public function testGroupByWithoutGroupByIntervalReturnsDimOnlyAggregate(): void // Without groupByInterval the result is a flat aggregate per // (metric, …dims) — no time bucketing, ordered by value DESC by // default (top-N table semantics). - $rows = $this->usage->find([ + $rows = $this->usage->find('1', [ UsageQuery::groupBy('service'), Query::equal('metric', ['gbi-requests']), ], Usage::TYPE_EVENT);