From dd4909f2c5a0fade8ad333689daf7197977191c1 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 12 Apr 2026 05:56:36 +0000 Subject: [PATCH 1/3] feat: add setTable/getTable methods to ClickHouse adapter Allow configuring the table name used by the ClickHouse adapter, enabling use cases like a dedicated slow query log table alongside the default audits table. Follows the same setter/getter pattern as setDatabase, setNamespace, and setTenant. Co-Authored-By: Claude Opus 4.6 --- src/Audit/Adapter/ClickHouse.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 0d598d5..5280a48 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -185,6 +185,30 @@ public function setDatabase(string $database): self return $this; } + /** + * Set the table name for audit logs. + * + * @param string $table + * @return self + * @throws Exception + */ + public function setTable(string $table): self + { + $this->validateIdentifier($table, 'Table'); + $this->table = $table; + return $this; + } + + /** + * Get the table name. + * + * @return string + */ + public function getTable(): string + { + return $this->table; + } + /** * Enable or disable HTTPS for ClickHouse HTTP interface. */ From fd3210a2b43e434931458718345364ad39b43791 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 12 Apr 2026 06:10:07 +0000 Subject: [PATCH 2/3] refactor: use constructor parameter for table name instead of setter Replace mutable setTable()/getTable() with an immutable constructor parameter to prevent accidental table name changes on a shared adapter instance. The table name defaults to 'audits' for backward compatibility. Co-Authored-By: Claude Opus 4.6 --- src/Audit/Adapter/ClickHouse.php | 32 +++++++------------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 5280a48..ec4660b 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -52,6 +52,7 @@ class ClickHouse extends SQL * @param string $password ClickHouse password (default: '') * @param int $port ClickHouse HTTP port (default: 8123) * @param bool $secure Whether to use HTTPS (default: false) + * @param string $table Table name for audit logs (default: 'audits') * @throws Exception If validation fails */ public function __construct( @@ -59,16 +60,21 @@ public function __construct( string $username = 'default', string $password = '', int $port = self::DEFAULT_PORT, - bool $secure = false + bool $secure = false, + string $table = self::DEFAULT_TABLE, ) { $this->validateHost($host); $this->validatePort($port); + if ($table !== self::DEFAULT_TABLE) { + $this->validateIdentifier($table, 'Table'); + } $this->host = $host; $this->port = $port; $this->username = $username; $this->password = $password; $this->secure = $secure; + $this->table = $table; // Initialize the HTTP client for connection reuse $this->client = new Client(); @@ -185,30 +191,6 @@ public function setDatabase(string $database): self return $this; } - /** - * Set the table name for audit logs. - * - * @param string $table - * @return self - * @throws Exception - */ - public function setTable(string $table): self - { - $this->validateIdentifier($table, 'Table'); - $this->table = $table; - return $this; - } - - /** - * Get the table name. - * - * @return string - */ - public function getTable(): string - { - return $this->table; - } - /** * Enable or disable HTTPS for ClickHouse HTTP interface. */ From 0493a05dbf3dd74b35016e906cc493e3d593210c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 13 Apr 2026 05:52:14 +0000 Subject: [PATCH 3/3] feat: add dedicated slow query logging methods to ClickHouse adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the generic setTable/getTable approach with purpose-built slow query methods: setupSlowQueries(), createSlowQuery(), findSlowQueries(), and cleanupSlowQueries(). The slow_queries table has its own schema with indexed durationMs (UInt32), LowCardinality method/action/plan, and UInt16 statusCode — optimized for analytical queries without JSON extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Audit/Adapter/ClickHouse.php | 217 ++++++++++++++++++++++++++++++- 1 file changed, 211 insertions(+), 6 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index ec4660b..baad9e7 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -21,6 +21,8 @@ class ClickHouse extends SQL private const DEFAULT_TABLE = 'audits'; + private const SLOW_QUERIES_TABLE = 'slow_queries'; + private const DEFAULT_DATABASE = 'default'; private string $host; @@ -52,7 +54,6 @@ class ClickHouse extends SQL * @param string $password ClickHouse password (default: '') * @param int $port ClickHouse HTTP port (default: 8123) * @param bool $secure Whether to use HTTPS (default: false) - * @param string $table Table name for audit logs (default: 'audits') * @throws Exception If validation fails */ public function __construct( @@ -61,20 +62,15 @@ public function __construct( string $password = '', int $port = self::DEFAULT_PORT, bool $secure = false, - string $table = self::DEFAULT_TABLE, ) { $this->validateHost($host); $this->validatePort($port); - if ($table !== self::DEFAULT_TABLE) { - $this->validateIdentifier($table, 'Table'); - } $this->host = $host; $this->port = $port; $this->username = $username; $this->password = $password; $this->secure = $secure; - $this->table = $table; // Initialize the HTTP client for connection reuse $this->client = new Client(); @@ -478,6 +474,22 @@ private function getTableName(): string return $tableName; } + /** + * Get the slow_queries table name with namespace prefix. + * + * @return string + */ + private function getSlowQueryTableName(): string + { + $tableName = self::SLOW_QUERIES_TABLE; + + if (!empty($this->namespace)) { + $tableName = $this->namespace . '_' . $tableName; + } + + return $tableName; + } + /** * Execute a ClickHouse query via HTTP interface using Fetch Client. * @@ -1580,4 +1592,197 @@ public function cleanup(\DateTime $datetime): bool return true; } + + /** + * Setup the slow_queries ClickHouse table. + * + * Creates a dedicated table for logging slow database queries with a + * purpose-built schema optimized for analytical queries on duration, + * action, and project. + * + * @throws Exception + */ + public function setupSlowQueries(): void + { + $escapedDatabase = $this->escapeIdentifier($this->database); + $this->query("CREATE DATABASE IF NOT EXISTS {$escapedDatabase}"); + + $columns = [ + 'id String', + 'time DateTime64(3)', + 'durationMs UInt32', + 'method LowCardinality(String)', + 'action LowCardinality(String)', + 'path String', + 'uri String', + 'hostname String', + 'projectId String', + 'userId String', + 'statusCode UInt16', + 'plan LowCardinality(String)', + 'ip String', + 'userAgent String', + 'data String', + ]; + + if ($this->sharedTables) { + $columns[] = 'tenant Nullable(UInt64)'; + } + + $tableName = $this->getSlowQueryTableName(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $createTableSql = " + CREATE TABLE IF NOT EXISTS {$escapedTable} ( + " . implode(",\n ", $columns) . ", + INDEX idx_duration durationMs TYPE minmax GRANULARITY 4 + ) + ENGINE = MergeTree() + ORDER BY (time, id) + PARTITION BY toYYYYMM(time) + SETTINGS index_granularity = 8192 + "; + + $this->query($createTableSql); + } + + /** + * Log a slow query to the slow_queries table. + * + * @param array{ + * durationMs: int, + * method: string, + * action: string, + * path: string, + * uri: string, + * hostname: string, + * projectId: string, + * userId: string, + * statusCode: int, + * plan: string, + * ip: string, + * userAgent: string, + * data: string, + * time?: \DateTime|string|null, + * } $data + * @throws Exception + */ + public function createSlowQuery(array $data): void + { + $tableName = $this->getSlowQueryTableName(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $row = [ + 'id' => uniqid('', true), + 'time' => $this->formatDateTime($data['time'] ?? null), + 'durationMs' => $data['durationMs'], + 'method' => $data['method'], + 'action' => $data['action'], + 'path' => $data['path'], + 'uri' => $data['uri'], + 'hostname' => $data['hostname'], + 'projectId' => $data['projectId'], + 'userId' => $data['userId'], + 'statusCode' => $data['statusCode'], + 'plan' => $data['plan'], + 'ip' => $data['ip'], + 'userAgent' => $data['userAgent'], + 'data' => $data['data'], + ]; + + if ($this->sharedTables) { + $row['tenant'] = $this->tenant; + } + + $this->query("INSERT INTO {$escapedTable} FORMAT JSONEachRow", [], [$row]); + } + + /** + * Find slow query logs within a time range. + * + * @param \DateTime|null $after Only return logs after this time + * @param \DateTime|null $before Only return logs before this time + * @param int $limit Maximum number of results + * @param int $offset Number of results to skip + * @return array> + * @throws Exception + */ + public function findSlowQueries( + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + ): array { + $tableName = $this->getSlowQueryTableName(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $tenantFilter = $this->getTenantFilter(); + + $conditions = []; + $params = []; + + if ($tenantFilter) { + $conditions[] = ltrim($tenantFilter, ' AND'); + } + + if ($after !== null) { + $conditions[] = 'time > {after:DateTime64(3)}'; + $params['after'] = $this->formatDateTime($after); + } + + if ($before !== null) { + $conditions[] = 'time < {before:DateTime64(3)}'; + $params['before'] = $this->formatDateTime($before); + } + + $whereClause = !empty($conditions) ? ' WHERE ' . implode(' AND ', $conditions) : ''; + $params['limit'] = $limit; + $params['offset'] = $offset; + + $sql = " + SELECT * + FROM {$escapedTable}{$whereClause} + ORDER BY time DESC + LIMIT {limit:UInt64} OFFSET {offset:UInt64} + FORMAT JSON + "; + + $result = $this->query($sql, $params); + + if (empty(trim($result))) { + return []; + } + + /** @var array|null $decoded */ + $decoded = json_decode($result, true); + if ($decoded === null || !isset($decoded['data']) || !is_array($decoded['data'])) { + return []; + } + + return $decoded['data']; + } + + /** + * Delete slow query logs older than the specified datetime. + * + * @param \DateTime $datetime Delete logs older than this time + * @return bool + * @throws Exception + */ + public function cleanupSlowQueries(\DateTime $datetime): bool + { + $tableName = $this->getSlowQueryTableName(); + $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $datetimeString = $datetime->format('Y-m-d H:i:s.v'); + + $sql = " + DELETE FROM {$escapedTable} + WHERE time < {datetime:String}{$tenantFilter} + "; + + $this->query($sql, ['datetime' => $datetimeString]); + + return true; + } }