diff --git a/src/Usage/Adapter.php b/src/Usage/Adapter.php index 79c2efd..09d6ab7 100644 --- a/src/Usage/Adapter.php +++ b/src/Usage/Adapter.php @@ -60,7 +60,6 @@ abstract public function getTimeSeries(array $metrics, string $interval, string * @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; @@ -80,16 +79,16 @@ abstract public function getTotalBatch(array $metrics, array $queries = [], ?str * Purge usage metrics matching the given queries. * When no queries are provided, all metrics are deleted. * - * @param array<\Utopia\Query\Query> $queries - * @param string|null $type Metric type: 'event', 'gauge', or null (purge both) + * @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; /** * Find metrics using Query objects. * - * @param array<\Utopia\Query\Query> $queries - * @param string|null $type Metric type: 'event', 'gauge', or null (query both) + * @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; @@ -102,10 +101,9 @@ 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 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 + * @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) */ abstract public function count(array $queries = [], ?string $type = null, ?int $max = null): int; @@ -115,10 +113,9 @@ 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 array<\Utopia\Query\Query> $queries - * @param string $attribute Attribute to sum (default: 'value') - * @param string $type Metric type: 'event' or 'gauge' - * @return int + * @param array<\Utopia\Query\Query> $queries + * @param string $attribute Attribute to sum (default: 'value') + * @param string $type Metric type: 'event' or 'gauge' */ abstract public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int; @@ -130,7 +127,7 @@ 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 array<\Utopia\Query\Query> $queries Filters (metric, time range, resource, etc.) + * @param array<\Utopia\Query\Query> $queries Filters (metric, time range, resource, etc.) * @return array */ abstract public function findDaily(array $queries = []): array; @@ -141,9 +138,8 @@ 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 array<\Utopia\Query\Query> $queries - * @param string $attribute Attribute to sum (default: 'value') - * @return int + * @param array<\Utopia\Query\Query> $queries + * @param string $attribute Attribute to sum (default: 'value') */ abstract public function sumDaily(array $queries = [], string $attribute = 'value'): int; @@ -153,8 +149,8 @@ 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 array $metrics List of metric names - * @param array<\Utopia\Query\Query> $queries Additional filters (e.g. date range) + * @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; diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 407c77b..e17dba1 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -92,10 +92,6 @@ class ClickHouse extends SQL private Client $client; - protected ?string $tenant = null; - - protected bool $sharedTables = false; - protected string $namespace = ''; /** @var bool Whether to log queries for debugging */ @@ -161,29 +157,107 @@ public function __construct( string $username = 'default', string $password = '', int $port = self::DEFAULT_PORT, - bool $secure = false + bool $secure = false, + string $namespace = '' ) { $this->validateHost($host); $this->validatePort($port); + if ($namespace !== '') { + $this->validateIdentifier($namespace, 'Namespace'); + } $this->host = $host; $this->port = $port; $this->username = $username; $this->password = $password; $this->secure = $secure; + $this->namespace = $namespace; // Initialize the HTTP client for connection reuse - $this->client = new Client(); + $this->client = new Client; $this->client->addHeader('X-ClickHouse-User', $this->username); $this->client->addHeader('X-ClickHouse-Key', $this->password); $this->client->setTimeout(30_000); // 30 seconds } + /* + * Multi-tenancy seams. The base adapter is single-tenant: these all + * no-op. The SharedTables subclass overrides them to add the tenant + * column, key it into the physical order, scope reads/purges, and stamp + * the per-row tenant on writes — keeping all tenant SQL in one place. + */ + + /** + * Extra column definitions appended to every table (events, gauges, daily). + * + * @return array + */ + protected function tenantColumnDefs(): array + { + return []; + } + + /** + * Leading ORDER BY / projection / GROUP BY key columns. + * + * @return array + */ + protected function keyPrefix(): array + { + return []; + } + + /** + * Attribute names that are queryable beyond the schema attributes + * (e.g. the tenant column in shared-tables mode). + * + * @return array + */ + protected function extraQueryableColumns(): array + { + return []; + } + + /** + * Attributes that scope a query but never narrow it — excluded from the + * daily-purge compatibility / no-op decision. + * + * @return array + */ + protected function scopeOnlyAttributes(): array + { + return []; + } + + /** + * Inject any implicit scope (e.g. the active tenant) into a read/purge + * query set. The base adapter has no implicit scope. + * + * @param array $queries + * @return array + */ + protected function scopeQueries(array $queries): array + { + return $queries; + } + + /** + * Stamp adapter-managed columns (e.g. tenant) onto a row before insert. + * + * @param array $row + * @param array $metricData + * @return array + */ + protected function decorateRow(array $row, array $metricData): array + { + return $row; + } + /** * Set the HTTP request timeout in milliseconds. * - * @param int $milliseconds Timeout in milliseconds (min: 1000ms, max: 600000ms) - * @return self + * @param int $milliseconds Timeout in milliseconds (min: 1000ms, max: 600000ms) + * * @throws Exception If timeout is out of valid range */ public function setTimeout(int $milliseconds): self @@ -195,50 +269,51 @@ public function setTimeout(int $milliseconds): self throw new Exception('Timeout cannot exceed 600000 milliseconds (10 minutes)'); } $this->client->setTimeout($milliseconds); + return $this; } /** * Enable or disable query logging for debugging. * - * @param bool $enable Whether to enable query logging - * @return self + * @param bool $enable Whether to enable query logging */ public function enableQueryLogging(bool $enable = true): self { $this->enableQueryLogging = $enable; + return $this; } /** * Enable or disable gzip compression for HTTP requests/responses. * - * @param bool $enable Whether to enable compression - * @return self + * @param bool $enable Whether to enable compression */ public function setCompression(bool $enable): self { $this->enableCompression = $enable; + return $this; } /** * Enable or disable HTTP keep-alive for connection pooling. * - * @param bool $enable Whether to enable keep-alive (default: true) - * @return self + * @param bool $enable Whether to enable keep-alive (default: true) */ public function setKeepAlive(bool $enable): self { $this->enableKeepAlive = $enable; + return $this; } /** * Set maximum number of retry attempts for failed requests. * - * @param int $maxRetries Maximum retry attempts (0-10, 0 = no retries) - * @return self + * @param int $maxRetries Maximum retry attempts (0-10, 0 = no retries) + * * @throws Exception If maxRetries is out of valid range */ public function setMaxRetries(int $maxRetries): self @@ -247,6 +322,7 @@ public function setMaxRetries(int $maxRetries): self throw new Exception('Max retries must be between 0 and 10'); } $this->maxRetries = $maxRetries; + return $this; } @@ -254,8 +330,8 @@ public function setMaxRetries(int $maxRetries): self * Set initial retry delay in milliseconds. * Delay doubles with each retry attempt (exponential backoff). * - * @param int $milliseconds Initial delay in milliseconds (10-5000ms) - * @return self + * @param int $milliseconds Initial delay in milliseconds (10-5000ms) + * * @throws Exception If delay is out of valid range */ public function setRetryDelay(int $milliseconds): self @@ -264,20 +340,21 @@ public function setRetryDelay(int $milliseconds): self throw new Exception('Retry delay must be between 10 and 5000 milliseconds'); } $this->retryDelay = $milliseconds; + return $this; } /** * Enable or disable ClickHouse async inserts (server-side batching). * - * @param bool $enable Whether to enable async inserts - * @param bool $waitForConfirmation Whether to wait for server-side flush before returning - * @return self + * @param bool $enable Whether to enable async inserts + * @param bool $waitForConfirmation Whether to wait for server-side flush before returning */ public function setAsyncInserts(bool $enable, bool $waitForConfirmation = true): self { $this->asyncInserts = $enable; $this->asyncInsertWait = $waitForConfirmation; + return $this; } @@ -290,6 +367,7 @@ public function setAsyncInserts(bool $enable, bool $waitForConfirmation = true): public function setNextQueryId(?string $queryId): self { $this->nextQueryId = $queryId; + return $this; } @@ -308,6 +386,7 @@ public function getRouteLog(): array public function clearRouteLog(): self { $this->routeLog = []; + return $this; } @@ -316,7 +395,7 @@ public function clearRouteLog(): self * routed reads also execute against the raw events table and log a * `warning` route entry if the totals diverge by >1%. * - * @param float $rate 0.0 (off) … 1.0 (every read) + * @param float $rate 0.0 (off) … 1.0 (every read) */ public function setDualReadSampleRate(float $rate): self { @@ -327,6 +406,7 @@ public function setDualReadSampleRate(float $rate): self $rate = 1.0; } $this->dualReadSampleRate = $rate; + return $this; } @@ -361,12 +441,11 @@ public function getQueryLog(): array /** * Clear the query execution log. - * - * @return self */ public function clearQueryLog(): self { $this->queryLog = []; + return $this; } @@ -401,8 +480,9 @@ public function healthCheck(): array $response = $this->query('SELECT 1 as ping FORMAT JSON'); $json = json_decode($response, true); - if (!is_array($json) || !isset($json['data'][0]['ping'])) { + if (! is_array($json) || ! isset($json['data'][0]['ping'])) { $result['error'] = 'Invalid response format'; + return $result; } @@ -426,6 +506,7 @@ public function healthCheck(): array } catch (Exception $e) { $result['error'] = $e->getMessage(); $result['response_time'] = round(microtime(true) - $startTime, 3); + return $result; } } @@ -433,13 +514,12 @@ public function healthCheck(): array /** * Validate host parameter. * - * @param string $host * @throws Exception */ private function validateHost(string $host): void { - $validator = new Hostname(); - if (!$validator->isValid($host)) { + $validator = new Hostname; + if (! $validator->isValid($host)) { throw new Exception('ClickHouse host is not a valid hostname or IP address'); } } @@ -447,7 +527,6 @@ private function validateHost(string $host): void /** * Validate port parameter. * - * @param int $port * @throws Exception */ private function validatePort(int $port): void @@ -460,8 +539,8 @@ private function validatePort(int $port): void /** * Validate identifier (database, table, namespace). * - * @param string $identifier - * @param string $type Name of the identifier type for error messages + * @param string $type Name of the identifier type for error messages + * * @throws Exception */ private function validateIdentifier(string $identifier, string $type = 'Identifier'): void @@ -474,7 +553,7 @@ private function validateIdentifier(string $identifier, string $type = 'Identifi throw new Exception("{$type} cannot exceed 255 characters"); } - if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $identifier)) { + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $identifier)) { throw new Exception("{$type} must start with a letter or underscore and contain only alphanumeric characters and underscores"); } @@ -486,42 +565,22 @@ private function validateIdentifier(string $identifier, string $type = 'Identifi /** * Escape an identifier for safe use in SQL. - * - * @param string $identifier - * @return string */ private function escapeIdentifier(string $identifier): string { - return '`' . str_replace('`', '``', $identifier) . '`'; - } - - /** - * Set the namespace for multi-project support. - * - * @param string $namespace - * @return self - * @throws Exception - */ - public function setNamespace(string $namespace): self - { - if (!empty($namespace)) { - $this->validateIdentifier($namespace, 'Namespace'); - } - $this->namespace = $namespace; - return $this; + return '`'.str_replace('`', '``', $identifier).'`'; } /** * Set the database name for subsequent operations. * - * @param string $database - * @return self * @throws Exception */ public function setDatabase(string $database): self { $this->validateIdentifier($database, 'Database'); $this->database = $database; + return $this; } @@ -531,108 +590,46 @@ public function setDatabase(string $database): self public function setSecure(bool $secure): self { $this->secure = $secure; - return $this; - } - - /** - * Get the namespace. - * - * @return string - */ - public function getNamespace(): string - { - return $this->namespace; - } - /** - * Set the tenant ID for multi-tenant support. - * - * @param string|null $tenant - * @return self - */ - public function setTenant(?string $tenant): self - { - $this->tenant = $tenant; return $this; } - /** - * Get the tenant ID. - * - * @return string|null - */ - public function getTenant(): ?string - { - return $this->tenant; - } - - /** - * Set whether tables are shared across tenants. - * - * @param bool $sharedTables - * @return self - */ - public function setSharedTables(bool $sharedTables): self - { - $this->sharedTables = $sharedTables; - return $this; - } - - /** - * Get whether tables are shared across tenants. - * - * @return bool - */ - public function isSharedTables(): bool - { - return $this->sharedTables; - } - /** * Get the base table name with namespace prefix. - * - * @return string */ private function getTableName(): string { - return !empty($this->namespace) ? $this->namespace . '_' . $this->table : $this->table; + return ! empty($this->namespace) ? $this->namespace.'_'.$this->table : $this->table; } /** * Get the events table name. - * - * @return string */ private function getEventsTableName(): string { - return $this->getTableName() . '_events'; + return $this->getTableName().'_events'; } /** * Get the gauges table name. - * - * @return string */ private function getGaugesTableName(): string { - return $this->getTableName() . '_gauges'; + return $this->getTableName().'_gauges'; } /** * Get the events daily table name. - * - * @return string */ private function getEventsDailyTableName(): string { - return $this->getTableName() . '_events_daily'; + return $this->getTableName().'_events_daily'; } /** * Get the appropriate table name for a given type. * - * @param string $type 'event' or 'gauge' - * @return string + * @param string $type 'event' or 'gauge' */ private function getTableForType(string $type): string { @@ -642,27 +639,27 @@ private function getTableForType(string $type): string /** * Build a fully qualified table reference with database and escaping. * - * @param string $tableName The table name (with namespace already applied) + * @param string $tableName The table name (with namespace already applied) * @return string Fully qualified table reference */ private function buildTableReference(string $tableName): string { - return $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + return $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($tableName); } /** * Log a query execution for debugging purposes. * - * @param string $sql SQL query executed - * @param array $params Query parameters - * @param float $duration Execution duration in seconds - * @param bool $success Whether the query succeeded - * @param string|null $error Error message if query failed - * @param int $retryAttempt Current retry attempt number + * @param string $sql SQL query executed + * @param array $params Query parameters + * @param float $duration Execution duration in seconds + * @param bool $success Whether the query succeeded + * @param string|null $error Error message if query failed + * @param int $retryAttempt Current retry attempt number */ private function logQuery(string $sql, array $params, float $duration, bool $success, ?string $error = null, int $retryAttempt = 0): void { - if (!$this->enableQueryLogging) { + if (! $this->enableQueryLogging) { return; } @@ -688,8 +685,8 @@ private function logQuery(string $sql, array $params, float $duration, bool $suc /** * Determine if an error is retryable. * - * @param int|null $httpCode HTTP status code if available - * @param string $errorMessage Error message + * @param int|null $httpCode HTTP status code if available + * @param string $errorMessage Error message * @return bool True if the error is retryable */ private function isRetryableError(?int $httpCode, string $errorMessage): bool @@ -720,9 +717,6 @@ private function isRetryableError(?int $httpCode, string $errorMessage): bool /** * Set the current operation context for better error messages. - * - * @param string|null $context - * @return void */ private function setOperationContext(?string $context): void { @@ -733,10 +727,12 @@ private function setOperationContext(?string $context): void * Execute an operation with automatic retry logic and exponential backoff. * * @template T - * @param callable(int): T $operation - * @param callable(Exception, int|null): bool $shouldRetry - * @param callable(Exception, int): Exception $buildException + * + * @param callable(int): T $operation + * @param callable(Exception, int|null): bool $shouldRetry + * @param callable(Exception, int): Exception $buildException * @return T + * * @throws Exception */ private function executeWithRetry(callable $operation, callable $shouldRetry, callable $buildException): mixed @@ -754,6 +750,7 @@ private function executeWithRetry(callable $operation, callable $shouldRetry, ca $attempt++; $delay = $this->retryDelay * (2 ** ($attempt - 1)); usleep($delay * 1000); + continue; } @@ -769,11 +766,6 @@ private function executeWithRetry(callable $operation, callable $shouldRetry, ca /** * Build a contextual error message. - * - * @param string $baseMessage - * @param string|null $table - * @param string|null $sql - * @return string */ private function buildErrorMessage(string $baseMessage, ?string $table = null, ?string $sql = null): string { @@ -788,21 +780,21 @@ private function buildErrorMessage(string $baseMessage, ?string $table = null, ? } if ($sql !== null) { - $truncatedSql = strlen($sql) > 200 ? substr($sql, 0, 200) . '...' : $sql; + $truncatedSql = strlen($sql) > 200 ? substr($sql, 0, 200).'...' : $sql; $truncatedSql = preg_replace('/\s+/', ' ', $truncatedSql); $parts[] = "Query: {$truncatedSql}"; } - $context = !empty($parts) ? ' [' . implode(', ', $parts) . ']' : ''; - return $baseMessage . $context; + $context = ! empty($parts) ? ' ['.implode(', ', $parts).']' : ''; + + return $baseMessage.$context; } /** * Execute a ClickHouse query via HTTP interface. * - * @param string $sql - * @param array $params - * @return string + * @param array $params + * * @throws Exception */ private function query(string $sql, array $params = []): string @@ -816,7 +808,7 @@ function (int $attempt) use ($sql, $params, $queryId): string { $scheme = $this->secure ? 'https' : 'http'; $url = "{$scheme}://{$this->host}:{$this->port}/"; if ($queryId !== null) { - $url .= '?' . http_build_query(['query_id' => $queryId]); + $url .= '?'.http_build_query(['query_id' => $queryId]); } $this->client->addHeader('X-ClickHouse-Database', $this->database); @@ -837,7 +829,7 @@ function (int $attempt) use ($sql, $params, $queryId): string { $body = ['query' => $sql]; foreach ($params as $key => $value) { - $body['param_' . $key] = $this->formatParamValue($value); + $body['param_'.$key] = $this->formatParamValue($value); } $response = $this->client->fetch( @@ -855,13 +847,14 @@ function (int $attempt) use ($sql, $params, $queryId): string { $errorMsg = $this->buildErrorMessage($baseError, null, $sql); $this->logQuery($sql, $params, $duration, false, $errorMsg, $attempt); - throw new Exception($errorMsg . '|HTTP_CODE:' . $httpCode); + throw new Exception($errorMsg.'|HTTP_CODE:'.$httpCode); } $body = $response->getBody(); $result = is_string($body) ? $body : ''; $duration = microtime(true) - $startTime; $this->logQuery($sql, $params, $duration, true, null, $attempt); + return $result; }, function (Exception $e, ?int $httpCode): bool { @@ -869,6 +862,7 @@ function (Exception $e, ?int $httpCode): bool { if (preg_match('/\|HTTP_CODE:(\d+)$/', $e->getMessage(), $matches)) { $exceptionHttpCode = (int) $matches[1]; } + return $this->isRetryableError($exceptionHttpCode, $e->getMessage()); }, function (Exception $e, int $attempt) use ($sql): Exception { @@ -879,8 +873,9 @@ function (Exception $e, int $attempt) use ($sql): Exception { return new Exception($cleanMessage, 0, $e); } - $baseError = "ClickHouse query execution failed after " . ($attempt + 1) . " attempt(s): {$cleanMessage}"; + $baseError = 'ClickHouse query execution failed after '.($attempt + 1)." attempt(s): {$cleanMessage}"; $errorMsg = $this->buildErrorMessage($baseError, null, $sql); + return new Exception($errorMsg, 0, $e); } ); @@ -889,8 +884,9 @@ function (Exception $e, int $attempt) use ($sql): Exception { /** * Execute a ClickHouse INSERT using JSONEachRow format. * - * @param string $table Table name - * @param array $data Array of JSON strings (one per row) + * @param string $table Table name + * @param array $data Array of JSON strings (one per row) + * * @throws Exception */ private function insert(string $table, array $data): void @@ -910,7 +906,7 @@ function (int $attempt) use ($table, $data): void { $queryParams['async_insert'] = '1'; $queryParams['wait_for_async_insert'] = $this->asyncInsertWait ? '1' : '0'; } - $url = "{$scheme}://{$this->host}:{$this->port}/?" . http_build_query($queryParams); + $url = "{$scheme}://{$this->host}:{$this->port}/?".http_build_query($queryParams); $this->client->addHeader('X-ClickHouse-Database', $this->database); $this->client->addHeader('Content-Type', 'application/x-ndjson'); @@ -952,7 +948,7 @@ function (int $attempt) use ($table, $data): void { $errorMsg = $this->buildErrorMessage($baseError, $table, "INSERT INTO {$table} ({$rowCount} rows)"); $this->logQuery($sql, $params, $duration, false, $errorMsg, $attempt); - throw new Exception($errorMsg . '|HTTP_CODE:' . $httpCode); + throw new Exception($errorMsg.'|HTTP_CODE:'.$httpCode); } $duration = microtime(true) - $startTime; @@ -979,8 +975,9 @@ function (Exception $e, int $attempt) use ($table, $data): Exception { } $rowCount = count($data); - $baseError = "ClickHouse insert execution failed after " . ($attempt + 1) . " attempt(s): {$cleanMessage}"; + $baseError = 'ClickHouse insert execution failed after '.($attempt + 1)." attempt(s): {$cleanMessage}"; $errorMsg = $this->buildErrorMessage($baseError, $table, "INSERT INTO {$table} ({$rowCount} rows)"); + return new Exception($errorMsg, 0, $e); } ); @@ -988,9 +985,6 @@ function (Exception $e, int $attempt) use ($table, $data): Exception { /** * Format a parameter value for safe transmission to ClickHouse. - * - * @param mixed $value - * @return string */ private function formatParamValue(mixed $value): string { @@ -1008,6 +1002,7 @@ private function formatParamValue(mixed $value): string if (is_array($value)) { $encoded = json_encode($value); + return is_string($encoded) ? $encoded : ''; } @@ -1125,7 +1120,7 @@ public function setup(): void */ private function setLightweightMutationProjectionMode(string $baseTable): void { - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($baseTable); + $escapedTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($baseTable); $sql = "ALTER TABLE {$escapedTable} MODIFY SETTING lightweight_mutation_projection_mode = 'rebuild'"; $this->query($sql); } @@ -1140,11 +1135,11 @@ private function setLightweightMutationProjectionMode(string $baseTable): void private function ensureGaugeDimColumns(): void { $gaugesTable = $this->escapeIdentifier($this->database) - . '.' . $this->escapeIdentifier($this->getGaugesTableName()); + .'.'.$this->escapeIdentifier($this->getGaugesTableName()); $sql = "ALTER TABLE {$gaugesTable} " - . 'ADD COLUMN IF NOT EXISTS service LowCardinality(Nullable(String)), ' - . 'ADD COLUMN IF NOT EXISTS resource LowCardinality(Nullable(String))'; + .'ADD COLUMN IF NOT EXISTS service LowCardinality(Nullable(String)), ' + .'ADD COLUMN IF NOT EXISTS resource LowCardinality(Nullable(String))'; $this->query($sql); } @@ -1158,18 +1153,14 @@ private function ensureGaugeDimColumns(): void * kept in the projection (not `toStartOfDay`) so `WHERE time BETWEEN` * filters can match the projection without query rewriting. * - * @param array $dims + * @param array $dims */ private function addProjection(string $baseTable, string $name, array $dims, string $aggregateExpr): void { - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($baseTable); + $escapedTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($baseTable); - $selectParts = ['metric', 'time']; - $groupParts = ['metric', 'time']; - if ($this->sharedTables) { - $selectParts[] = 'tenant'; - $groupParts[] = 'tenant'; - } + $selectParts = array_merge(['metric', 'time'], $this->keyPrefix()); + $groupParts = array_merge(['metric', 'time'], $this->keyPrefix()); foreach ($dims as $dim) { $selectParts[] = $this->escapeIdentifier($dim); $groupParts[] = $this->escapeIdentifier($dim); @@ -1180,9 +1171,9 @@ private function addProjection(string $baseTable, string $name, array $dims, str $groupSql = implode(', ', $groupParts); $sql = "ALTER TABLE {$escapedTable} ADD PROJECTION IF NOT EXISTS {$name} (" - . "SELECT {$selectSql} " - . "GROUP BY {$groupSql}" - . ")"; + ."SELECT {$selectSql} " + ."GROUP BY {$groupSql}" + .')'; $this->query($sql); } @@ -1190,30 +1181,27 @@ private function addProjection(string $baseTable, string $name, array $dims, str /** * Create a MergeTree table for the given type. * - * @param string $tableName - * @param string $type 'event' or 'gauge' - * @param array> $indexes + * @param string $type 'event' or 'gauge' + * @param array> $indexes + * * @throws Exception */ private function createTable(string $tableName, string $type, array $indexes): void { - $columns = ['id String ' . $this->getColumnCodec('id')]; + $columns = ['id String '.$this->getColumnCodec('id')]; foreach ($this->getAttributes($type) as $attribute) { /** @var string $id */ $id = $attribute['$id']; if ($id === 'time') { - $columns[] = "time DateTime64(3, 'UTC') " . $this->getColumnCodec('time'); + $columns[] = "time DateTime64(3, 'UTC') ".$this->getColumnCodec('time'); } else { $columns[] = $this->getColumnDefinition($id, $type); } } - // Add tenant column only if tables are shared across tenants - if ($this->sharedTables) { - $columns[] = 'tenant Nullable(String)'; - } + $columns = array_merge($columns, $this->tenantColumnDefs()); $indexDefs = []; foreach ($indexes as $index) { @@ -1228,17 +1216,17 @@ private function createTable(string $tableName, string $type, array $indexes): v $indexDefs[] = "INDEX {$escapedIndexName} ({$attributeList}) TYPE {$indexType} GRANULARITY 1"; } - $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $escapedDatabaseAndTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($tableName); $columnDefs = implode(",\n ", $columns); - $indexDefsStr = !empty($indexDefs) ? ",\n " . implode(",\n ", $indexDefs) : ''; + $indexDefsStr = ! empty($indexDefs) ? ",\n ".implode(",\n ", $indexDefs) : ''; // Primary key matches the most common filter pattern: - // tenant (multi-tenant isolation) → metric (per-metric series) → + // [tenant (multi-tenant isolation) →] metric (per-metric series) → // time (range scans). id is the tiebreaker for stable physical // ordering. This shape lets ClickHouse skip whole granules on // metric+time predicates instead of doing a full-table scan. - $orderByExpr = $this->sharedTables ? '(tenant, metric, time, id)' : '(metric, time, id)'; + $orderByExpr = '('.implode(', ', array_merge($this->keyPrefix(), ['metric', 'time', 'id'])).')'; $createTableSql = " CREATE TABLE IF NOT EXISTS {$escapedDatabaseAndTable} ( @@ -1264,7 +1252,7 @@ private function createTable(string $tableName, string $type, array $indexes): v private function createDailyTable(): void { $dailyTableName = $this->getEventsDailyTableName(); - $escapedDailyTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyTableName); + $escapedDailyTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($dailyTableName); $columns = [ 'metric String', @@ -1277,15 +1265,14 @@ private function createDailyTable(): void 'teamInternalId Nullable(String)', ]; - if ($this->sharedTables) { - $columns[] = 'tenant Nullable(String)'; - } + $columns = array_merge($columns, $this->tenantColumnDefs()); $columnDefs = implode(",\n ", $columns); - $dailyOrderBy = $this->sharedTables - ? '(tenant, metric, time, resource, resourceId, resourceInternalId, teamId, teamInternalId)' - : '(metric, time, resource, resourceId, resourceInternalId, teamId, teamInternalId)'; + $dailyOrderBy = '('.implode(', ', array_merge( + $this->keyPrefix(), + ['metric', 'time', 'resource', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId'] + )).')'; $createDailyTableSql = " CREATE TABLE IF NOT EXISTS {$escapedDailyTable} ( @@ -1309,23 +1296,19 @@ private function createDailyMaterializedView(): void { $eventsTable = $this->getEventsTableName(); $dailyTableName = $this->getEventsDailyTableName(); - $dailyMvName = $this->getTableName() . '_events_daily_mv'; + $dailyMvName = $this->getTableName().'_events_daily_mv'; - $escapedEventsTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($eventsTable); - $escapedDailyTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyTableName); - $escapedDailyMv = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyMvName); + $escapedEventsTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($eventsTable); + $escapedDailyTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($dailyTableName); + $escapedDailyMv = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($dailyMvName); $dimensions = 'resource, resourceId, resourceInternalId, teamId, teamInternalId'; - if ($this->sharedTables) { - $innerSelect = "metric, tenant, {$dimensions}, sum(value) as value, toStartOfDay(time, 'UTC') as d"; - $innerGroupBy = "metric, tenant, {$dimensions}, d"; - $outerSelect = "metric, value, d as time, tenant, {$dimensions}"; - } else { - $innerSelect = "metric, {$dimensions}, sum(value) as value, toStartOfDay(time, 'UTC') as d"; - $innerGroupBy = "metric, {$dimensions}, d"; - $outerSelect = "metric, value, d as time, {$dimensions}"; - } + $tenantCol = $this->keyPrefix() === [] ? '' : implode(', ', $this->keyPrefix()).', '; + + $innerSelect = "metric, {$tenantCol}{$dimensions}, sum(value) as value, toStartOfDay(time, 'UTC') as d"; + $innerGroupBy = "metric, {$tenantCol}{$dimensions}, d"; + $outerSelect = "metric, value, d as time, {$tenantCol}{$dimensions}"; $createDailyMvSql = " CREATE MATERIALIZED VIEW IF NOT EXISTS {$escapedDailyMv} @@ -1344,9 +1327,8 @@ private function createDailyMaterializedView(): void /** * Validate that an attribute name exists in the schema for a given type. * - * @param string $attributeName - * @param string $type 'event' or 'gauge' - * @return bool + * @param string $type 'event' or 'gauge' + * * @throws Exception */ private function validateAttributeName(string $attributeName, string $type = 'event'): bool @@ -1355,7 +1337,7 @@ private function validateAttributeName(string $attributeName, string $type = 'ev return true; } - if ($attributeName === 'tenant' && $this->sharedTables) { + if (in_array($attributeName, $this->extraQueryableColumns(), true)) { return true; } @@ -1391,7 +1373,7 @@ private function validateGroupByAttribute(string $attribute, string $type): bool return true; } - throw new Exception("Invalid groupBy attribute '{$attribute}' for {$type}. Allowed: " . implode(', ', $allowed)); + throw new Exception("Invalid groupBy attribute '{$attribute}' for {$type}. Allowed: ".implode(', ', $allowed)); } /** @@ -1415,7 +1397,7 @@ private function validateDailyAttributeName(string $attributeName): bool return true; } - if ($attributeName === 'tenant' && $this->sharedTables) { + if (in_array($attributeName, $this->extraQueryableColumns(), true)) { return true; } @@ -1423,24 +1405,24 @@ private function validateDailyAttributeName(string $attributeName): bool return true; } - $allowed = implode(', ', self::DAILY_COLUMNS) . ($this->sharedTables ? ', tenant' : ''); + $allowed = implode(', ', array_merge(self::DAILY_COLUMNS, $this->extraQueryableColumns())); throw new Exception( "Invalid attribute '{$attributeName}' for daily table. " - . "Allowed: {$allowed}." + ."Allowed: {$allowed}." ); } /** * Format datetime for ClickHouse compatibility. * - * @param DateTime|string|null $dateTime - * @return string + * @param DateTime|string|null $dateTime + * * @throws Exception */ private function formatDateTime($dateTime): string { if ($dateTime === null) { - return (new DateTime())->format('Y-m-d H:i:s.v'); + return (new DateTime)->format('Y-m-d H:i:s.v'); } if ($dateTime instanceof DateTime) { @@ -1450,6 +1432,7 @@ private function formatDateTime($dateTime): string if (is_string($dateTime)) { try { $dt = new DateTime($dateTime); + return $dt->format('Y-m-d H:i:s.v'); } catch (Exception $e) { throw new Exception("Invalid datetime string: {$dateTime}"); @@ -1457,21 +1440,22 @@ private function formatDateTime($dateTime): string } /** @phpstan-ignore-next-line */ - throw new Exception("Invalid datetime value type: " . gettype($dateTime)); + throw new Exception('Invalid datetime value type: '.gettype($dateTime)); } /** * Get ClickHouse type for an attribute. * - * @param string $id Attribute identifier - * @param string $type 'event' or 'gauge' + * @param string $id Attribute identifier + * @param string $type 'event' or 'gauge' * @return string ClickHouse type + * * @throws Exception */ private function getColumnType(string $id, string $type = 'event'): string { $attribute = $this->getAttribute($id, $type); - if (!$attribute) { + if (! $attribute) { throw new Exception("Attribute {$id} not found in {$type} schema"); } @@ -1497,14 +1481,15 @@ private function getColumnType(string $id, string $type = 'event'): string default => 'String', }; - return !$attribute['required'] ? 'Nullable(' . $baseType . ')' : $baseType; + return ! $attribute['required'] ? 'Nullable('.$baseType.')' : $baseType; } protected function getColumnDefinition(string $id, string $type = 'event'): string { $codec = $this->getColumnCodec($id); - $suffix = $codec !== '' ? ' ' . $codec : ''; - return $this->escapeIdentifier($id) . ' ' . $this->getColumnType($id, $type) . $suffix; + $suffix = $codec !== '' ? ' '.$codec : ''; + + return $this->escapeIdentifier($id).' '.$this->getColumnType($id, $type).$suffix; } /** @@ -1534,11 +1519,12 @@ private function getColumnCodec(string $id): string /** * Validate metric data for batch operations. * - * @param string $metric Metric name - * @param int $value Metric value - * @param string $type Metric type ('event' or 'gauge') - * @param array $tags Tags - * @param int|null $metricIndex Index for batch error messages + * @param string $metric Metric name + * @param int $value Metric value + * @param string $type Metric type ('event' or 'gauge') + * @param array $tags Tags + * @param int|null $metricIndex Index for batch error messages + * * @throws Exception */ private function validateMetricData(string $metric, int $value, string $type, array $tags, ?int $metricIndex = null): void @@ -1546,51 +1532,52 @@ private function validateMetricData(string $metric, int $value, string $type, ar $prefix = $metricIndex !== null ? "Metric #{$metricIndex}: " : ''; if (empty($metric)) { - throw new Exception($prefix . 'Metric cannot be empty'); + throw new Exception($prefix.'Metric cannot be empty'); } if (strlen($metric) > 255) { - throw new Exception($prefix . 'Metric exceeds maximum size of 255 characters'); + throw new Exception($prefix.'Metric exceeds maximum size of 255 characters'); } if ($value < 0) { - throw new Exception($prefix . 'Value cannot be negative'); + throw new Exception($prefix.'Value cannot be negative'); } if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) { - throw new \InvalidArgumentException($prefix . "Invalid type '{$type}'. Allowed: " . Usage::TYPE_EVENT . ', ' . Usage::TYPE_GAUGE); + throw new \InvalidArgumentException($prefix."Invalid type '{$type}'. Allowed: ".Usage::TYPE_EVENT.', '.Usage::TYPE_GAUGE); } - if (!is_array($tags)) { - throw new Exception($prefix . 'Tags must be an array'); + if (! is_array($tags)) { + throw new Exception($prefix.'Tags must be an array'); } } /** * Validate all metrics in a batch before processing. * - * @param array> $metrics - * @param string $type The target table type + * @param array> $metrics + * @param string $type The target table type + * * @throws Exception */ private function validateMetricsBatch(array $metrics, string $type): void { foreach ($metrics as $index => $metricData) { - if (!isset($metricData['metric'])) { + if (! isset($metricData['metric'])) { throw new Exception("Metric #{$index}: 'metric' is required"); } - if (!isset($metricData['value'])) { + if (! isset($metricData['value'])) { throw new Exception("Metric #{$index}: 'value' is required"); } $metric = $metricData['metric']; $value = $metricData['value']; - if (!is_string($metric)) { - throw new Exception("Metric #{$index}: 'metric' must be a string, got " . gettype($metric)); + if (! is_string($metric)) { + throw new Exception("Metric #{$index}: 'metric' must be a string, got ".gettype($metric)); } - if (!is_int($value)) { - throw new Exception("Metric #{$index}: 'value' must be an integer, got " . gettype($value)); + if (! is_int($value)) { + throw new Exception("Metric #{$index}: 'value' must be an integer, got ".gettype($value)); } /** @var array */ @@ -1600,8 +1587,8 @@ private function validateMetricsBatch(array $metrics, string $type): void if (array_key_exists('$tenant', $metricData)) { $tenantValue = $metricData['$tenant']; - if ($tenantValue !== null && !is_string($tenantValue)) { - throw new Exception("Metric #{$index}: '\$tenant' must be a string or null, got " . gettype($tenantValue)); + if ($tenantValue !== null && ! is_string($tenantValue)) { + throw new Exception("Metric #{$index}: '\$tenant' must be a string or null, got ".gettype($tenantValue)); } } } @@ -1617,6 +1604,7 @@ private function validateMetricsBatch(array $metrics, string $type): void * @param array> $metrics * @param string $type Metric type: 'event' or 'gauge' * @param int $batchSize Maximum number of metrics per INSERT statement + * * @throws Exception */ public function addBatch(array $metrics, string $type, int $batchSize = self::INSERT_BATCH_SIZE): bool @@ -1645,24 +1633,20 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN /** @var array $tags */ $tags = $metricData['tags'] ?? []; - $tenant = $this->sharedTables ? $this->resolveTenantFromMetric($metricData) : null; - $columns = Metric::extractColumns($tags, $type); $row = array_merge([ - 'id' => $this->generateId(), + 'id' => $this->generateId(), 'metric' => $metric, - 'value' => $value, - 'time' => $this->formatDateTime(null), + 'value' => $value, + 'time' => $this->formatDateTime(null), ], $columns); - if ($this->sharedTables) { - $row['tenant'] = $tenant; - } + $row = $this->decorateRow($row, $metricData); $encoded = json_encode($row); if ($encoded === false) { - throw new Exception("Failed to JSON encode metric row: " . json_last_error_msg()); + throw new Exception('Failed to JSON encode metric row: '.json_last_error_msg()); } $rows[] = $encoded; } @@ -1673,42 +1657,20 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN return true; } - /** - * Resolve tenant for a single metric entry. - * - * @param array $metricData - */ - private function resolveTenantFromMetric(array $metricData): ?string - { - $tenant = array_key_exists('$tenant', $metricData) ? $metricData['$tenant'] : $this->tenant; - - if ($tenant === null) { - return null; - } - - if (is_string($tenant)) { - return $tenant; - } - - if (is_int($tenant) || is_float($tenant)) { - return (string) $tenant; - } - - return null; - } - /** * Find metrics using Query objects. * When $type is null, queries both tables with UNION ALL. * - * @param array $queries - * @param string|null $type 'event', 'gauge', or null (both) + * @param array $queries + * @param string|null $type 'event', 'gauge', or null (both) * @return array + * * @throws Exception */ public function find(array $queries = [], ?string $type = null): array { $this->setOperationContext('find()'); + $queries = $this->scopeQueries($queries); if ($type !== null) { return $this->findFromTable($queries, $type); @@ -1724,7 +1686,7 @@ public function find(array $queries = [], ?string $type = null): array } if ($method === Query::TYPE_LIMIT) { $values = $query->getValues(); - if (!empty($values) && is_numeric($values[0])) { + if (! empty($values) && is_numeric($values[0])) { $userLimit = (int) $values[0]; } } @@ -1757,7 +1719,7 @@ public function find(array $queries = [], ?string $type = null): array * so a query with event-only attributes (path/method/status/etc.) silently * skips the gauges table instead of throwing "Invalid attribute name". * - * @param array $queries + * @param array $queries */ private function queriesMatchType(array $queries, string $type): bool { @@ -1766,7 +1728,7 @@ private function queriesMatchType(array $queries, string $type): bool if ($attribute === '' || $attribute === 'id') { continue; } - if ($attribute === 'tenant' && $this->sharedTables) { + if (in_array($attribute, $this->extraQueryableColumns(), true)) { continue; } $matched = false; @@ -1776,10 +1738,11 @@ private function queriesMatchType(array $queries, string $type): bool break; } } - if (!$matched) { + if (! $matched) { return false; } } + return true; } @@ -1791,9 +1754,10 @@ private function queriesMatchType(array $queries, string $type): bool * - Gauges: SELECT metric, argMax(value, time) as value, toStartOfInterval(time, INTERVAL ...) as time * Results are grouped by metric and time bucket, ordered by time ASC. * - * @param array $queries - * @param string $type 'event' or 'gauge' + * @param array $queries + * @param string $type 'event' or 'gauge' * @return array + * * @throws Exception */ private function findFromTable(array $queries, string $type): array @@ -1811,7 +1775,7 @@ private function findFromTable(array $queries, string $type): array // Route through the aggregated path whenever any aggregation // hint is present — time bucketing, dimension breakdown, or both. - if (isset($parsed['groupByInterval']) || !empty($parsed['groupBy'])) { + if (isset($parsed['groupByInterval']) || ! empty($parsed['groupBy'])) { return $this->findAggregatedFromTable($parsed, $fromTable, $type); } @@ -1839,9 +1803,9 @@ private function findFromTable(array $queries, string $type): array // $orderAttributes is always non-empty here — resolveCursorOrder // appends an `id` tiebreaker when no order is specified. $orderSql = $this->buildOrderBySql($orderAttributes, flip: $cursorDirection === 'before'); - $orderClause = ' ORDER BY ' . implode(', ', $orderSql); - } elseif (!empty($parsed['orderBy'])) { - $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']); + $orderClause = ' ORDER BY '.implode(', ', $orderSql); + } elseif (! empty($parsed['orderBy'])) { + $orderClause = ' ORDER BY '.implode(', ', $parsed['orderBy']); } $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; @@ -1872,10 +1836,11 @@ private function findFromTable(array $queries, string $type): array * toStartOfInterval(time, INTERVAL 1 HOUR) as time * FROM table WHERE ... GROUP BY metric, time ORDER BY time ASC * - * @param array{filters: array, params: array, orderBy?: array, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array} $parsed Parsed query data from parseQueries() - * @param string $fromTable Fully qualified table reference - * @param string $type 'event' or 'gauge' + * @param array{filters: array, params: array, orderBy?: array, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array} $parsed Parsed query data from parseQueries() + * @param string $fromTable Fully qualified table reference + * @param string $type 'event' or 'gauge' * @return array + * * @throws Exception */ private function findAggregatedFromTable(array $parsed, string $fromTable, string $type): array @@ -1901,13 +1866,13 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin $groupByDims = $parsed['groupBy'] ?? []; $dimSelect = ''; $dimGroup = ''; - if (!empty($groupByDims)) { + if (! empty($groupByDims)) { $escapedDims = array_map( fn (string $dim): string => $this->escapeIdentifier($dim), $groupByDims ); - $dimSelect = ', ' . implode(', ', $escapedDims); - $dimGroup = ', ' . implode(', ', $escapedDims); + $dimSelect = ', '.implode(', ', $escapedDims); + $dimGroup = ', '.implode(', ', $escapedDims); } $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); @@ -1922,7 +1887,7 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin // invalid (the column is no longer in the SELECT after GROUP BY). if ($hasInterval) { $orderClause = ' ORDER BY bucket ASC'; - if (!empty($parsed['orderBy'])) { + if (! empty($parsed['orderBy'])) { $rewrittenOrderBy = array_map( fn (string $clause): string => preg_replace( '/^`time`(\s+(?:ASC|DESC))?$/', @@ -1931,11 +1896,11 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin ) ?? $clause, $parsed['orderBy'] ); - $orderClause = ' ORDER BY ' . implode(', ', $rewrittenOrderBy); + $orderClause = ' ORDER BY '.implode(', ', $rewrittenOrderBy); } } else { $orderClause = ' ORDER BY value DESC'; - if (!empty($parsed['orderBy'])) { + if (! empty($parsed['orderBy'])) { foreach ($parsed['orderBy'] as $clause) { if (preg_match('/^`time`/', $clause)) { throw new Exception( @@ -1943,7 +1908,7 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin ); } } - $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']); + $orderClause = ' ORDER BY '.implode(', ', $parsed['orderBy']); } } @@ -1967,8 +1932,8 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin * * Maps the 'bucket' column back to 'time' for consistent Metric objects. * - * @param string $result Raw JSON response from ClickHouse - * @param string $type 'event' or 'gauge' + * @param string $result Raw JSON response from ClickHouse + * @param string $type 'event' or 'gauge' * @return array */ private function parseAggregatedResults(string $result, string $type = 'event'): array @@ -1979,7 +1944,7 @@ private function parseAggregatedResults(string $result, string $type = 'event'): $json = json_decode($result, true); - if (!is_array($json) || !isset($json['data']) || !is_array($json['data'])) { + if (! is_array($json) || ! isset($json['data']) || ! is_array($json['data'])) { return []; } @@ -1987,7 +1952,7 @@ private function parseAggregatedResults(string $result, string $type = 'event'): $metrics = []; foreach ($rows as $row) { - if (!is_array($row)) { + if (! is_array($row)) { continue; } @@ -1998,7 +1963,7 @@ private function parseAggregatedResults(string $result, string $type = 'event'): // Map 'bucket' back to 'time' for consistent Metric objects $parsedTime = (string) $value; if (strpos($parsedTime, 'T') === false) { - $parsedTime = str_replace(' ', 'T', $parsedTime) . '+00:00'; + $parsedTime = str_replace(' ', 'T', $parsedTime).'+00:00'; } $document['time'] = $parsedTime; } elseif ($key === 'value') { @@ -2038,15 +2003,16 @@ private function parseAggregatedResults(string $result, string $type = 'event'): * a `LIMIT {max}` inside a subquery — ClickHouse stops scanning once * that many rows have been matched, keeping large counts cheap. * - * @param array $queries - * @param string|null $type 'event', 'gauge', or null (both) - * @param int|null $max Optional upper bound (inclusive) for the count - * @return int + * @param array $queries + * @param string|null $type 'event', 'gauge', or null (both) + * @param int|null $max Optional upper bound (inclusive) for the count + * * @throws Exception */ public function count(array $queries = [], ?string $type = null, ?int $max = null): int { $this->setOperationContext('count()'); + $queries = $this->scopeQueries($queries); if ($type !== null) { return $this->countFromTable($queries, $type, $max); @@ -2075,10 +2041,9 @@ public function count(array $queries = [], ?string $type = null, ?int $max = nul /** * Count metrics from a specific table. * - * @param array $queries - * @param string $type - * @param int|null $max Optional upper bound (inclusive) for the count - * @return int + * @param array $queries + * @param int|null $max Optional upper bound (inclusive) for the count + * * @throws Exception */ private function countFromTable(array $queries, string $type, ?int $max = null): int @@ -2113,7 +2078,7 @@ private function countFromTable(array $queries, string $type, ?int $max = null): $result = $this->query($sql, $params); $json = json_decode($result, true); - if (!is_array($json) || !isset($json['data'][0]['total'])) { + if (! is_array($json) || ! isset($json['data'][0]['total'])) { return 0; } @@ -2125,15 +2090,16 @@ private function countFromTable(array $queries, string $type, ?int $max = null): * * Events-only by default — summing gauges is semantically meaningless. * - * @param array $queries - * @param string $attribute Attribute to sum (default: 'value') - * @param string $type 'event' or 'gauge' - * @return int + * @param array $queries + * @param string $attribute Attribute to sum (default: 'value') + * @param string $type 'event' or 'gauge' + * * @throws Exception */ public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int { $this->setOperationContext('sum()'); + $queries = $this->scopeQueries($queries); if ($type === Usage::TYPE_EVENT && $attribute === 'value') { return $this->routedSum($queries, 'sum'); @@ -2147,7 +2113,7 @@ public function sum(array $queries = [], string $attribute = 'value', string $ty * MV+raw, or raw) for a `SELECT sum(value)` over the events table and * record the decision in the route log under `$operation`. * - * @param array $queries + * @param array $queries */ private function routedSum(array $queries, string $operation): int { @@ -2158,11 +2124,13 @@ private function routedSum(array $queries, string $operation): int if ($route === 'daily') { $total = $this->sumDaily($this->translateInclusiveMidnightForDaily($queries), 'value'); $this->maybeDualRead($queries, $route, $plan, $total); + return $total; } if ($route === 'hybrid') { $total = $this->sumHybridDailyAndRaw($queries, $plan); $this->maybeDualRead($queries, $route, $plan, $total); + return $total; } @@ -2172,7 +2140,7 @@ private function routedSum(array $queries, string $operation): int /** * Snapshot of the parsed query shape relevant for routing. * - * @param array $queries + * @param array $queries * @return array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string, orderColumns: array, hasCursor: bool} */ private function extractRoutingPlan(array $queries): array @@ -2192,14 +2160,16 @@ private function extractRoutingPlan(array $queries): array $values = $query->getValues(); if ($method === UsageQuery::TYPE_GROUP_BY) { - if (!in_array($attribute, $dimensions, true)) { + if (! in_array($attribute, $dimensions, true)) { $dimensions[] = $attribute; } + continue; } if ($method === UsageQuery::TYPE_GROUP_BY_INTERVAL) { $intervalValue = $values[0] ?? null; $interval = is_string($intervalValue) ? $intervalValue : null; + continue; } if ($method === Query::TYPE_CURSOR_AFTER || $method === Query::TYPE_CURSOR_BEFORE) { @@ -2207,12 +2177,14 @@ private function extractRoutingPlan(array $queries): array if ($rawCursor !== null) { $hasCursor = true; } + continue; } if ($method === Query::TYPE_ORDER_ASC || $method === Query::TYPE_ORDER_DESC) { - if ($attribute !== '' && !in_array($attribute, $orderColumns, true)) { + if ($attribute !== '' && ! in_array($attribute, $orderColumns, true)) { $orderColumns[] = $attribute; } + continue; } if (in_array($method, [Query::TYPE_LIMIT, Query::TYPE_OFFSET], true)) { @@ -2223,7 +2195,7 @@ private function extractRoutingPlan(array $queries): array continue; } - if (!in_array($attribute, $filterColumns, true)) { + if (! in_array($attribute, $filterColumns, true)) { $filterColumns[] = $attribute; } @@ -2271,7 +2243,7 @@ private function extractRoutingPlan(array $queries): array * table and the ClickHouse optimizer transparently picks the * matching projection. * - * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string, orderColumns?: array, hasCursor?: bool} $plan + * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string, orderColumns?: array, hasCursor?: bool} $plan */ private function selectAggregateSource(array $plan): string { @@ -2283,7 +2255,7 @@ private function selectAggregateSource(array $plan): string return 'raw'; } - if (!empty($plan['hasCursor'])) { + if (! empty($plan['hasCursor'])) { return 'raw'; } @@ -2291,7 +2263,7 @@ private function selectAggregateSource(array $plan): string return 'raw'; } - if (!empty($plan['dimensions'])) { + if (! empty($plan['dimensions'])) { return 'raw'; } @@ -2308,12 +2280,12 @@ private function selectAggregateSource(array $plan): string } foreach ($plan['filterColumns'] as $column) { - if (!in_array($column, self::DAILY_COLUMNS, true) && $column !== 'tenant') { + if (! in_array($column, self::DAILY_COLUMNS, true) && ! in_array($column, $this->extraQueryableColumns(), true)) { return 'raw'; } } - if (!$this->isDayAligned($startDt)) { + if (! $this->isDayAligned($startDt)) { return 'raw'; } @@ -2321,7 +2293,7 @@ private function selectAggregateSource(array $plan): string return 'hybrid'; } - if (!$this->isDayAligned($endDt)) { + if (! $this->isDayAligned($endDt)) { return 'raw'; } @@ -2336,6 +2308,7 @@ private function isDayAligned(?DateTime $dt): bool if ($dt === null) { return true; } + return $dt->format('H:i:s.u') === '00:00:00.000000'; } @@ -2360,7 +2333,7 @@ private function isMidnightString(?string $ts): bool * silently overwrites with whichever value lands last (often a string in * a slot the SQL expects to be a DateTime). * - * @param array{filters: array, params: array} $parsed + * @param array{filters: array, params: array} $parsed * @return array{filters: array, params: array} */ private function prefixParsedParams(array $parsed, string $prefix): array @@ -2368,13 +2341,14 @@ private function prefixParsedParams(array $parsed, string $prefix): array $renamedParams = []; $renamedFilters = $parsed['filters']; foreach ($parsed['params'] as $key => $value) { - $newKey = $prefix . $key; + $newKey = $prefix.$key; $renamedParams[$newKey] = $value; - $pattern = '/\{' . preg_quote($key, '/') . '(:[^}]+)\}/'; + $pattern = '/\{'.preg_quote($key, '/').'(:[^}]+)\}/'; foreach ($renamedFilters as $i => $filter) { - $renamedFilters[$i] = preg_replace($pattern, '{' . $newKey . '$1}', $filter) ?? $filter; + $renamedFilters[$i] = preg_replace($pattern, '{'.$newKey.'$1}', $filter) ?? $filter; } } + return ['filters' => $renamedFilters, 'params' => $renamedParams]; } @@ -2385,15 +2359,16 @@ private function prefixParsedParams(array $parsed, string $prefix): array * (LESSER_EQUAL and BETWEEN upper) to exclusive `<` for the daily * branch. Other bounds pass through untouched. * - * @param array $queries + * @param array $queries * @return array */ private function translateInclusiveMidnightForDaily(array $queries): array { $result = []; foreach ($queries as $q) { - if (!($q instanceof Query) || $q->getAttribute() !== 'time') { + if (! ($q instanceof Query) || $q->getAttribute() !== 'time') { $result[] = $q; + continue; } $method = $q->getMethod(); @@ -2403,6 +2378,7 @@ private function translateInclusiveMidnightForDaily(array $queries): array $upper = $this->stringifyTime($values[0] ?? null); if ($upper !== null && $this->isMidnightString($upper)) { $result[] = new Query(Query::TYPE_LESSER, 'time', [$upper]); + continue; } } @@ -2415,12 +2391,14 @@ private function translateInclusiveMidnightForDaily(array $queries): array $result[] = new Query(Query::TYPE_GREATER_EQUAL, 'time', [$lower]); } $result[] = new Query(Query::TYPE_LESSER, 'time', [$upper]); + continue; } } $result[] = $q; } + return $result; } @@ -2429,6 +2407,7 @@ private function stringifyTime(mixed $value): ?string if ($value instanceof DateTime) { return $value->format('Y-m-d H:i:s'); } + return is_string($value) ? $value : null; } @@ -2450,6 +2429,7 @@ private function tightenLowerBound(?string $current, ?string $candidate): ?strin } catch (Exception $e) { return $current; } + return $cand > $cur ? $candidate : $current; } @@ -2470,6 +2450,7 @@ private function tightenUpperBound(?string $current, ?string $candidate): ?strin } catch (Exception $e) { return $current; } + return $cand < $cur ? $candidate : $current; } @@ -2478,7 +2459,7 @@ private function tightenUpperBound(?string $current, ?string $candidate): ?strin * the hybrid helpers so the daily branch can substitute a day-floored * lower bound while the raw branch keeps the caller's original literal. * - * @param array $queries + * @param array $queries * @return array{nonTime: array, time: array} */ private function splitTimeQueries(array $queries): array @@ -2520,6 +2501,7 @@ private function floorToStartOfDay(?string $value): ?string return null; } $dt->setTime(0, 0, 0); + return $dt->format('Y-m-d H:i:s.v'); } @@ -2531,7 +2513,7 @@ private function floorToStartOfDay(?string $value): ?string * upper bounds floor to the same day's midnight (skipping a partial * end day). Other queries pass through unchanged. * - * @param array $queries + * @param array $queries * @return array */ private function translateTimeQueriesToDayBoundaries(array $queries): array @@ -2540,6 +2522,7 @@ private function translateTimeQueriesToDayBoundaries(array $queries): array foreach ($queries as $query) { if ($query->getAttribute() !== 'time') { $output[] = $query; + continue; } @@ -2550,9 +2533,11 @@ private function translateTimeQueriesToDayBoundaries(array $queries): array $ceiled = $this->ceilLowerToFullyCoveredDayStart($this->stringifyTime($values[0] ?? null)); if ($ceiled === null) { $output[] = $query; + continue; } $output[] = Query::greaterThanEqual('time', $ceiled); + continue; } @@ -2560,9 +2545,11 @@ private function translateTimeQueriesToDayBoundaries(array $queries): array $floored = $this->floorToStartOfDay($this->stringifyTime($values[0] ?? null)); if ($floored === null) { $output[] = $query; + continue; } $output[] = Query::lessThan('time', $floored); + continue; } @@ -2575,6 +2562,7 @@ private function translateTimeQueriesToDayBoundaries(array $queries): array if ($upper !== null) { $output[] = Query::lessThan('time', $upper); } + continue; } @@ -2604,6 +2592,7 @@ private function ceilLowerToFullyCoveredDayStart(?string $value): ?string $dt->setTime(0, 0, 0, 0); $dt->modify('+1 day'); } + return $dt->format('Y-m-d H:i:s.v'); } @@ -2613,9 +2602,9 @@ private function ceilLowerToFullyCoveredDayStart(?string $value): ?string * a mid-day start still picks up that day's rollup row; upper bounds * pass through unchanged. * - * @param array $existing Pre-built non-time filter fragments. - * @param array $timeQueries - * @param array $params Mutated with the new bind values. + * @param array $existing Pre-built non-time filter fragments. + * @param array $timeQueries + * @param array $params Mutated with the new bind values. * @return array */ private function buildDailyTimeFilters(array $existing, array $timeQueries, array &$params): array @@ -2631,9 +2620,10 @@ private function buildDailyTimeFilters(array $existing, array $timeQueries, arra if ($floored === null) { continue; } - $name = 'daily_time_lower_' . $counter++; + $name = 'daily_time_lower_'.$counter++; $params[$name] = $floored; - $filters[] = '`time` >= {' . $name . ':DateTime64(3, \'UTC\')}'; + $filters[] = '`time` >= {'.$name.':DateTime64(3, \'UTC\')}'; + continue; } @@ -2642,11 +2632,12 @@ private function buildDailyTimeFilters(array $existing, array $timeQueries, arra if ($upper === null) { continue; } - $name = 'daily_time_upper_' . $counter++; + $name = 'daily_time_upper_'.$counter++; $params[$name] = $upper; $inclusiveOnMidnight = $method === Query::TYPE_LESSER_EQUAL && $this->isMidnightString($upper); $op = ($method === Query::TYPE_LESSER || $inclusiveOnMidnight) ? '<' : '<='; - $filters[] = '`time` ' . $op . ' {' . $name . ':DateTime64(3, \'UTC\')}'; + $filters[] = '`time` '.$op.' {'.$name.':DateTime64(3, \'UTC\')}'; + continue; } @@ -2654,16 +2645,17 @@ private function buildDailyTimeFilters(array $existing, array $timeQueries, arra $lower = $this->floorToStartOfDay($this->stringifyTime($values[0] ?? null)); $upper = $this->stringifyTime($values[1] ?? null); if ($lower !== null) { - $name = 'daily_time_lower_' . $counter++; + $name = 'daily_time_lower_'.$counter++; $params[$name] = $lower; - $filters[] = '`time` >= {' . $name . ':DateTime64(3, \'UTC\')}'; + $filters[] = '`time` >= {'.$name.':DateTime64(3, \'UTC\')}'; } if ($upper !== null) { - $name = 'daily_time_upper_' . $counter++; + $name = 'daily_time_upper_'.$counter++; $params[$name] = $upper; $op = $this->isMidnightString($upper) ? '<' : '<='; - $filters[] = '`time` ' . $op . ' {' . $name . ':DateTime64(3, \'UTC\')}'; + $filters[] = '`time` '.$op.' {'.$name.':DateTime64(3, \'UTC\')}'; } + continue; } } @@ -2672,7 +2664,7 @@ private function buildDailyTimeFilters(array $existing, array $timeQueries, arra } /** - * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string, orderColumns?: array, hasCursor?: bool} $plan + * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string, orderColumns?: array, hasCursor?: bool} $plan */ private function recordRoute(string $operation, array $plan, string $route): void { @@ -2688,7 +2680,7 @@ private function recordRoute(string $operation, array $plan, string $route): voi } /** - * @param array{operation: string, metric: ?string, route: string, start: ?string, end: ?string, dimensions: array, interval: ?string} $entry + * @param array{operation: string, metric: ?string, route: string, start: ?string, end: ?string, dimensions: array, interval: ?string} $entry */ private function appendRouteLogEntry(array $entry): void { @@ -2709,9 +2701,8 @@ private function appendRouteLogEntry(array $entry): void * as the parent insert and cannot drift, so the sampler skips grouped * (projection-routed) reads. * - * @param array $queries - * @param string $route - * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string, orderColumns?: array, hasCursor?: bool} $plan + * @param array $queries + * @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 { @@ -2738,7 +2729,7 @@ private function maybeDualRead(array $queries, string $route, array $plan, int $ $this->appendRouteLogEntry([ 'operation' => 'dual_read_warning', 'metric' => $plan['metric'], - 'route' => $route . ':delta=' . round($delta, 4), + 'route' => $route.':delta='.round($delta, 4), 'start' => $plan['start'], 'end' => $plan['end'], 'dimensions' => $plan['dimensions'], @@ -2752,8 +2743,8 @@ private function maybeDualRead(array $queries, string $route, array $plan, int $ * partial from the raw events table, combined via outer SUM over * UNION ALL. * - * @param array $queries - * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string} $plan + * @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 { @@ -2793,7 +2784,7 @@ private function sumHybridDailyAndRaw(array $queries, array $plan): int $result = $this->query($sql, $rawWhere['params']); $json = json_decode($result, true); - if (!is_array($json) || !isset($json['data'][0]['total'])) { + if (! is_array($json) || ! isset($json['data'][0]['total'])) { return 0; } @@ -2803,10 +2794,8 @@ private function sumHybridDailyAndRaw(array $queries, array $plan): int /** * Sum metric values from a specific table. * - * @param array $queries - * @param string $attribute - * @param string $type - * @return int + * @param array $queries + * * @throws Exception */ private function sumFromTable(array $queries, string $attribute, string $type): int @@ -2832,7 +2821,7 @@ private function sumFromTable(array $queries, string $attribute, string $type): $json = json_decode($result, true); - if (!is_array($json) || !isset($json['data'][0]['total'])) { + if (! is_array($json) || ! isset($json['data'][0]['total'])) { return 0; } @@ -2842,26 +2831,28 @@ private function sumFromTable(array $queries, string $attribute, string $type): /** * Find event metrics from the pre-aggregated daily table. * - * @param array $queries + * @param array $queries * @return array + * * @throws Exception */ public function findDaily(array $queries = []): array { $this->setOperationContext('findDaily()'); + $queries = $this->scopeQueries($queries); $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); foreach ($queries as $query) { $attr = $query->getAttribute(); - if (!empty($attr)) { + if (! empty($attr)) { $this->validateDailyAttributeName($attr); } } $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $groupByColumns = $this->sharedTables ? ['tenant'] : []; + $groupByColumns = $this->keyPrefix(); $groupByColumns[] = 'metric'; $groupByColumns[] = 'time'; foreach (['resource', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId'] as $dim) { @@ -2877,7 +2868,7 @@ public function findDaily(array $queries = []): array $selectColumns = implode(', ', $selectExpressions); $groupBySql = implode(', ', array_map(fn ($c) => $this->escapeIdentifier($c), $groupByColumns)); - $orderClause = !empty($parsed['orderBy']) ? ' ORDER BY ' . implode(', ', $parsed['orderBy']) : ''; + $orderClause = ! empty($parsed['orderBy']) ? ' ORDER BY '.implode(', ', $parsed['orderBy']) : ''; $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; @@ -2889,14 +2880,15 @@ public function findDaily(array $queries = []): array /** * Sum event metric values from the pre-aggregated daily table. * - * @param array $queries - * @param string $attribute Attribute to sum (default: 'value') - * @return int + * @param array $queries + * @param string $attribute Attribute to sum (default: 'value') + * * @throws Exception */ public function sumDaily(array $queries = [], string $attribute = 'value'): int { $this->setOperationContext('sumDaily()'); + $queries = $this->scopeQueries($queries); $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); $this->validateDailyAttributeName($attribute); @@ -2904,7 +2896,7 @@ public function sumDaily(array $queries = [], string $attribute = 'value'): int foreach ($queries as $query) { $attr = $query->getAttribute(); - if (!empty($attr)) { + if (! empty($attr)) { $this->validateDailyAttributeName($attr); } } @@ -2922,9 +2914,10 @@ public function sumDaily(array $queries = [], string $attribute = 'value'): int /** * Sum multiple event metrics from the pre-aggregated daily table in one query. * - * @param array $metrics - * @param array $queries + * @param array $metrics + * @param array $queries * @return array + * * @throws Exception */ public function sumDailyBatch(array $metrics, array $queries = []): array @@ -2933,11 +2926,13 @@ public function sumDailyBatch(array $metrics, array $queries = []): array return []; } + $queries = $this->scopeQueries($queries); + $this->setOperationContext('sumDailyBatch()'); foreach ($queries as $query) { $attr = $query->getAttribute(); - if (!empty($attr)) { + if (! empty($attr)) { $this->validateDailyAttributeName($attr); } } @@ -2950,7 +2945,7 @@ public function sumDailyBatch(array $metrics, array $queries = []): array $metricParams = []; $metricPlaceholders = []; foreach ($metrics as $i => $metric) { - $paramName = 'metric_' . $i; + $paramName = 'metric_'.$i; $metricParams[$paramName] = $metric; $metricPlaceholders[] = "{{$paramName}:String}"; } @@ -2963,10 +2958,10 @@ public function sumDailyBatch(array $metrics, array $queries = []): array $whereClause = $whereData['clause']; $params = $whereData['params']; - $metricFilter = $this->escapeIdentifier('metric') . " IN ({$metricInClause})"; - $whereClause = !empty($whereClause) - ? $whereClause . ' AND ' . $metricFilter - : ' WHERE ' . $metricFilter; + $metricFilter = $this->escapeIdentifier('metric')." IN ({$metricInClause})"; + $whereClause = ! empty($whereClause) + ? $whereClause.' AND '.$metricFilter + : ' WHERE '.$metricFilter; $sql = " SELECT metric, SUM(value) as total @@ -2998,12 +2993,10 @@ public function sumDailyBatch(array $metrics, array $queries = []): array * * @param array $metrics * @param string $interval '1h' or '1d' - * @param string $startDate - * @param string $endDate * @param array $queries - * @param bool $zeroFill * @param string|null $type 'event', 'gauge', or null (both) * @return array}> + * * @throws Exception */ public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array @@ -3012,8 +3005,10 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat return []; } - if (!isset(self::INTERVAL_FUNCTIONS[$interval])) { - throw new \InvalidArgumentException("Invalid interval '{$interval}'. Allowed: " . implode(', ', array_keys(self::INTERVAL_FUNCTIONS))); + $queries = $this->scopeQueries($queries); + + if (! isset(self::INTERVAL_FUNCTIONS[$interval])) { + throw new \InvalidArgumentException("Invalid interval '{$interval}'. Allowed: ".implode(', ', array_keys(self::INTERVAL_FUNCTIONS))); } $this->setOperationContext('getTimeSeries()'); @@ -3036,7 +3031,7 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat // Skip a table when its schema can't satisfy every filter attribute // (e.g. `path` on a gauge query); avoids "Invalid attribute name" // when the caller leaves $type null and only one side is applicable. - if (!$this->queriesMatchType($queries, $queryType)) { + if (! $this->queriesMatchType($queries, $queryType)) { continue; } @@ -3044,7 +3039,7 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat // Merge results foreach ($typeResult as $metricName => $metricData) { - if (!isset($output[$metricName])) { + if (! isset($output[$metricName])) { continue; } @@ -3076,12 +3071,9 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat * Get time series data from a specific table. * * @param array $metrics - * @param string $interval - * @param string $startDate - * @param string $endDate * @param array $queries - * @param string $type * @return array}> + * * @throws Exception */ private function getTimeSeriesFromTable(array $metrics, string $interval, string $startDate, string $endDate, array $queries, string $type): array @@ -3094,7 +3086,7 @@ private function getTimeSeriesFromTable(array $metrics, string $interval, string $metricParams = []; $metricPlaceholders = []; foreach ($metrics as $i => $metric) { - $paramName = 'metric_' . $i; + $paramName = 'metric_'.$i; $metricParams[$paramName] = $metric; $metricPlaceholders[] = "{{$paramName}:String}"; } @@ -3109,16 +3101,9 @@ private function getTimeSeriesFromTable(array $metrics, string $interval, string $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; - } - $additionalWhere = ''; - if (!empty($additionalFilters)) { - $additionalWhere = ' AND ' . implode(' AND ', $additionalFilters); + if (! empty($additionalFilters)) { + $additionalWhere = ' AND '.implode(' AND ', $additionalFilters); } $valueExpr = $type === Usage::TYPE_EVENT @@ -3133,7 +3118,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 @@ -3154,14 +3139,14 @@ private function getTimeSeriesFromTable(array $metrics, string $interval, string $bucketTime = (string) ($row['bucket'] ?? ''); $value = (float) ($row['agg_value'] ?? 0); - if (!isset($output[$metricName])) { + if (! isset($output[$metricName])) { continue; } // Format bucket time $formattedDate = $bucketTime; if (strpos($bucketTime, 'T') === false) { - $formattedDate = str_replace(' ', 'T', $bucketTime) . '+00:00'; + $formattedDate = str_replace(' ', 'T', $bucketTime).'+00:00'; } $output[$metricName]['total'] += $value; @@ -3178,10 +3163,10 @@ private function getTimeSeriesFromTable(array $metrics, string $interval, string /** * Fill gaps in time series data with zero-value entries. * - * @param array $data Existing data points - * @param string $interval '1h' or '1d' - * @param string $startDate Start datetime - * @param string $endDate End datetime + * @param array $data Existing data points + * @param string $interval '1h' or '1d' + * @param string $startDate Start datetime + * @param string $endDate End datetime * @return array */ private function zeroFillTimeSeries(array $data, string $interval, string $startDate, string $endDate): array @@ -3223,15 +3208,15 @@ private function zeroFillTimeSeries(array $data, string $interval, string $start * Returns sum for event metrics, latest value for gauge metrics. * When $type is null, queries both tables. * - * @param string $metric * @param array $queries * @param string|null $type 'event', 'gauge', or null (both) - * @return int + * * @throws Exception */ public function getTotal(string $metric, array $queries = [], ?string $type = null): int { $this->setOperationContext('getTotal()'); + $queries = $this->scopeQueries($queries); if ($type === Usage::TYPE_EVENT) { return $this->getTotalFromEvents($metric, $queries); @@ -3248,7 +3233,7 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu if ($eventTotal > 0 && $gaugeTotal > 0) { throw new Exception( "Metric '{$metric}' exists in both event and gauge tables. " - . "Specify \$type explicitly to avoid ambiguous aggregation." + .'Specify $type explicitly to avoid ambiguous aggregation.' ); } @@ -3259,14 +3244,14 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu * Get total from events table (SUM). Routes through routedSum() so * closed-day windows hit the daily MV. * - * @param string $metric - * @param array $queries - * @return int + * @param array $queries + * * @throws Exception */ private function getTotalFromEvents(string $metric, array $queries): int { $queries[] = Query::equal('metric', [$metric]); + return $this->routedSum($queries, 'getTotal'); } @@ -3274,9 +3259,8 @@ private function getTotalFromEvents(string $metric, array $queries): int * Get total from gauges table (argMax). Records a route decision so * ops dashboards see gauge getTotal calls alongside the rest. * - * @param string $metric - * @param array $queries - * @return int + * @param array $queries + * * @throws Exception */ private function getTotalFromGauges(string $metric, array $queries): int @@ -3301,7 +3285,7 @@ private function getTotalFromGauges(string $metric, array $queries): int $result = $this->query($sql, $whereData['params']); $json = json_decode($result, true); - if (!is_array($json) || !isset($json['data'][0]['total'])) { + if (! is_array($json) || ! isset($json['data'][0]['total'])) { return 0; } @@ -3321,6 +3305,7 @@ private function getTotalFromGauges(string $metric, array $queries): int * @param array $queries * @param string|null $type 'event', 'gauge', or null (both) * @return array + * * @throws Exception */ public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array @@ -3329,6 +3314,8 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type return []; } + $queries = $this->scopeQueries($queries); + $this->setOperationContext('getTotalBatch()'); // Initialize all metrics to 0 @@ -3353,7 +3340,7 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type $metricParams = []; $metricPlaceholders = []; foreach ($metrics as $i => $metric) { - $paramName = 'metric_' . $i; + $paramName = 'metric_'.$i; $metricParams[$paramName] = $metric; $metricPlaceholders[] = "{{$paramName}:String}"; } @@ -3366,10 +3353,10 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type $whereClause = $whereData['clause']; $params = $whereData['params']; - $metricFilter = $this->escapeIdentifier('metric') . " IN ({$metricInClause})"; - $whereClause = !empty($whereClause) - ? $whereClause . ' AND ' . $metricFilter - : ' WHERE ' . $metricFilter; + $metricFilter = $this->escapeIdentifier('metric')." IN ({$metricInClause})"; + $whereClause = ! empty($whereClause) + ? $whereClause.' AND '.$metricFilter + : ' WHERE '.$metricFilter; $valueExpr = $queryType === Usage::TYPE_EVENT ? 'SUM(value) as agg_val' @@ -3391,7 +3378,7 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type foreach ($json['data'] as $row) { $metricName = $row['metric'] ?? ''; - if (!isset($totals[$metricName])) { + if (! isset($totals[$metricName])) { continue; } @@ -3405,7 +3392,7 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type && $contributingType[$metricName] !== $queryType) { throw new Exception( "Metric '{$metricName}' exists in both event and gauge tables. " - . "Specify \$type explicitly to avoid ambiguous aggregation." + .'Specify $type explicitly to avoid ambiguous aggregation.' ); } @@ -3419,31 +3406,23 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type } /** - * Build WHERE clause from filters with optional tenant filtering. + * Build WHERE clause from filters. + * + * Tenant scoping, when needed, is expressed as a normal query filter by + * the caller (see the $tenant argument on the public read methods), so it + * arrives here already folded into $filters. * - * @param array $filters - * @param array $params - * @param bool $includeTenant + * @param array $filters + * @param array $params * @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, ]; } @@ -3456,7 +3435,6 @@ private function buildWhereClause(array $filters, array $params = [], bool $incl * silently produce incorrect filter results or page boundaries. Add a * branch here when introducing a new typed column. * - * @param string $attribute * @return string ClickHouse parameter type (e.g. 'String', 'DateTime64(3, \'UTC\')', 'Int64') */ private function getParamType(string $attribute): string @@ -3475,9 +3453,8 @@ private function getParamType(string $attribute): string * else through formatParamValue(). Centralising this dispatch keeps * parseQueries and buildCursorWhere consistent across libraries. * - * @param string $chType ClickHouse parameter type as returned by getParamType() - * @param mixed $value - * @return string + * @param string $chType ClickHouse parameter type as returned by getParamType() + * * @throws Exception */ private function formatTypedValue(string $chType, mixed $value): string @@ -3486,6 +3463,7 @@ private function formatTypedValue(string $chType, mixed $value): string if ($value === null) { throw new Exception('DateTime parameter value cannot be null'); } + /** @var DateTime|string $value */ return $this->formatDateTime($value); } @@ -3501,8 +3479,8 @@ private function formatTypedValue(string $chType, mixed $value): string * the underlying column is `id` — this remaps `$id` → `id` so cursor * pagination can match the SQL column. * - * @param mixed $rawCursor * @return array + * * @throws Exception */ private function normalizeCursorRow(mixed $rawCursor): array @@ -3516,11 +3494,11 @@ private function normalizeCursorRow(mixed $rawCursor): array } else { throw new Exception( 'Invalid cursor value: expected ArrayObject (Metric) or associative array, got ' - . get_debug_type($rawCursor) + .get_debug_type($rawCursor) ); } - if (!array_key_exists('id', $row) && array_key_exists('$id', $row)) { + if (! array_key_exists('id', $row) && array_key_exists('$id', $row)) { $row['id'] = $row['$id']; unset($row['$id']); } @@ -3534,7 +3512,7 @@ private function normalizeCursorRow(mixed $rawCursor): array * Auto-appends `id` as a tiebreaker when not already present so keyset * pagination is deterministic on non-unique columns (e.g. time). * - * @param array $orderAttributes + * @param array $orderAttributes * @return array */ private function resolveCursorOrder(array $orderAttributes): array @@ -3546,7 +3524,7 @@ private function resolveCursorOrder(array $orderAttributes): array } $defaultDirection = 'ASC'; - if (!empty($orderAttributes)) { + if (! empty($orderAttributes)) { $last = $orderAttributes[count($orderAttributes) - 1]; $defaultDirection = $last['direction']; } @@ -3567,11 +3545,12 @@ private function resolveCursorOrder(array $orderAttributes): array * actual ORDER BY at SQL build time so the page comes back from the right * side, then reversing the rows post-fetch). * - * @param array $orderAttributes - * @param array $cursor - * @param string $cursorDirection 'after' or 'before' - * @param array $params Existing params (mutated by adding cursor binds) + * @param array $orderAttributes + * @param array $cursor + * @param string $cursorDirection 'after' or 'before' + * @param array $params Existing params (mutated by adding cursor binds) * @return array{clause: string, params: array} + * * @throws Exception */ private function buildCursorWhere(array $orderAttributes, array $cursor, string $cursorDirection, array $params): array @@ -3583,7 +3562,7 @@ private function buildCursorWhere(array $orderAttributes, array $cursor, string $attr = $entry['attribute']; $direction = $entry['direction']; - if (!array_key_exists($attr, $cursor)) { + if (! array_key_exists($attr, $cursor)) { throw new Exception("Cursor is missing required attribute '{$attr}'"); } @@ -3597,7 +3576,7 @@ private function buildCursorWhere(array $orderAttributes, array $cursor, string for ($j = 0; $j < $i; $j++) { $prev = $orderAttributes[$j]; $prevAttr = $prev['attribute']; - if (!array_key_exists($prevAttr, $cursor)) { + if (! array_key_exists($prevAttr, $cursor)) { throw new Exception("Cursor is missing required attribute '{$prevAttr}'"); } $prevValue = $cursor[$prevAttr]; @@ -3624,11 +3603,11 @@ private function buildCursorWhere(array $orderAttributes, array $cursor, string $conditions[] = "{$escaped} {$operator} {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $value); - $tuples[] = '(' . implode(' AND ', $conditions) . ')'; + $tuples[] = '('.implode(' AND ', $conditions).')'; } return [ - 'clause' => '(' . implode(' OR ', $tuples) . ')', + 'clause' => '('.implode(' OR ', $tuples).')', 'params' => $params, ]; } @@ -3639,8 +3618,8 @@ private function buildCursorWhere(array $orderAttributes, array $cursor, string * Used when cursor direction is `before` — we run the query in reverse to * grab the previous-page rows, then `array_reverse` the result. * - * @param array $orderAttributes - * @param bool $flip Whether to flip ASC↔DESC + * @param array $orderAttributes + * @param bool $flip Whether to flip ASC↔DESC * @return array */ private function buildOrderBySql(array $orderAttributes, bool $flip = false): array @@ -3651,17 +3630,19 @@ private function buildOrderBySql(array $orderAttributes, bool $flip = false): ar if ($flip) { $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; } - $sql[] = $this->escapeIdentifier($entry['attribute']) . ' ' . $direction; + $sql[] = $this->escapeIdentifier($entry['attribute']).' '.$direction; } + return $sql; } /** * Parse Query objects into SQL clauses. * - * @param array $queries - * @param string $type 'event' or 'gauge' — used for attribute validation + * @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 @@ -3689,7 +3670,7 @@ private function parseQueries(array $queries, string $type = 'event'): array // otherwise turn `Query::contains('attr', [])` into a full-table // match instead of an empty result. if (\in_array($method, self::VALUE_REQUIRED_METHODS, true) && empty($values)) { - throw new Exception(\ucfirst($method) . ' queries require at least one value.'); + throw new Exception(\ucfirst($method).' queries require at least one value.'); } switch ($method) { @@ -3701,13 +3682,13 @@ private function parseQueries(array $queries, string $type = 'event'): array if (count($values) > 1) { $inParams = []; foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $inParams[] = "{{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $value); } - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; + $filters[] = "{$escapedAttr} IN (".implode(', ', $inParams).')'; } else { - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} = {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); } @@ -3717,7 +3698,7 @@ private function parseQueries(array $queries, string $type = 'event'): array $this->validateAttributeName($attribute, $type); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} != {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); break; @@ -3726,7 +3707,7 @@ private function parseQueries(array $queries, string $type = 'event'): array $this->validateAttributeName($attribute, $type); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} < {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); break; @@ -3735,7 +3716,7 @@ private function parseQueries(array $queries, string $type = 'event'): array $this->validateAttributeName($attribute, $type); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} > {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); break; @@ -3744,8 +3725,8 @@ private function parseQueries(array $queries, string $type = 'event'): array $this->validateAttributeName($attribute, $type); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; + $paramName1 = 'param_'.$paramCounter++; + $paramName2 = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); @@ -3755,8 +3736,8 @@ private function parseQueries(array $queries, string $type = 'event'): array $this->validateAttributeName($attribute, $type); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; + $paramName1 = 'param_'.$paramCounter++; + $paramName2 = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} NOT BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); @@ -3794,7 +3775,7 @@ private function parseQueries(array $queries, string $type = 'event'): array $this->validateAttributeName($attribute, $type); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} <= {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); break; @@ -3803,7 +3784,7 @@ private function parseQueries(array $queries, string $type = 'event'): array $this->validateAttributeName($attribute, $type); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} >= {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); break; @@ -3814,12 +3795,12 @@ private function parseQueries(array $queries, string $type = 'event'): array $chType = $this->getParamType($attribute); $inParams = []; foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $inParams[] = "{{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $value); } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; + if (! empty($inParams)) { + $filters[] = "{$escapedAttr} IN (".implode(', ', $inParams).')'; } break; @@ -3829,12 +3810,12 @@ private function parseQueries(array $queries, string $type = 'event'): array $chType = $this->getParamType($attribute); $inParams = []; foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $inParams[] = "{{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $value); } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} NOT IN (" . implode(', ', $inParams) . ")"; + if (! empty($inParams)) { + $filters[] = "{$escapedAttr} NOT IN (".implode(', ', $inParams).')'; } break; @@ -3854,10 +3835,10 @@ private function parseQueries(array $queries, string $type = 'event'): array $this->validateAttributeName($attribute, $type); $escapedAttr = $this->escapeIdentifier($attribute); $needle = $values[0] ?? null; - if (!is_string($needle)) { + if (! is_string($needle)) { throw new Exception("startsWith needle must be a string for attribute '{$attribute}'"); } - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "startsWith({$escapedAttr}, {{$paramName}:String})"; $params[$paramName] = $needle; break; @@ -3866,17 +3847,17 @@ private function parseQueries(array $queries, string $type = 'event'): array $this->validateAttributeName($attribute, $type); $escapedAttr = $this->escapeIdentifier($attribute); $needle = $values[0] ?? null; - if (!is_string($needle)) { + if (! is_string($needle)) { throw new Exception("endsWith needle must be a string for attribute '{$attribute}'"); } - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "endsWith({$escapedAttr}, {{$paramName}:String})"; $params[$paramName] = $needle; break; case Query::TYPE_LIMIT: - $limitVal = is_array($values) && !empty($values) ? $values[0] : $values; - if (!\is_int($limitVal)) { + $limitVal = is_array($values) && ! empty($values) ? $values[0] : $values; + if (! \is_int($limitVal)) { throw new Exception('Invalid limit value. Expected int'); } $limit = $limitVal; @@ -3884,8 +3865,8 @@ private function parseQueries(array $queries, string $type = 'event'): array break; case Query::TYPE_OFFSET: - $offsetVal = is_array($values) && !empty($values) ? $values[0] : $values; - if (!\is_int($offsetVal)) { + $offsetVal = is_array($values) && ! empty($values) ? $values[0] : $values; + if (! \is_int($offsetVal)) { throw new Exception('Invalid offset value. Expected int'); } $offset = $offsetVal; @@ -3895,16 +3876,16 @@ private function parseQueries(array $queries, string $type = 'event'): array case UsageQuery::TYPE_GROUP_BY_INTERVAL: $this->validateAttributeName($attribute, $type); $interval = $values[0] ?? '1h'; - if (!is_string($interval)) { + if (! is_string($interval)) { throw new Exception( - 'Invalid groupByInterval interval: expected string, got ' . get_debug_type($interval) . '. Allowed: ' - . implode(', ', array_keys(UsageQuery::VALID_INTERVALS)) + 'Invalid groupByInterval interval: expected string, got '.get_debug_type($interval).'. Allowed: ' + .implode(', ', array_keys(UsageQuery::VALID_INTERVALS)) ); } - if (!isset(UsageQuery::VALID_INTERVALS[$interval])) { + if (! isset(UsageQuery::VALID_INTERVALS[$interval])) { throw new Exception( "Invalid groupByInterval interval '{$interval}'. Allowed: " - . implode(', ', array_keys(UsageQuery::VALID_INTERVALS)) + .implode(', ', array_keys(UsageQuery::VALID_INTERVALS)) ); } $groupByInterval = $interval; @@ -3912,7 +3893,7 @@ private function parseQueries(array $queries, string $type = 'event'): array case UsageQuery::TYPE_GROUP_BY: $this->validateGroupByAttribute($attribute, $type); - if (!in_array($attribute, $groupBy, true)) { + if (! in_array($attribute, $groupBy, true)) { $groupBy[] = $attribute; } break; @@ -3924,7 +3905,7 @@ private function parseQueries(array $queries, string $type = 'event'): array 'params' => $params, ]; - if (!empty($orderBy)) { + if (! empty($orderBy)) { $result['orderBy'] = $orderBy; $result['orderAttributes'] = $orderAttributes; } @@ -3941,7 +3922,7 @@ private function parseQueries(array $queries, string $type = 'event'): array $result['groupByInterval'] = $groupByInterval; } - if (!empty($groupBy)) { + if (! empty($groupBy)) { $result['groupBy'] = $groupBy; } @@ -3956,8 +3937,7 @@ private function parseQueries(array $queries, string $type = 'event'): array /** * Parse ClickHouse JSON results into Metric array. * - * @param string $result - * @param string $type 'event' or 'gauge' — used to set the type attribute on parsed metrics + * @param string $type 'event' or 'gauge' — used to set the type attribute on parsed metrics * @return array */ private function parseResults(string $result, string $type = 'event'): array @@ -3968,7 +3948,7 @@ private function parseResults(string $result, string $type = 'event'): array $json = json_decode($result, true); - if (!is_array($json) || !isset($json['data']) || !is_array($json['data'])) { + if (! is_array($json) || ! isset($json['data']) || ! is_array($json['data'])) { return []; } @@ -3976,7 +3956,7 @@ private function parseResults(string $result, string $type = 'event'): array $metrics = []; foreach ($rows as $row) { - if (!is_array($row)) { + if (! is_array($row)) { continue; } $document = []; @@ -3987,9 +3967,9 @@ private function parseResults(string $result, string $type = 'event'): array } elseif ($key === 'value') { $document[$key] = $value !== null ? (int) $value : null; } elseif ($key === 'time') { - $parsedTime = (string)$value; + $parsedTime = (string) $value; if (strpos($parsedTime, 'T') === false) { - $parsedTime = str_replace(' ', 'T', $parsedTime) . '+00:00'; + $parsedTime = str_replace(' ', 'T', $parsedTime).'+00:00'; } $document[$key] = $parsedTime; } elseif ($key === 'tags') { @@ -4020,8 +4000,7 @@ private function parseResults(string $result, string $type = 'event'): array /** * Get the SELECT column list for queries. * - * @param string $type 'event' or 'gauge' - * @return string + * @param string $type 'event' or 'gauge' */ private function getSelectColumns(string $type = 'event'): string { @@ -4036,27 +4015,13 @@ private function getSelectColumns(string $type = 'event'): string } } - if ($this->sharedTables) { - $columns[] = $this->escapeIdentifier('tenant'); + foreach ($this->extraQueryableColumns() as $col) { + $columns[] = $this->escapeIdentifier($col); } 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). @@ -4068,13 +4033,15 @@ private function getTenantFilter(): string * with event-only attributes (path/method/status/etc.) leave existing * daily rows in place. * - * @param array $queries - * @param string|null $type 'event', 'gauge', or null (purge both) + * @param array $queries + * @param string|null $type 'event', 'gauge', or null (purge both) + * * @throws Exception */ public function purge(array $queries = [], ?string $type = null): bool { $this->setOperationContext('purge()'); + $queries = $this->scopeQueries($queries); $typesToPurge = []; if ($type === Usage::TYPE_EVENT || $type === null) { @@ -4086,7 +4053,7 @@ public function purge(array $queries = [], ?string $type = null): bool foreach ($typesToPurge as $purgeType) { $tableName = $this->getTableForType($purgeType); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $escapedTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($tableName); $parsed = $this->parseQueries($queries, $purgeType); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); @@ -4117,8 +4084,8 @@ public function purge(array $queries = [], ?string $type = null): bool * staleness is worse than degradation, and the next ingest cycle adds * data back. * - * @param array $queries - * @param array $safeAttributes + * @param array $queries + * @param array $safeAttributes * @return array */ private function buildStaleRollupPurgeQueries(array $queries, array $safeAttributes): array @@ -4130,6 +4097,7 @@ private function buildStaleRollupPurgeQueries(array $queries, array $safeAttribu $filtered[] = $query; } } + return $this->translateTimeQueriesToDayBoundaries($filtered); } @@ -4153,7 +4121,8 @@ 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 + * * @throws Exception */ private function purgeDaily(array $queries): void @@ -4163,30 +4132,44 @@ private function purgeDaily(array $queries): void fn (string $col): bool => $col !== 'value' )); $safeAttributes = array_merge(['time'], $safeAttributes); - if ($this->sharedTables) { - $safeAttributes[] = 'tenant'; + + // Tenant scopes the delete but never narrows it: a tenant-only daily + // delete wipes every metric for that tenant. Keep it out of the + // compatibility / no-op decision and re-attach it to the final query. + $tenantQueries = []; + $narrowingQueries = []; + foreach ($queries as $query) { + if (in_array($query->getAttribute(), $this->scopeOnlyAttributes(), true)) { + $tenantQueries[] = $query; + } else { + $narrowingQueries[] = $query; + } } $compatible = true; - foreach ($queries as $query) { + foreach ($narrowingQueries as $query) { $attr = $query->getAttribute(); if ($attr === '') { continue; } - if (!in_array($attr, $safeAttributes, true)) { + if (! in_array($attr, $safeAttributes, true)) { $compatible = false; break; } } - $dailyQueries = $compatible - ? $this->translateTimeQueriesToDayBoundaries($queries) - : $this->buildStaleRollupPurgeQueries($queries, $safeAttributes); + $dailyNarrowing = $compatible + ? $this->translateTimeQueriesToDayBoundaries($narrowingQueries) + : $this->buildStaleRollupPurgeQueries($narrowingQueries, $safeAttributes); - if (!empty($queries) && empty($dailyQueries)) { + // A non-empty caller filter that leaves no daily-expressible narrowing + // predicate must not fall through to a tenant-only (or unbounded) wipe. + if (! empty($narrowingQueries) && empty($dailyNarrowing)) { return; } + $dailyQueries = array_merge($dailyNarrowing, $tenantQueries); + $dailyTable = $this->buildTableReference($this->getEventsDailyTableName()); $parsed = $this->parseQueries($dailyQueries, Usage::TYPE_EVENT); diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index 2f2f9ac..fa46b56 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -6,10 +6,10 @@ use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Query as DatabaseQuery; +use Utopia\Query\Query; use Utopia\Usage\Metric; use Utopia\Usage\Usage; use Utopia\Usage\UsageQuery; -use Utopia\Query\Query; class Database extends SQL { @@ -36,10 +36,10 @@ public function healthCheck(): array { try { $databaseName = $this->db->getDatabase(); - if (!$this->db->exists($databaseName)) { + if (! $this->db->exists($databaseName)) { return [ 'healthy' => false, - 'error' => "Database '{$databaseName}' does not exist" + 'error' => "Database '{$databaseName}' does not exist", ]; } @@ -49,19 +49,19 @@ public function healthCheck(): array 'healthy' => false, 'database' => $databaseName, 'collection' => $collectionName, - 'error' => "Collection '{$collectionName}' is missing or empty in database '{$databaseName}'" + 'error' => "Collection '{$collectionName}' is missing or empty in database '{$databaseName}'", ]; } return [ 'healthy' => true, 'database' => $databaseName, - 'collection' => $collectionName + 'collection' => $collectionName, ]; } catch (\Exception $e) { return [ 'healthy' => false, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]; } } @@ -122,10 +122,9 @@ 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 - * @param string $type Metric type: 'event' or 'gauge' - * @param int $batchSize - * @return bool + * @param array}> $metrics + * @param string $type Metric type: 'event' or 'gauge' + * * @throws \Exception */ public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool @@ -152,7 +151,7 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b 'metric' => $metric['metric'], 'value' => $metric['value'], 'type' => $type, - 'time' => (new \DateTime())->format('Y-m-d H:i:s.v'), + 'time' => (new \DateTime)->format('Y-m-d H:i:s.v'), ], $columns); $documents[] = new Document($docData); @@ -173,13 +172,8 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b * * Stub implementation for Database adapter. * - * @param array $metrics - * @param string $interval - * @param string $startDate - * @param string $endDate - * @param array $queries - * @param bool $zeroFill - * @param string|null $type + * @param array $metrics + * @param array $queries * @return array}> */ public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array @@ -189,6 +183,7 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat foreach ($metrics as $metric) { $output[$metric] = ['total' => 0, 'data' => []]; } + return $output; } @@ -197,10 +192,7 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat * * Returns SUM for event metrics, latest value for gauge metrics. * - * @param string $metric - * @param array $queries - * @param string|null $type - * @return int + * @param array $queries */ public function getTotal(string $metric, array $queries = [], ?string $type = null): int { @@ -221,6 +213,7 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu if (empty($gaugeResults)) { return 0; } + return (int) ($gaugeResults[0]->getValue(0) ?? 0); } @@ -237,6 +230,7 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu foreach ($results as $result) { $sum += (int) ($result->getValue(0) ?? 0); } + return $sum; } @@ -251,14 +245,14 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu } } - if (!empty($eventResults) && !empty($gaugeResults)) { + if (! empty($eventResults) && ! empty($gaugeResults)) { throw new \Exception( "Metric '{$metric}' exists as both event and gauge. " - . "Specify \$type explicitly to avoid ambiguous aggregation." + .'Specify $type explicitly to avoid ambiguous aggregation.' ); } - if (!empty($gaugeResults)) { + if (! empty($gaugeResults)) { // find() returns rows in unspecified order; sort by time so the // "latest" gauge sample is deterministic. usort( @@ -266,6 +260,7 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu fn (Metric $a, Metric $b): int => strcmp($a->getTime() ?? '', $b->getTime() ?? '') ); $lastResult = end($gaugeResults); + return (int) ($lastResult->getValue(0) ?? 0); } @@ -280,9 +275,8 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu /** * Get totals for multiple metrics. * - * @param array $metrics - * @param array $queries - * @param string|null $type + * @param array $metrics + * @param array $queries * @return array */ public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array @@ -305,10 +299,8 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type * * Events-only by default — summing gauges is semantically meaningless. * - * @param array $queries - * @param string $attribute - * @param string $type 'event' or 'gauge' - * @return int + * @param array $queries + * @param string $type 'event' or 'gauge' */ public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int { @@ -326,7 +318,7 @@ public function sum(array $queries = [], string $attribute = 'value', string $ty /** * Find from daily table — Database adapter falls back to regular find for events. * - * @param array $queries + * @param array $queries * @return array */ public function findDaily(array $queries = []): array @@ -337,7 +329,7 @@ public function findDaily(array $queries = []): array /** * Sum multiple metrics from daily table — falls back to individual sumDaily calls. * - * @param array<\Utopia\Query\Query> $queries + * @param array<\Utopia\Query\Query> $queries * @return array */ public function sumDailyBatch(array $metrics, array $queries = []): array @@ -345,17 +337,16 @@ public function sumDailyBatch(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($metricQueries, 'value'); } + return $totals; } /** * Sum from daily table — Database adapter falls back to regular sum for events. * - * @param array $queries - * @param string $attribute - * @return int + * @param array $queries */ public function sumDaily(array $queries = [], string $attribute = 'value'): int { @@ -365,8 +356,9 @@ public function sumDaily(array $queries = [], string $attribute = 'value'): int /** * Convert Utopia\Query\Query to Utopia\Database\Query for use with the Database class. * - * @param array $queries + * @param array $queries * @return array + * * @throws \Exception When a groupBy attribute is not a valid dimension column, * or when groupBy is used without groupByInterval. */ @@ -386,14 +378,14 @@ private function convertQueriesToDatabase(array $queries): array $dbQueries[] = DatabaseQuery::equal($attribute, $values); break; case Query::TYPE_GREATER: - if (!empty($values)) { + if (! empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::greaterThan($attribute, $value); } break; case Query::TYPE_LESSER: - if (!empty($values)) { + if (! empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::lessThan($attribute, $value); @@ -413,7 +405,7 @@ private function convertQueriesToDatabase(array $queries): array $dbQueries[] = DatabaseQuery::contains($attribute, $values); break; case Query::TYPE_NOT_EQUAL: - if (!empty($values)) { + if (! empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::notEqual($attribute, $value); @@ -433,24 +425,24 @@ private function convertQueriesToDatabase(array $queries): array } break; case Query::TYPE_STARTS_WITH: - if (!empty($values) && is_string($values[0])) { + if (! empty($values) && is_string($values[0])) { $dbQueries[] = DatabaseQuery::startsWith($attribute, $values[0]); } break; case Query::TYPE_ENDS_WITH: - if (!empty($values) && is_string($values[0])) { + if (! empty($values) && is_string($values[0])) { $dbQueries[] = DatabaseQuery::endsWith($attribute, $values[0]); } break; case Query::TYPE_LESSER_EQUAL: - if (!empty($values)) { + if (! empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::lessThanEqual($attribute, $value); } break; case Query::TYPE_GREATER_EQUAL: - if (!empty($values)) { + if (! empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::greaterThanEqual($attribute, $value); @@ -463,14 +455,14 @@ private function convertQueriesToDatabase(array $queries): array $dbQueries[] = DatabaseQuery::orderAsc($attribute); break; case Query::TYPE_LIMIT: - if (!empty($values)) { + if (! empty($values)) { /** @var int|string $val */ $val = $values[0] ?? 0; $dbQueries[] = DatabaseQuery::limit((int) $val); } break; case Query::TYPE_OFFSET: - if (!empty($values)) { + if (! empty($values)) { /** @var int|string $val */ $val = $values[0] ?? 0; $dbQueries[] = DatabaseQuery::offset((int) $val); @@ -497,7 +489,8 @@ private function convertQueriesToDatabase(array $queries): array * for the Database adapter since both share one collection), and groupBy * does not push the aggregation hints down to SQL — it just validates them. * - * @param array $queries + * @param array $queries + * * @throws \Exception */ private function validateGroupByQueries(array $queries): void @@ -511,17 +504,16 @@ private function validateGroupByQueries(array $queries): void $attribute = $query->getAttribute(); - if (!in_array($attribute, $allowed, true)) { + if (! in_array($attribute, $allowed, true)) { throw new \Exception( - "Invalid groupBy attribute '{$attribute}'. Allowed: " . implode(', ', $allowed) + "Invalid groupBy attribute '{$attribute}'. Allowed: ".implode(', ', $allowed) ); } } } /** - * @param array $queries - * @param string|null $type + * @param array $queries */ public function purge(array $queries = [], ?string $type = null): bool { @@ -553,8 +545,7 @@ 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 array $queries - * @param string|null $type + * @param array $queries * @return array */ public function find(array $queries = [], ?string $type = null): array @@ -564,6 +555,7 @@ public function find(array $queries = [], ?string $type = null): array /** @var array $result */ $result = $this->db->getAuthorization()->skip(function () use ($queries) { $dbQueries = $this->convertQueriesToDatabase($queries); + return $this->db->find( collection: $this->collection, queries: $dbQueries, @@ -580,10 +572,8 @@ 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 array $queries - * @param string|null $type - * @param int|null $max Optional upper bound (inclusive) for the count - * @return int + * @param array $queries + * @param int|null $max Optional upper bound (inclusive) for the count */ public function count(array $queries = [], ?string $type = null, ?int $max = null): int { @@ -592,6 +582,7 @@ public function count(array $queries = [], ?string $type = null, ?int $max = nul /** @var int $count */ $count = $this->db->getAuthorization()->skip(function () use ($queries, $max) { $dbQueries = $this->convertQueriesToDatabase($queries); + return $this->db->count( collection: $this->collection, queries: $dbQueries, @@ -605,8 +596,7 @@ public function count(array $queries = [], ?string $type = null, ?int $max = nul /** * Append a `type = $type` filter to the query list when $type is non-null. * - * @param array $queries - * @param string|null $type + * @param array $queries * @return array */ private function withTypeFilter(array $queries, ?string $type): array @@ -616,7 +606,7 @@ private function withTypeFilter(array $queries, ?string $type): array } if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) { - throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: " . Usage::TYPE_EVENT . ', ' . Usage::TYPE_GAUGE); + throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: ".Usage::TYPE_EVENT.', '.Usage::TYPE_GAUGE); } return array_merge($queries, [Query::equal('type', [$type])]); @@ -624,43 +614,37 @@ private function withTypeFilter(array $queries, ?string $type): array /** * Set the namespace prefix for table names. - * - * @param string $namespace - * @return self */ public function setNamespace(string $namespace): self { $this->db->setNamespace($namespace); + return $this; } /** * Set the tenant ID for multi-tenant support. - * - * @param string|null $tenant - * @return self */ public function setTenant(?string $tenant): self { - if ($tenant !== null && !is_numeric($tenant)) { + if ($tenant !== null && ! is_numeric($tenant)) { throw new \InvalidArgumentException( - 'Database adapter requires a numeric tenant ID, got: ' . $tenant + 'Database adapter requires a numeric tenant ID, got: '.$tenant ); } $this->db->setTenant($tenant !== null ? (int) $tenant : null); + return $this; } /** * Enable or disable shared tables mode. - * - * @param bool $sharedTables - * @return self */ public function setSharedTables(bool $sharedTables): self { $this->db->setSharedTables($sharedTables); + return $this; } } diff --git a/src/Usage/Adapter/SQL.php b/src/Usage/Adapter/SQL.php index f7f2d6b..8c5d2f9 100644 --- a/src/Usage/Adapter/SQL.php +++ b/src/Usage/Adapter/SQL.php @@ -2,9 +2,9 @@ namespace Utopia\Usage\Adapter; +use Utopia\Database\Document; use Utopia\Usage\Adapter; use Utopia\Usage\Metric; -use Utopia\Database\Document; /** * Base SQL Adapter for Usage @@ -18,8 +18,6 @@ abstract class SQL extends Adapter /** * Get the collection/table name for usage metrics. - * - * @return string */ public function getCollectionName(): string { @@ -49,7 +47,7 @@ public function getGaugeAttributes(): array /** * Get attribute definitions for a specific type. * - * @param string $type 'event' or 'gauge' + * @param string $type 'event' or 'gauge' * @return array> */ public function getAttributes(string $type = 'event'): array @@ -60,7 +58,7 @@ public function getAttributes(string $type = 'event'): array /** * Get attribute documents for a specific type. * - * @param string $type 'event' or 'gauge' + * @param string $type 'event' or 'gauge' * @return array */ public function getAttributeDocuments(string $type = 'event'): array @@ -91,7 +89,7 @@ public function getGaugeIndexes(): array /** * Get index definitions for a specific type. * - * @param string $type 'event' or 'gauge' + * @param string $type 'event' or 'gauge' * @return array> */ public function getIndexes(string $type = 'event'): array @@ -102,7 +100,7 @@ public function getIndexes(string $type = 'event'): array /** * Get index documents for a specific type. * - * @param string $type 'event' or 'gauge' + * @param string $type 'event' or 'gauge' * @return array */ public function getIndexDocuments(string $type = 'event'): array @@ -113,8 +111,7 @@ public function getIndexDocuments(string $type = 'event'): array /** * Get a single attribute by ID from a specific schema. * - * @param string $id - * @param string $type 'event' or 'gauge' + * @param string $type 'event' or 'gauge' * @return array|null */ protected function getAttribute(string $id, string $type = 'event') @@ -132,8 +129,8 @@ protected function getAttribute(string $id, string $type = 'event') * Get SQL column definition for a given attribute ID. * This method is database-specific and must be implemented by each concrete adapter. * - * @param string $id Attribute identifier - * @param string $type 'event' or 'gauge' + * @param string $id Attribute identifier + * @param string $type 'event' or 'gauge' * @return string Database-specific column definition */ abstract protected function getColumnDefinition(string $id, string $type = 'event'): string; @@ -142,7 +139,7 @@ abstract protected function getColumnDefinition(string $id, string $type = 'even * Get all SQL column definitions for a specific type. * Uses the concrete adapter's implementation of getColumnDefinition. * - * @param string $type 'event' or 'gauge' + * @param string $type 'event' or 'gauge' * @return array */ protected function getAllColumnDefinitions(string $type = 'event'): array diff --git a/src/Usage/Adapter/SharedTables.php b/src/Usage/Adapter/SharedTables.php new file mode 100644 index 0000000..f2f87c3 --- /dev/null +++ b/src/Usage/Adapter/SharedTables.php @@ -0,0 +1,108 @@ +scopeTenant = $tenant; + + return $callback($scoped); + } + + protected function tenantColumnDefs(): array + { + return ['tenant Nullable(String)']; + } + + protected function keyPrefix(): array + { + return ['tenant']; + } + + protected function extraQueryableColumns(): array + { + return ['tenant']; + } + + protected function scopeOnlyAttributes(): array + { + return ['tenant']; + } + + protected function scopeQueries(array $queries): array + { + if ($this->scopeTenant !== null) { + $queries[] = Query::equal('tenant', [$this->scopeTenant]); + } + + return $queries; + } + + protected function decorateRow(array $row, array $metricData): array + { + $row['tenant'] = $this->resolveTenantFromMetric($metricData); + + return $row; + } + + /** + * Resolve the per-row tenant from a metric entry's `$tenant` key. + * + * @param array $metricData + */ + private function resolveTenantFromMetric(array $metricData): ?string + { + $tenant = $metricData['$tenant'] ?? null; + + if ($tenant === null) { + return null; + } + + if (is_string($tenant)) { + return $tenant; + } + + if (is_int($tenant) || is_float($tenant)) { + return (string) $tenant; + } + + return null; + } +} diff --git a/src/Usage/Metric.php b/src/Usage/Metric.php index c597685..226e3c5 100644 --- a/src/Usage/Metric.php +++ b/src/Usage/Metric.php @@ -108,6 +108,7 @@ public function __construct(array $input = []) public function getId(): string { $id = $this->getAttribute('$id', ''); + return is_string($id) ? $id : ''; } @@ -122,6 +123,7 @@ public function getId(): string public function getMetric(): string { $metric = $this->getAttribute('metric', ''); + return is_string($metric) ? $metric : ''; } @@ -143,6 +145,7 @@ public function getValue(int|float|null $default = null): int|float|null if (is_int($value) || is_float($value)) { return $value; } + return $default; } @@ -163,6 +166,7 @@ public function getValue(int|float|null $default = null): int|float|null public function getType(): string { $type = $this->getAttribute('type', 'event'); + return is_string($type) ? $type : 'event'; } @@ -178,6 +182,7 @@ public function getType(): string public function getTime(): ?string { $time = $this->getAttribute('time', null); + return is_string($time) ? $time : null; } @@ -189,6 +194,7 @@ public function getTime(): ?string public function getPath(): ?string { $path = $this->getAttribute('path', null); + return is_string($path) ? $path : null; } @@ -200,6 +206,7 @@ public function getPath(): ?string public function getMethod(): ?string { $method = $this->getAttribute('method', null); + return is_string($method) ? $method : null; } @@ -211,6 +218,7 @@ public function getMethod(): ?string public function getStatus(): ?string { $status = $this->getAttribute('status', null); + return is_string($status) ? $status : null; } @@ -222,6 +230,7 @@ public function getStatus(): ?string public function getResource(): ?string { $resource = $this->getAttribute('resource', null); + return is_string($resource) ? $resource : null; } @@ -233,6 +242,7 @@ public function getResource(): ?string public function getResourceId(): ?string { $resourceId = $this->getAttribute('resourceId', null); + return is_string($resourceId) ? $resourceId : null; } @@ -244,28 +254,27 @@ public function getResourceId(): ?string public function getCountry(): ?string { $country = $this->getAttribute('country', null); + return is_string($country) ? $country : null; } /** * Get service (event metrics only). - * - * @return string|null */ public function getService(): ?string { $v = $this->getAttribute('service', null); + return is_string($v) ? $v : null; } /** * Get internal resource id (event/gauge metrics). - * - * @return string|null */ public function getResourceInternalId(): ?string { $v = $this->getAttribute('resourceInternalId', null); + return is_string($v) ? $v : null; } @@ -275,6 +284,7 @@ public function getResourceInternalId(): ?string public function getTeamId(): ?string { $v = $this->getAttribute('teamId', null); + return is_string($v) ? $v : null; } @@ -284,6 +294,7 @@ public function getTeamId(): ?string public function getTeamInternalId(): ?string { $v = $this->getAttribute('teamInternalId', null); + return is_string($v) ? $v : null; } @@ -293,6 +304,7 @@ public function getTeamInternalId(): ?string public function getRegion(): ?string { $v = $this->getAttribute('region', null); + return is_string($v) ? $v : null; } @@ -302,6 +314,7 @@ public function getRegion(): ?string public function getHostname(): ?string { $v = $this->getAttribute('hostname', null); + return is_string($v) ? $v : null; } @@ -311,6 +324,7 @@ public function getHostname(): ?string public function getOsCode(): ?string { $v = $this->getAttribute('osCode', null); + return is_string($v) ? $v : null; } @@ -320,6 +334,7 @@ public function getOsCode(): ?string public function getOsName(): ?string { $v = $this->getAttribute('osName', null); + return is_string($v) ? $v : null; } @@ -329,6 +344,7 @@ public function getOsName(): ?string public function getOsVersion(): ?string { $v = $this->getAttribute('osVersion', null); + return is_string($v) ? $v : null; } @@ -338,6 +354,7 @@ public function getOsVersion(): ?string public function getClientType(): ?string { $v = $this->getAttribute('clientType', null); + return is_string($v) ? $v : null; } @@ -347,6 +364,7 @@ public function getClientType(): ?string public function getClientCode(): ?string { $v = $this->getAttribute('clientCode', null); + return is_string($v) ? $v : null; } @@ -356,6 +374,7 @@ public function getClientCode(): ?string public function getClientName(): ?string { $v = $this->getAttribute('clientName', null); + return is_string($v) ? $v : null; } @@ -365,6 +384,7 @@ public function getClientName(): ?string public function getClientVersion(): ?string { $v = $this->getAttribute('clientVersion', null); + return is_string($v) ? $v : null; } @@ -374,6 +394,7 @@ public function getClientVersion(): ?string public function getClientEngine(): ?string { $v = $this->getAttribute('clientEngine', null); + return is_string($v) ? $v : null; } @@ -383,6 +404,7 @@ public function getClientEngine(): ?string public function getClientEngineVersion(): ?string { $v = $this->getAttribute('clientEngineVersion', null); + return is_string($v) ? $v : null; } @@ -392,6 +414,7 @@ public function getClientEngineVersion(): ?string public function getDeviceName(): ?string { $v = $this->getAttribute('deviceName', null); + return is_string($v) ? $v : null; } @@ -401,6 +424,7 @@ public function getDeviceName(): ?string public function getDeviceBrand(): ?string { $v = $this->getAttribute('deviceBrand', null); + return is_string($v) ? $v : null; } @@ -410,6 +434,7 @@ public function getDeviceBrand(): ?string public function getDeviceModel(): ?string { $v = $this->getAttribute('deviceModel', null); + return is_string($v) ? $v : null; } @@ -724,7 +749,7 @@ public static function getEventIndexes(): array return array_map( static function (string $col) use ($setIndexed): array { $entry = [ - '$id' => 'index-' . $col, + '$id' => 'index-'.$col, 'type' => 'key', 'attributes' => [$col], 'indexType' => in_array($col, $setIndexed, true) ? 'set(0)' : 'bloom_filter', @@ -732,6 +757,7 @@ static function (string $col) use ($setIndexed): array { if ($col === 'path') { $entry['lengths'] = [255]; } + return $entry; }, $indexed, @@ -751,7 +777,7 @@ public static function getGaugeIndexes(): array return array_map( static fn (string $col): array => [ - '$id' => 'index-' . $col, + '$id' => 'index-'.$col, 'type' => 'key', 'attributes' => [$col], 'indexType' => in_array($col, $setIndexed, true) ? 'set(0)' : 'bloom_filter', @@ -785,6 +811,7 @@ public static function getIndexes(): array * @param array $tags * @param string $type 'event' or 'gauge' * @return array + * * @throws \Exception When an unknown column key is present in $tags. */ public static function extractColumns(array $tags, string $type): array @@ -808,7 +835,7 @@ public static function extractColumns(array $tags, string $type): array $columns[$col] = $val; } - if (!empty($tags)) { + if (! empty($tags)) { $unknown = array_key_first($tags); throw new \Exception("Unknown column '{$unknown}' for {$type}"); } @@ -828,6 +855,7 @@ public static function extractColumns(array $tags, string $type): array * * @param array $data The metric data to validate * @param string $type The metric type ('event' or 'gauge') to validate against + * * @throws \Exception If validation fails */ public static function validate(array $data, string $type = 'event'): void @@ -843,12 +871,12 @@ public static function validate(array $data, string $type = 'event'): void $size = $attribute['size'] ?? 0; // Check if required attribute is present - if ($required && !isset($data[$attrId])) { + if ($required && ! isset($data[$attrId])) { throw new \Exception("Required attribute '{$attrId}' is missing"); } // Skip validation if not present and not required - if (!isset($data[$attrId])) { + if (! isset($data[$attrId])) { continue; } @@ -871,8 +899,8 @@ public static function validate(array $data, string $type = 'event'): void */ private static function validateStringAttribute(string $attrId, mixed $value, int $size): void { - if (!is_string($value)) { - throw new \Exception("Attribute '{$attrId}' must be a string, got " . gettype($value)); + if (! is_string($value)) { + throw new \Exception("Attribute '{$attrId}' must be a string, got ".gettype($value)); } if ($size > 0 && strlen($value) > $size) { @@ -887,8 +915,8 @@ private static function validateStringAttribute(string $attrId, mixed $value, in */ private static function validateIntegerAttribute(string $attrId, mixed $value): void { - if (!is_int($value)) { - throw new \Exception("Attribute '{$attrId}' must be an integer, got " . gettype($value)); + if (! is_int($value)) { + throw new \Exception("Attribute '{$attrId}' must be an integer, got ".gettype($value)); } } @@ -903,8 +931,8 @@ private static function validateDatetimeAttribute(string $attrId, mixed $value): return; // Valid DateTime object } - if (!is_string($value)) { - throw new \Exception("Attribute '{$attrId}' must be a DateTime object or string, got " . gettype($value)); + if (! is_string($value)) { + throw new \Exception("Attribute '{$attrId}' must be a DateTime object or string, got ".gettype($value)); } try { diff --git a/src/Usage/Usage.php b/src/Usage/Usage.php index 1715bd6..6313b8a 100644 --- a/src/Usage/Usage.php +++ b/src/Usage/Usage.php @@ -15,9 +15,11 @@ 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; @@ -61,6 +63,33 @@ public function getAdapter(): Adapter return $this->adapter; } + /** + * Run a callback with reads/purges scoped to a single tenant. + * + * Requires a multi-tenant adapter (one exposing withTenant(), e.g. + * SharedTables); on a single-tenant adapter the callback simply receives + * the unscoped instance. The callback is handed a scoped clone, so the + * scope cannot leak past the closure or across coroutines. + * + * @template T + * + * @param callable(self): T $callback + * @return T + */ + public function withTenant(?string $tenant, callable $callback): mixed + { + if (! method_exists($this->adapter, 'withTenant')) { + return $callback($this); + } + + return $this->adapter->withTenant($tenant, function (Adapter $scopedAdapter) use ($callback) { + $scoped = clone $this; + $scoped->adapter = $scopedAdapter; + + return $callback($scoped); + }); + } + /** * Check adapter health and connection status. * @@ -87,10 +116,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 - * @param string $type Metric type: 'event' or 'gauge' - * @param int $batchSize Maximum number of metrics per INSERT statement - * @return bool + * @param array}> $metrics + * @param string $type Metric type: 'event' or 'gauge' + * @param int $batchSize Maximum number of metrics per INSERT statement + * * @throws \Exception */ public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool @@ -101,14 +130,15 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b /** * Get time series data for metrics. * - * @param array $metrics List of metric names - * @param string $interval '1h' or '1d' - * @param string $startDate Start datetime - * @param string $endDate End datetime - * @param array<\Utopia\Query\Query> $queries Additional filters - * @param bool $zeroFill Whether to fill gaps with zero values - * @param string|null $type Metric type: 'event', 'gauge', or null (query both) + * @param array $metrics List of metric names + * @param string $interval '1h' or '1d' + * @param string $startDate Start datetime + * @param string $endDate End datetime + * @param array<\Utopia\Query\Query> $queries Additional filters + * @param bool $zeroFill Whether to fill gaps with zero values + * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return array}> + * * @throws \Exception */ public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array @@ -119,10 +149,10 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat /** * Get total value for a single metric. * - * @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 + * @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) + * * @throws \Exception */ public function getTotal(string $metric, array $queries = [], ?string $type = null): int @@ -133,10 +163,11 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu /** * Get totals for multiple metrics in a single query. * - * @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) + * @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 @@ -148,8 +179,9 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type * Purge usage metrics matching the given queries. * When no queries are provided, all metrics are deleted. * - * @param array<\Utopia\Query\Query> $queries - * @param string|null $type Metric type: 'event', 'gauge', or null (purge both) + * @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 @@ -160,9 +192,10 @@ public function purge(array $queries = [], ?string $type = null): bool /** * Find metrics using Query objects. * - * @param array<\Utopia\Query\Query> $queries - * @param string|null $type Metric type: 'event', 'gauge', or null (query both) + * @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 @@ -177,10 +210,10 @@ 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 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 + * @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) + * * @throws \Exception */ public function count(array $queries = [], ?string $type = null, ?int $max = null): int @@ -196,16 +229,16 @@ 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 array<\Utopia\Query\Query> $queries - * @param string $attribute Attribute to sum (default: 'value') - * @param string $type Metric type: 'event' or 'gauge' - * @return int + * @param array<\Utopia\Query\Query> $queries + * @param string $attribute Attribute to sum (default: 'value') + * @param string $type Metric type: 'event' or 'gauge' + * * @throws \Exception */ public function sum(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); + throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: ".self::TYPE_EVENT.', '.self::TYPE_GAUGE); } return $this->adapter->sum($queries, $attribute, $type); @@ -219,8 +252,9 @@ 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 array<\Utopia\Query\Query> $queries + * @param array<\Utopia\Query\Query> $queries * @return array + * * @throws \Exception */ public function findDaily(array $queries = []): array @@ -237,9 +271,9 @@ 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 array<\Utopia\Query\Query> $queries - * @param string $attribute Attribute to sum (default: 'value') - * @return int + * @param array<\Utopia\Query\Query> $queries + * @param string $attribute Attribute to sum (default: 'value') + * * @throws \Exception */ public function sumDaily(array $queries = [], string $attribute = 'value'): int @@ -253,9 +287,10 @@ 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 array $metrics List of metric names - * @param array<\Utopia\Query\Query> $queries Additional filters (e.g. date range) + * @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 @@ -263,63 +298,6 @@ public function sumDailyBatch(array $metrics, array $queries = []): array return $this->adapter->sumDailyBatch($metrics, $queries); } - /** - * Set the namespace prefix for table names. - * - * Flushes the buffer first so any pending metrics are written under the - * previous namespace — buffered entries don't carry adapter context. - * - * @param string $namespace - * @return $this - * @throws \Exception - */ - public function setNamespace(string $namespace): self - { - $this->flush(); - if (method_exists($this->adapter, 'setNamespace')) { - $this->adapter->setNamespace($namespace); - } - return $this; - } - - /** - * 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; - } - - /** - * Enable or disable shared tables mode (multi-tenant with tenant column). - * - * Flushes the buffer first so any pending metrics are written under the - * previous mode — buffered entries don't carry adapter context. - * - * @param bool $sharedTables - * @return $this - * @throws \Exception - */ - public function setSharedTables(bool $sharedTables): self - { - $this->flush(); - if (method_exists($this->adapter, 'setSharedTables')) { - $this->adapter->setSharedTables($sharedTables); - } - return $this; - } - /** * Enable parity sampling for routed reads. At rate=0 the sampler is * disabled (default). At rate>0 each routed read is re-executed against @@ -327,11 +305,12 @@ public function setSharedTables(bool $sharedTables): self * entry when totals diverge by more than 1%. Pass 1.0 for every-read * sampling (CI use) or small values (0.01) for production canaries. * - * @param float $rate 0.0 (off) … 1.0 (every read) + * @param float $rate 0.0 (off) … 1.0 (every read) */ public function setDualReadSampleRate(float $rate): self { $this->adapter->setDualReadSampleRate($rate); + return $this; } @@ -342,13 +321,14 @@ public function setDualReadSampleRate(float $rate): self * 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 + * @param string $metric Metric name + * @param int $value Value + * @param string $type Metric type: 'event' or 'gauge' + * @param array $tags Optional tags + * @param string|null $tenant Per-row tenant (shared-tables mode); ignored + * by single-tenant adapters */ - public function collect(string $metric, int $value, string $type, array $tags = []): self + public function collect(string $metric, int $value, string $type, array $tags = [], ?string $tenant = null): self { if (empty($metric)) { throw new \InvalidArgumentException('Metric name cannot be empty'); @@ -357,32 +337,31 @@ public function collect(string $metric, int $value, string $type, array $tags = 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); + 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; + $tagsHash = ! empty($tags) ? md5(json_encode($tags, JSON_THROW_ON_ERROR)) : ''; + // Tenant is part of the identity so a single buffer can hold many + // tenants at once — callers no longer flush between tenant switches. + $key = $metric.':'.$type.':'.$tagsHash.':'.($tenant ?? ''); + + $entry = [ + 'metric' => $metric, + 'value' => $value, + 'type' => $type, + 'tags' => $tags, + ]; + // Only stamp the tenant when explicitly provided. + if ($tenant !== null) { + $entry['$tenant'] = $tenant; + } - 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, - ]; - } + if ($type === self::TYPE_EVENT && isset($this->buffer[$key])) { + // Additive: sum values for the same metric + tags + tenant combination + $this->buffer[$key]['value'] += $value; } else { - // Gauge: last-write-wins - $this->buffer[$key] = [ - 'metric' => $metric, - 'value' => $value, - 'type' => $type, - 'tags' => $tags, - ]; + // Event (first sighting) or gauge (last-write-wins) + $this->buffer[$key] = $entry; } $this->bufferCount++; @@ -403,12 +382,14 @@ public function collect(string $metric, int $value, string $type, array $tags = * 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; } @@ -432,7 +413,7 @@ public function flush(): bool $overallResult = true; // Flush events — clear buffer entries only on success. - if (!empty($events)) { + if (! empty($events)) { if ($this->adapter->addBatch($events, self::TYPE_EVENT)) { foreach ($eventKeys as $key) { unset($this->buffer[$key]); @@ -443,7 +424,7 @@ public function flush(): bool } // Flush gauges — clear buffer entries only on success. - if (!empty($gauges)) { + if (! empty($gauges)) { if ($this->adapter->addBatch($gauges, self::TYPE_GAUGE)) { foreach ($gaugeKeys as $key) { unset($this->buffer[$key]); @@ -465,8 +446,6 @@ public function flush(): bool * 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 { @@ -484,8 +463,6 @@ public function shouldFlush(): bool /** * Get the number of collect() calls since the last flush. - * - * @return int */ public function getBufferCount(): int { @@ -494,8 +471,6 @@ public function getBufferCount(): int /** * Get the number of unique metric entries in the buffer. - * - * @return int */ public function getBufferSize(): int { @@ -505,8 +480,7 @@ public function getBufferSize(): int /** * Set the flush threshold (number of collect() calls before flush is recommended). * - * @param int $threshold Must be >= 1 - * @return self + * @param int $threshold Must be >= 1 */ public function setFlushThreshold(int $threshold): self { @@ -514,14 +488,14 @@ public function setFlushThreshold(int $threshold): self 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 + * @param int $seconds Must be >= 1 */ public function setFlushInterval(int $seconds): self { @@ -529,13 +503,12 @@ public function setFlushInterval(int $seconds): self 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 { @@ -544,8 +517,6 @@ public function getFlushThreshold(): int /** * Get the flush interval in seconds. - * - * @return int */ public function getFlushInterval(): int { diff --git a/src/Usage/UsageQuery.php b/src/Usage/UsageQuery.php index 1976a13..c887b3f 100644 --- a/src/Usage/UsageQuery.php +++ b/src/Usage/UsageQuery.php @@ -30,6 +30,7 @@ class UsageQuery extends Query { public const TYPE_GROUP_BY_INTERVAL = 'groupByInterval'; + public const TYPE_GROUP_BY = 'groupBy'; /** @@ -64,15 +65,14 @@ public static function isMethod(string $value): bool * When passed to `find()`, this switches the adapter to return time-bucketed * aggregated results instead of raw rows. * - * @param string $attribute The time attribute to bucket (usually 'time') - * @param string $interval The bucket size: '1m', '5m', '15m', '1h', '1d', '1w', '1M' - * @return self + * @param string $attribute The time attribute to bucket (usually 'time') + * @param string $interval The bucket size: '1m', '5m', '15m', '1h', '1d', '1w', '1M' */ public static function groupByInterval(string $attribute, string $interval): self { - if (!isset(self::VALID_INTERVALS[$interval])) { + if (! isset(self::VALID_INTERVALS[$interval])) { throw new \InvalidArgumentException( - "Invalid interval '{$interval}'. Allowed: " . implode(', ', array_keys(self::VALID_INTERVALS)) + "Invalid interval '{$interval}'. Allowed: ".implode(', ', array_keys(self::VALID_INTERVALS)) ); } @@ -81,9 +81,6 @@ public static function groupByInterval(string $attribute, string $interval): sel /** * Check if a query is a groupByInterval query. - * - * @param Query $query - * @return bool */ public static function isGroupByInterval(Query $query): bool { @@ -96,7 +93,7 @@ public static function isGroupByInterval(Query $query): bool * Queries parsed via `Query::parse()` are base `Query` objects rather than * `UsageQuery` instances, so we match on the method string alone. * - * @param array $queries + * @param array $queries * @return Query|null The groupByInterval query, or null if not present */ public static function extractGroupByInterval(array $queries): ?Query @@ -115,13 +112,13 @@ public static function extractGroupByInterval(array $queries): ?Query * * Returns the remaining queries that should be processed normally. * - * @param array $queries + * @param array $queries * @return array */ public static function removeGroupByInterval(array $queries): array { return array_values(array_filter($queries, function (Query $query) { - return !self::isGroupByInterval($query); + return ! self::isGroupByInterval($query); })); } @@ -132,8 +129,7 @@ public static function removeGroupByInterval(array $queries): array * supplied via `groupByInterval`. Multiple `groupBy` queries may be * combined to bucket by several dimensions at once (e.g. service x status). * - * @param string $attribute The dimension column to bucket on (service, path, status, ...). - * @return self + * @param string $attribute The dimension column to bucket on (service, path, status, ...). */ public static function groupBy(string $attribute): self { @@ -142,9 +138,6 @@ public static function groupBy(string $attribute): self /** * Check if a query is a groupBy query. - * - * @param Query $query - * @return bool */ public static function isGroupBy(Query $query): bool { @@ -158,7 +151,7 @@ public static function isGroupBy(Query $query): bool * this returns every match rather than the single-instance form used by * groupByInterval. * - * @param array $queries + * @param array $queries * @return array */ public static function extractGroupBy(array $queries): array @@ -171,13 +164,13 @@ public static function extractGroupBy(array $queries): array /** * Remove all groupBy queries from an array of queries. * - * @param array $queries + * @param array $queries * @return array */ public static function removeGroupBy(array $queries): array { return array_values(array_filter($queries, function (Query $query) { - return !self::isGroupBy($query); + return ! self::isGroupBy($query); })); } } diff --git a/tests/Benchmark/BenchmarkBase.php b/tests/Benchmark/BenchmarkBase.php index a31c72a..dc8c7db 100644 --- a/tests/Benchmark/BenchmarkBase.php +++ b/tests/Benchmark/BenchmarkBase.php @@ -6,6 +6,7 @@ use ReflectionClass; use RuntimeException; use Throwable; +use Utopia\Tests\Usage\Adapter\ScopedClickHouse; use Utopia\Usage\Adapter\ClickHouse as ClickHouseAdapter; use Utopia\Usage\Usage; @@ -49,20 +50,7 @@ abstract class BenchmarkBase extends TestCase protected function setUp(): void { - $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); - - $this->adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure); - $this->adapter->setNamespace($this->namespace); - $this->adapter->setSharedTables(true); - $this->adapter->setTenant($this->tenant); - - if ($database = getenv('CLICKHOUSE_DATABASE')) { - $this->adapter->setDatabase($database); - } + $this->adapter = ScopedClickHouse::fromEnv($this->namespace, $this->tenant); $this->usage = new Usage($this->adapter); $this->usage->setup(); @@ -89,7 +77,7 @@ protected function seedEventRows(int $rows, ?string $metric = null): void { $metric ??= $this->metric; - $reflection = new ReflectionClass($this->adapter); + $reflection = new ReflectionClass(ClickHouseAdapter::class); $getEvents = $reflection->getMethod('getEventsTableName'); $getEvents->setAccessible(true); @@ -123,7 +111,7 @@ protected function seedEventRows(int $rows, ?string $metric = null): void */ protected function seedGaugeRows(int $rows, string $metric = 'storage'): void { - $reflection = new ReflectionClass($this->adapter); + $reflection = new ReflectionClass(ClickHouseAdapter::class); $getGauges = $reflection->getMethod('getGaugesTableName'); $getGauges->setAccessible(true); @@ -157,7 +145,7 @@ protected function seedGaugeRows(int $rows, string $metric = 'storage'): void */ protected function runRawSql(string $sql): void { - $reflection = new ReflectionClass($this->adapter); + $reflection = new ReflectionClass(ClickHouseAdapter::class); $query = $reflection->getMethod('query'); $query->setAccessible(true); $query->invoke($this->adapter, $sql, []); @@ -247,7 +235,7 @@ protected function captureCHStats(string $queryId): array . "FORMAT JSON"; try { - $reflection = new ReflectionClass($this->adapter); + $reflection = new ReflectionClass(ClickHouseAdapter::class); $query = $reflection->getMethod('query'); $query->setAccessible(true); $raw = $query->invoke($this->adapter, $sql, []); @@ -286,7 +274,7 @@ protected function captureProjectionsUsed(string $queryId): array . "ORDER BY event_time DESC LIMIT 1 FORMAT JSON"; try { - $reflection = new ReflectionClass($this->adapter); + $reflection = new ReflectionClass(ClickHouseAdapter::class); $query = $reflection->getMethod('query'); $query->setAccessible(true); $raw = $query->invoke($this->adapter, $sql, []); diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index c145765..4ecda91 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -6,6 +6,7 @@ use Utopia\Query\Query; use Utopia\Tests\Usage\UsageBase; use Utopia\Usage\Adapter\ClickHouse as ClickHouseAdapter; +use Utopia\Usage\Adapter\SharedTables; use Utopia\Usage\Usage; use Utopia\Usage\UsageQuery; @@ -22,9 +23,7 @@ protected function initializeUsage(): void $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false); - $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure); - $adapter->setNamespace('utopia_usage'); - $adapter->setTenant('1'); + $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure, 'utopia_usage'); // Optional customization via env vars if ($database = getenv('CLICKHOUSE_DATABASE')) { @@ -43,10 +42,7 @@ public function testMetricTenantOverridesAdapterTenantInBatch(): void $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123); $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false); - $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure); - $adapter->setNamespace('utopia_usage_shared'); - $adapter->setSharedTables(true); - $adapter->setTenant('1'); + $adapter = new SharedTables($host, $username, $password, $port, $secure, 'utopia_usage_shared'); if ($database = getenv('CLICKHOUSE_DATABASE')) { $adapter->setDatabase($database); @@ -54,7 +50,6 @@ public function testMetricTenantOverridesAdapterTenantInBatch(): void $usage = new Usage($adapter); $usage->setup(); - $usage->purge(); $metrics = [ [ @@ -65,19 +60,54 @@ public function testMetricTenantOverridesAdapterTenantInBatch(): void ], ]; - $this->assertTrue($usage->addBatch($metrics, Usage::TYPE_EVENT)); + // Read scope is per-tenant via withTenant(); the write carries its + // tenant per-row, so it lands under tenant '2' regardless of scope. + $usage->withTenant('2', function (Usage $scoped) use ($usage, $metrics) { + $scoped->purge(); + $this->assertTrue($usage->addBatch($metrics, Usage::TYPE_EVENT)); + + $results = $scoped->find([ + \Utopia\Query\Query::equal('metric', ['tenant-override']), + ], Usage::TYPE_EVENT); + + $this->assertCount(1, $results); + $this->assertEquals('2', $results[0]->getTenant()); + + $scoped->purge(); + }); + } + + public function testWithTenantIsolatesReads(): void + { + $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); + + $adapter = new SharedTables($host, $username, $password, $port, $secure, 'utopia_usage_isolation'); + if ($database = getenv('CLICKHOUSE_DATABASE')) { + $adapter->setDatabase($database); + } - // Switch adapter scope to the metric tenant to verify the row was stored under the override - $adapter->setTenant('2'); + $usage = new Usage($adapter); + $usage->setup(); - $results = $usage->find([ - \Utopia\Query\Query::equal('metric', ['tenant-override']), + // One buffer, two tenants, one flush — the per-row tenant decides + // where each row lands. + $usage->addBatch([ + ['metric' => 'iso', 'value' => 10, '$tenant' => 'a', 'tags' => []], + ['metric' => 'iso', 'value' => 99, '$tenant' => 'b', 'tags' => []], ], Usage::TYPE_EVENT); - $this->assertCount(1, $results); - $this->assertEquals('2', $results[0]->getTenant()); + $a = $usage->withTenant('a', fn (Usage $u) => $u->getTotal('iso', [], Usage::TYPE_EVENT)); + $b = $usage->withTenant('b', fn (Usage $u) => $u->getTotal('iso', [], Usage::TYPE_EVENT)); - $usage->purge(); + $this->assertSame(10, $a); + $this->assertSame(99, $b); + + $usage->withTenant('a', fn (Usage $u) => $u->purge()); + $usage->withTenant('b', fn (Usage $u) => $u->purge()); } /** @@ -834,9 +864,7 @@ public function testCompression(): void $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123); $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false); - $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure); - $adapter->setNamespace('utopia_usage_compression_test'); - $adapter->setTenant('1'); + $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure, 'utopia_usage_compression_test'); if ($database = getenv('CLICKHOUSE_DATABASE')) { $adapter->setDatabase($database); @@ -890,9 +918,7 @@ public function testConnectionPooling(): void $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123); $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false); - $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure); - $adapter->setNamespace('utopia_usage_pooling_test'); - $adapter->setTenant('1'); + $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure, 'utopia_usage_pooling_test'); if ($database = getenv('CLICKHOUSE_DATABASE')) { $adapter->setDatabase($database); @@ -1021,9 +1047,7 @@ public function testRetryWithSuccessfulOperations(): void $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123); $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false); - $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure); - $adapter->setNamespace('utopia_usage_retry_test'); - $adapter->setTenant('1'); + $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure, 'utopia_usage_retry_test'); $adapter->setMaxRetries(2); $adapter->setRetryDelay(50); @@ -1055,10 +1079,8 @@ public function testErrorMessagesIncludeContext(): void $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123); $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false); - $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure); - $adapter->setNamespace('utopia_usage_error_test'); + $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure, 'utopia_usage_error_test'); $adapter->setDatabase('nonexistent_db_for_testing_errors_12345'); - $adapter->setTenant('1'); $adapter->setMaxRetries(0); // Disable retries for faster test $usage = new Usage($adapter); @@ -1089,9 +1111,7 @@ public function testAsyncInsertConfiguration(): void $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123); $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false); - $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure); - $adapter->setNamespace('utopia_usage_async'); - $adapter->setTenant('1'); + $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure, 'utopia_usage_async'); if ($database = getenv('CLICKHOUSE_DATABASE')) { $adapter->setDatabase($database); diff --git a/tests/Usage/Adapter/ClickHouseTestCase.php b/tests/Usage/Adapter/ClickHouseTestCase.php index 9836dce..3002019 100644 --- a/tests/Usage/Adapter/ClickHouseTestCase.php +++ b/tests/Usage/Adapter/ClickHouseTestCase.php @@ -21,24 +21,7 @@ abstract class ClickHouseTestCase extends TestCase */ 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); - - $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure); - $adapter->setNamespace($namespace); - $adapter->setSharedTables(true); - if ($tenant !== null) { - $adapter->setTenant($tenant); - } - - if ($database = getenv('CLICKHOUSE_DATABASE')) { - $adapter->setDatabase($database); - } - - return $adapter; + return ScopedClickHouse::fromEnv($namespace, $tenant); } /** @@ -46,10 +29,11 @@ protected function makeAdapter(string $namespace, ?string $tenant = '1'): ClickH */ protected function databaseName(ClickHouseAdapter $adapter): string { - $reflection = new ReflectionClass($adapter); + $reflection = new ReflectionClass(ClickHouseAdapter::class); $prop = $reflection->getProperty('database'); $prop->setAccessible(true); $value = $prop->getValue($adapter); + return is_string($value) ? $value : ''; } @@ -57,14 +41,15 @@ protected function databaseName(ClickHouseAdapter $adapter): string * Invoke a private accessor on the adapter that returns a table name * (e.g. getEventsTableName, getDimRollupTableName). * - * @param array $args + * @param array $args */ protected function resolveTableName(ClickHouseAdapter $adapter, string $accessor, array $args = []): string { - $reflection = new ReflectionClass($adapter); + $reflection = new ReflectionClass(ClickHouseAdapter::class); $method = $reflection->getMethod($accessor); $method->setAccessible(true); $raw = $method->invokeArgs($adapter, $args); + return is_string($raw) ? $raw : ''; } @@ -73,14 +58,15 @@ protected function resolveTableName(ClickHouseAdapter $adapter, string $accessor * API does internally; needed in tests for setup / cleanup statements * that don't fit the find / sum / purge contracts. * - * @param array $params + * @param array $params */ protected function queryRaw(ClickHouseAdapter $adapter, string $sql, array $params = []): string { - $reflection = new ReflectionClass($adapter); + $reflection = new ReflectionClass(ClickHouseAdapter::class); $method = $reflection->getMethod('query'); $method->setAccessible(true); $raw = $method->invoke($adapter, $sql, $params); + return is_string($raw) ? $raw : ''; } } diff --git a/tests/Usage/Adapter/ScopedClickHouse.php b/tests/Usage/Adapter/ScopedClickHouse.php new file mode 100644 index 0000000..288ef0f --- /dev/null +++ b/tests/Usage/Adapter/ScopedClickHouse.php @@ -0,0 +1,66 @@ +defaultTenant = $tenant; + $this->scopeTenant = $tenant; + } + + /** + * Build a pinned adapter from the standard CLICKHOUSE_* test env vars. + */ + public static function fromEnv(string $namespace, ?string $tenant = '1'): self + { + $adapter = new self( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + $namespace, + $tenant, + ); + + if ($database = getenv('CLICKHOUSE_DATABASE')) { + $adapter->setDatabase($database); + } + + return $adapter; + } + + public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool + { + foreach ($metrics as $i => $metric) { + if (! array_key_exists('$tenant', $metric)) { + $metrics[$i]['$tenant'] = $this->defaultTenant; + } + } + + return parent::addBatch($metrics, $type, $batchSize); + } +}