From 8fc34156daa92910b3695b735d4d7dfe1b7b32a0 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 03:49:56 +0000 Subject: [PATCH 01/11] feat(schema)!: rename resource column to resourceType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dimension previously called `resource` on the events, gauges, and events-daily tables now becomes `resourceType`. The name matches what the column actually stores — the type of the resource ('bucket', 'function', 'database', 'file', 'site') — and avoids collision with the `resourceId` companion column. Breaking change: - Metric::EVENT_COLUMNS and Metric::GAUGE_COLUMNS entries renamed. - Metric::getEventSchema() / getGaugeSchema() column definitions renamed. - Metric::getEventIndexes() / getGaugeIndexes() cover the new name. - Metric::getResource() renamed to Metric::getResourceType(). - ClickHouse adapter renames: * events table column: resource -> resourceType * gauges table column: resource -> resourceType * events-daily SummingMergeTree column + ORDER BY: same rename * MV projection SELECT dimension list * GAUGE_PROJECTIONS: p_by_resource -> p_by_resourceType, p_by_resource_resourceId -> p_by_resourceType_resourceId * ensureGaugeDimColumns() backfill ADD COLUMN targets resourceType * getColumnType() LowCardinality list updated * findDaily() GROUP BY dimension list updated Consumers must migrate their write callers (change the `resource` tag key to `resourceType`) and their queries. A one-time ClickHouse column rename plus data backfill is required for existing deployments; that migration lives in the cloud repo, not this library. Tests updated to exercise the new column name throughout. All 97 non-ClickHouse unit tests pass; ClickHouse integration tests were not run locally (require the docker-compose harness). --- README.md | 2 +- src/Usage/Adapter.php | 2 +- src/Usage/Adapter/ClickHouse.php | 26 +++++++++---------- src/Usage/Metric.php | 26 +++++++++---------- tests/Benchmark/EventsBench.php | 4 +-- tests/Benchmark/GaugesBench.php | 6 ++--- tests/Benchmark/README.md | 2 +- tests/Benchmark/fixtures/seed.sql | 6 ++--- .../Adapter/ClickHouseDimRoutingTest.php | 4 +-- .../Adapter/ClickHouseGaugeDimRoutingTest.php | 22 ++++++++-------- tests/Usage/Adapter/ClickHouseTest.php | 22 ++++++++-------- tests/Usage/Adapter/DatabaseTest.php | 2 +- tests/Usage/MetricTest.php | 18 ++++++------- tests/Usage/UsageBase.php | 6 ++--- 14 files changed, 74 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 9591632..9272193 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ $accumulator->collect('project_123', 'bandwidth', 5000, Usage::TYPE_EVENT, [ 'method' => 'POST', 'status' => '201', 'service' => 'storage', - 'resource' => 'bucket', + 'resourceType' => 'bucket', 'resourceId' => 'abc123', 'resourceInternalId' => '42', 'teamId' => 'team_x', diff --git a/src/Usage/Adapter.php b/src/Usage/Adapter.php index ddbf659..135f5de 100644 --- a/src/Usage/Adapter.php +++ b/src/Usage/Adapter.php @@ -141,7 +141,7 @@ abstract public function sum(string $tenant, array $queries = [], string $attrib * the daily events table — gauges are never pre-aggregated. * * @param string $tenant Tenant scope (shared-tables mode) - * @param array<\Utopia\Query\Query> $queries Filters (metric, time range, resource, etc.) + * @param array<\Utopia\Query\Query> $queries Filters (metric, time range, resourceType, etc.) * @return array */ abstract public function findDaily(string $tenant, array $queries = []): array; diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 9cf0cd5..9029ebc 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -23,7 +23,7 @@ * This adapter stores usage metrics in ClickHouse using HTTP interface. * Uses two separate tables: * - Events table (MergeTree): raw request events with metadata columns - * (path, method, status, resource, resourceId) + * (path, method, status, resourceType, resourceId) * - Gauges table (MergeTree): simple resource snapshots (metric, value, time, tags) * * A SummingMergeTree materialized view pre-aggregates events by day for fast @@ -694,9 +694,9 @@ private function formatParamValue(mixed $value): string */ private const GAUGE_PROJECTIONS = [ ['name' => 'p_by_service', 'dims' => ['service']], - ['name' => 'p_by_resource', 'dims' => ['resource']], + ['name' => 'p_by_resourceType', 'dims' => ['resourceType']], ['name' => 'p_by_resourceId', 'dims' => ['resourceId']], - ['name' => 'p_by_resource_resourceId', 'dims' => ['resource', 'resourceId']], + ['name' => 'p_by_resourceType_resourceId', 'dims' => ['resourceType', 'resourceId']], ]; /** @@ -777,7 +777,7 @@ private function setLightweightMutationProjectionMode(string $baseTable): void } /** - * Backfill the service / resource columns on an existing gauges table. + * Backfill the service / resourceType columns on an existing gauges table. * setup() uses CREATE TABLE IF NOT EXISTS, so deployments that came up * before these columns were added never receive them — the gauge * projections would then fail because their SELECT references columns @@ -790,7 +790,7 @@ private function ensureGaugeDimColumns(): void $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 resourceType LowCardinality(Nullable(String))'; $this->query($sql); } @@ -916,7 +916,7 @@ private function createDailyTable(): void 'metric String', 'value Int64', "time DateTime64(3, 'UTC')", - 'resource LowCardinality(Nullable(String))', + 'resourceType LowCardinality(Nullable(String))', 'resourceId Nullable(String)', 'resourceInternalId Nullable(String)', 'teamId Nullable(String)', @@ -930,8 +930,8 @@ private function createDailyTable(): void $columnDefs = implode(",\n ", $columns); $dailyOrderBy = $this->sharedTables - ? '(tenant, metric, time, resource, resourceId, resourceInternalId, teamId, teamInternalId)' - : '(metric, time, resource, resourceId, resourceInternalId, teamId, teamInternalId)'; + ? '(tenant, metric, time, resourceType, resourceId, resourceInternalId, teamId, teamInternalId)' + : '(metric, time, resourceType, resourceId, resourceInternalId, teamId, teamInternalId)'; $createDailyTableSql = " CREATE TABLE IF NOT EXISTS {$escapedDailyTable} ( @@ -961,7 +961,7 @@ private function createDailyMaterializedView(): void $escapedDailyTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyTableName); $escapedDailyMv = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyMvName); - $dimensions = 'resource, resourceId, resourceInternalId, teamId, teamInternalId'; + $dimensions = 'resourceType, resourceId, resourceInternalId, teamId, teamInternalId'; if ($this->sharedTables) { $innerSelect = "metric, tenant, {$dimensions}, sum(value) as value, toStartOfDay(time, 'UTC') as d"; @@ -1045,7 +1045,7 @@ private function validateGroupByAttribute(string $attribute, string $type): bool */ private const DAILY_COLUMNS = [ 'metric', 'value', 'time', - 'resource', 'resourceId', 'resourceInternalId', + 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId', ]; @@ -1122,7 +1122,7 @@ private function getColumnType(string $id, string $type = 'event'): string } $lowCardinality = [ - 'country', 'region', 'service', 'resource', + 'country', 'region', 'service', 'resourceType', 'osCode', 'osName', 'osVersion', 'clientType', 'clientCode', 'clientName', 'clientVersion', 'clientEngine', 'clientEngineVersion', @@ -1257,7 +1257,7 @@ private function validateMetricsBatch(array $metrics, string $type): void /** * Add metrics in batch (raw append to appropriate table). * - * For events: extracts path/method/status/resource/resourceId from tags into + * For events: extracts path/method/status/resourceType/resourceId from tags into * dedicated columns; remaining tags stay in the tags JSON column. * For gauges: simple metric/value/time/tags insert. * @@ -2497,7 +2497,7 @@ public function findDaily(string $tenant, array $queries = []): array $groupByColumns = $this->sharedTables ? ['tenant'] : []; $groupByColumns[] = 'metric'; $groupByColumns[] = 'time'; - foreach (['resource', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId'] as $dim) { + foreach (['resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId'] as $dim) { $groupByColumns[] = $dim; } diff --git a/src/Usage/Metric.php b/src/Usage/Metric.php index c597685..23f15bc 100644 --- a/src/Usage/Metric.php +++ b/src/Usage/Metric.php @@ -24,7 +24,7 @@ * 'method' => 'POST', * 'status' => '201', * 'service' => 'storage', - * 'resource' => 'bucket', + * 'resourceType' => 'bucket', * 'resourceId' => 'abc123', * 'resourceInternalId' => '42', * 'teamId' => 'team_x', @@ -51,7 +51,7 @@ class Metric extends ArrayObject */ public const EVENT_COLUMNS = [ 'path', 'method', 'status', - 'service', 'resource', 'resourceId', 'resourceInternalId', + 'service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId', 'country', 'region', 'hostname', 'osCode', 'osName', 'osVersion', @@ -63,7 +63,7 @@ class Metric extends ArrayObject /** * Gauge-specific column names that are extracted from tags into dedicated columns. */ - public const GAUGE_COLUMNS = ['service', 'resource', 'teamId', 'teamInternalId', 'resourceId', 'resourceInternalId']; + public const GAUGE_COLUMNS = ['service', 'resourceType', 'teamId', 'teamInternalId', 'resourceId', 'resourceInternalId']; /** * Construct a new metric object. @@ -79,7 +79,7 @@ class Metric extends ArrayObject * Event-only dimension columns (see EVENT_COLUMNS): * - path / method / status: HTTP shape * - service: API service segment (storage, databases, …) - * - resource / resourceId / resourceInternalId: resource identity + * - resourceType / resourceId / resourceInternalId: resource identity * - teamId / teamInternalId: owning team identity * - country / region / hostname: geographic + caller origin * - osCode / osName / osVersion: parsed user-agent OS fields @@ -219,10 +219,10 @@ public function getStatus(): ?string * * @return string|null The resource type, or null if not set */ - public function getResource(): ?string + public function getResourceType(): ?string { - $resource = $this->getAttribute('resource', null); - return is_string($resource) ? $resource : null; + $resourceType = $this->getAttribute('resourceType', null); + return is_string($resourceType) ? $resourceType : null; } /** @@ -561,7 +561,7 @@ public function toArray(): array * * Returns the attribute schema for the events table which stores * raw request events with metadata columns for path, method, status, - * resource, and resourceId. + * resourceType, and resourceId. * * @return array> */ @@ -610,7 +610,7 @@ public static function getEventSchema(): array $stringColumn('method', 16), $stringColumn('status', 16), $stringColumn('service', 256), - $stringColumn('resource', 256), + $stringColumn('resourceType', 256), $stringColumn('resourceId', 255), $stringColumn('resourceInternalId', 255), $stringColumn('teamId', 255), @@ -683,7 +683,7 @@ public static function getGaugeSchema(): array 'filters' => ['datetime'], ], $stringColumn('service', 256), - $stringColumn('resource', 256), + $stringColumn('resourceType', 256), $stringColumn('teamId', 255), $stringColumn('teamInternalId', 255), $stringColumn('resourceId', 255), @@ -713,7 +713,7 @@ public static function getEventIndexes(): array { $indexed = [ 'path', 'method', 'status', - 'service', 'resource', 'resourceId', 'resourceInternalId', + 'service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId', 'country', 'region', 'hostname', 'osName', 'clientType', 'clientName', 'deviceName', @@ -745,9 +745,9 @@ static function (string $col) use ($setIndexed): array { */ public static function getGaugeIndexes(): array { - $indexed = ['service', 'resource', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId']; + $indexed = ['service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId']; - $setIndexed = ['service', 'resource']; + $setIndexed = ['service', 'resourceType']; return array_map( static fn (string $col): array => [ diff --git a/tests/Benchmark/EventsBench.php b/tests/Benchmark/EventsBench.php index 1e4c1e3..ad00ada 100644 --- a/tests/Benchmark/EventsBench.php +++ b/tests/Benchmark/EventsBench.php @@ -117,12 +117,12 @@ public function testBenchmarks(): void ], Usage::TYPE_EVENT); }); - $this->runBench('bench_events_topN_path_30d_filtered_resource', function (string $queryId) use ($start, $end): void { + $this->runBench('bench_events_topN_path_30d_filtered_resourceType', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); $this->usage->find($this->tenant, [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), - Query::equal('resource', ['function']), + Query::equal('resourceType', ['function']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), Query::limit(500), diff --git a/tests/Benchmark/GaugesBench.php b/tests/Benchmark/GaugesBench.php index f84eee2..41f4a56 100644 --- a/tests/Benchmark/GaugesBench.php +++ b/tests/Benchmark/GaugesBench.php @@ -49,10 +49,10 @@ public function testBenchmarks(): void ], Usage::TYPE_GAUGE); }); - $this->runBench('bench_gauges_topN_resource_30d', function (string $queryId) use ($start30d, $endClosed): void { + $this->runBench('bench_gauges_topN_resourceType_30d', function (string $queryId) use ($start30d, $endClosed): void { $this->adapter->setNextQueryId($queryId); $this->usage->find($this->tenant, [ - UsageQuery::groupBy('resource'), + UsageQuery::groupBy('resourceType'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start30d), Query::lessThanEqual('time', $endClosed), @@ -75,7 +75,7 @@ public function testBenchmarks(): void $expectedProjections = [ 'bench_gauges_topN_service_30d' => 'p_by_service', - 'bench_gauges_topN_resource_30d' => 'p_by_resource', + 'bench_gauges_topN_resourceType_30d' => 'p_by_resourceType', 'bench_gauges_topN_service_today_partial' => 'p_by_service', ]; foreach ($expectedProjections as $scenario => $projection) { diff --git a/tests/Benchmark/README.md b/tests/Benchmark/README.md index 025356e..1b66e36 100644 --- a/tests/Benchmark/README.md +++ b/tests/Benchmark/README.md @@ -77,7 +77,7 @@ Gauges (`GaugesBench`): |---|---|---| | `bench_gauges_latest_in_window` | gauge `getTotal` argMax | p50 < 50ms | | `bench_gauges_topN_service_30d` | closed-day gauge by_service AMT | p50 < 100ms | -| `bench_gauges_topN_resource_30d` | closed-day gauge by_resource AMT | p50 < 100ms | +| `bench_gauges_topN_resourceType_30d` | closed-day gauge by_resourceType AMT | p50 < 100ms | | `bench_gauges_topN_service_today_partial` | gauge by_service hybrid | p50 < 80ms | Budgets are guidance, not gates. diff --git a/tests/Benchmark/fixtures/seed.sql b/tests/Benchmark/fixtures/seed.sql index 5bc2698..03b1273 100644 --- a/tests/Benchmark/fixtures/seed.sql +++ b/tests/Benchmark/fixtures/seed.sql @@ -10,10 +10,10 @@ -- * 1000 distinct paths (api-style surface) — bounded but high-cardinality. -- * 6 HTTP methods, 6 status codes — bounded cross-product (~36 keys). -- * 30-day span, 1-minute resolution — matches expected production density. --- * Country / service / resource cycled for routing-fallback tests. +-- * Country / service / resourceType cycled for routing-fallback tests. INSERT INTO {TABLE} (id, metric, value, time, path, method, status, service, country, - region, hostname, resource, resourceId, tenant) + region, hostname, resourceType, resourceId, tenant) SELECT lower(hex(randomString(16))) AS id, '{METRIC}' AS metric, @@ -38,7 +38,7 @@ SELECT concat('host-', toString(number % 10), '.example.com') AS hostname, [ 'project','bucket','database','function' - ][1 + (number % 4)] AS resource, + ][1 + (number % 4)] AS resourceType, concat('resource-', toString(number % 5000)) AS resourceId, '{TENANT}' AS tenant FROM numbers({ROWS}); diff --git a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php index b3b6912..f6129dc 100644 --- a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php @@ -144,7 +144,7 @@ public function testMultiDimNotInAnyProjectionFallsBackToTable(): void public function testFilterOnExtraColumnStillRoutesToProjectionWhenColumnPresent(): void { - // resource is a column on the events table but not in p_by_path's + // resourceType is a column on the events table but not in p_by_path's // projection; the optimizer cannot satisfy this query from the // projection and must scan the base table. $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); @@ -155,7 +155,7 @@ public function testFilterOnExtraColumnStillRoutesToProjectionWhenColumnPresent( $this->usage->find('1', [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), - Query::equal('resource', ['function']), + Query::equal('resourceType', ['function']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), ], Usage::TYPE_EVENT); diff --git a/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php b/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php index 562a81b..21f2c26 100644 --- a/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php @@ -12,7 +12,7 @@ /** * Routing tests for the gauge per-dim projection slate (p_by_service, - * p_by_resource). Each grouped scenario asserts (a) latest-per-group + * p_by_resourceType). Each grouped scenario asserts (a) latest-per-group * values match the raw scan, and (b) the optimizer picked the matching * argMaxState projection per `system.query_log.projections`. */ @@ -40,13 +40,13 @@ protected function setUp(): void $this->usage->setup(); $this->usage->purge('1'); - $this->seedHistoricalRow($this->metric, 100, '-5 days', ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f1']); - $this->seedHistoricalRow($this->metric, 200, '-4 days', ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f2']); - $this->seedHistoricalRow($this->metric, 50, '-3 days', ['service' => 'databases', 'resource' => 'database', 'resourceId' => 'db1']); - $this->seedHistoricalRow($this->metric, 80, '-3 days', ['service' => 'functions', 'resource' => 'function', 'resourceId' => 'fn1']); + $this->seedHistoricalRow($this->metric, 100, '-5 days', ['service' => 'storage', 'resourceType' => 'file', 'resourceId' => 'f1']); + $this->seedHistoricalRow($this->metric, 200, '-4 days', ['service' => 'storage', 'resourceType' => 'file', 'resourceId' => 'f2']); + $this->seedHistoricalRow($this->metric, 50, '-3 days', ['service' => 'databases', 'resourceType' => 'database', 'resourceId' => 'db1']); + $this->seedHistoricalRow($this->metric, 80, '-3 days', ['service' => 'functions', 'resourceType' => 'function', 'resourceId' => 'fn1']); $this->usage->addBatch([ - ['tenant' => '1', 'metric' => $this->metric, 'value' => 999, 'tags' => ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f1']], + ['tenant' => '1', 'metric' => $this->metric, 'value' => 999, 'tags' => ['service' => 'storage', 'resourceType' => 'file', 'resourceId' => 'f1']], ], Usage::TYPE_GAUGE); } @@ -74,7 +74,7 @@ private function seedHistoricalRow(string $metric, int $value, string $modifier, "'{$time}'", "'1'", ]; - foreach (['service', 'resource'] as $tag) { + foreach (['service', 'resourceType'] as $tag) { if (isset($tags[$tag])) { $cols[] = $tag; $vals[] = "'" . addslashes($tags[$tag]) . "'"; @@ -91,10 +91,10 @@ private function seedHistoricalRow(string $metric, int $value, string $modifier, public static function topGaugesProjectionProvider(): array { return [ - 'by_service' => [['service'], 'p_by_service'], - 'by_resource' => [['resource'], 'p_by_resource'], - 'by_resourceId' => [['resourceId'], 'p_by_resourceId'], - 'by_resource_resourceId' => [['resource', 'resourceId'], 'p_by_resource_resourceId'], + 'by_service' => [['service'], 'p_by_service'], + 'by_resourceType' => [['resourceType'], 'p_by_resourceType'], + 'by_resourceId' => [['resourceId'], 'p_by_resourceId'], + 'by_resourceType_resourceId' => [['resourceType', 'resourceId'], 'p_by_resourceType_resourceId'], ]; } diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index aa7e031..b111c40 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -235,7 +235,7 @@ public function testEventColumnsExtractedFromTags(): void 'method' => 'POST', 'status' => '201', 'service' => 'storage', - 'resource' => 'bucket', + 'resourceType' => 'bucket', 'resourceId' => 'bucket123', 'resourceInternalId' => '42', 'teamId' => 'team_x', @@ -272,7 +272,7 @@ public function testEventColumnsExtractedFromTags(): void $this->assertEquals('POST', $metric->getMethod()); $this->assertEquals('201', $metric->getStatus()); $this->assertEquals('storage', $metric->getService()); - $this->assertEquals('bucket', $metric->getResource()); + $this->assertEquals('bucket', $metric->getResourceType()); $this->assertEquals('bucket123', $metric->getResourceId()); $this->assertEquals('42', $metric->getResourceInternalId()); $this->assertEquals('team_x', $metric->getTeamId()); @@ -309,7 +309,7 @@ public function testGaugeColumnsRoundTrip(): void 'value' => 500, 'tags' => [ 'service' => 'storage', - 'resource' => 'file', + 'resourceType' => 'file', 'teamId' => 'team_x', 'teamInternalId' => '7', 'resourceId' => 'r1', @@ -325,7 +325,7 @@ public function testGaugeColumnsRoundTrip(): void $this->assertCount(1, $results); $metric = $results[0]; $this->assertEquals('storage', $metric->getService()); - $this->assertEquals('file', $metric->getResource()); + $this->assertEquals('file', $metric->getResourceType()); $this->assertEquals('team_x', $metric->getTeamId()); $this->assertEquals('7', $metric->getTeamInternalId()); $this->assertEquals('r1', $metric->getResourceId()); @@ -396,7 +396,7 @@ public function testEventsSchemaPersistsAllNewColumns(): void $tags = [ 'path' => '/v1/x', 'method' => 'GET', 'status' => '200', - 'service' => 'storage', 'resource' => 'bucket', + 'service' => 'storage', 'resourceType' => 'bucket', 'resourceId' => 'r1', 'resourceInternalId' => '42', 'teamId' => 't1', 'teamInternalId' => '7', 'country' => 'us', 'region' => 'fra', 'hostname' => 'h.example.com', @@ -432,9 +432,9 @@ public function testQueryEventsByColumns(): void $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['tenant' => '1', 'metric' => 'req', 'value' => 10, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resource' => 'project', 'resourceId' => 'p1']], - ['tenant' => '1', 'metric' => 'req', 'value' => 20, 'tags' => ['path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resource' => 'database', 'resourceId' => 'db1']], - ['tenant' => '1', 'metric' => 'req', 'value' => 30, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '404', 'resource' => 'project', 'resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'req', 'value' => 10, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resourceType' => 'project', 'resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'req', 'value' => 20, 'tags' => ['path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resourceType' => 'database', 'resourceId' => 'db1']], + ['tenant' => '1', 'metric' => 'req', 'value' => 30, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '404', 'resourceType' => 'project', 'resourceId' => 'p1']], ], Usage::TYPE_EVENT)); // Filter by path @@ -457,9 +457,9 @@ public function testQueryEventsByColumns(): void $this->assertCount(1, $results); $this->assertEquals(30, $results[0]->getValue()); - // Filter by resource + // Filter by resourceType $results = $this->usage->find('1', [ - \Utopia\Query\Query::equal('resource', ['database']), + \Utopia\Query\Query::equal('resourceType', ['database']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); @@ -493,7 +493,7 @@ public function testGaugeTableSimpleSchema(): void $this->assertNull($results[0]->getPath()); $this->assertNull($results[0]->getMethod()); $this->assertNull($results[0]->getStatus()); - $this->assertNull($results[0]->getResource()); + $this->assertNull($results[0]->getResourceType()); $this->assertEquals('r1', $results[0]->getResourceId()); } diff --git a/tests/Usage/Adapter/DatabaseTest.php b/tests/Usage/Adapter/DatabaseTest.php index 5c0ee1e..3352b3a 100644 --- a/tests/Usage/Adapter/DatabaseTest.php +++ b/tests/Usage/Adapter/DatabaseTest.php @@ -76,7 +76,7 @@ public function testEventColumnsExtractedFromTags(): void 'method' => 'POST', 'status' => '201', 'service' => 'storage', - 'resource' => 'bucket', + 'resourceType' => 'bucket', 'resourceId' => 'bucket123', 'resourceInternalId' => '42', 'teamId' => 'team_x', diff --git a/tests/Usage/MetricTest.php b/tests/Usage/MetricTest.php index eba1029..5afdace 100644 --- a/tests/Usage/MetricTest.php +++ b/tests/Usage/MetricTest.php @@ -99,7 +99,7 @@ public function testEventIndexesCoverNewFilterableColumns(): void $indexed = array_merge($indexed, $attrs); } foreach ([ - 'path', 'method', 'status', 'service', 'resource', + 'path', 'method', 'status', 'service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId', 'country', 'region', 'hostname', 'osName', 'clientType', 'clientName', 'deviceName', @@ -143,7 +143,7 @@ public function testValidateAcceptsValidEventData(): void 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', - 'resource' => 'bucket', + 'resourceType' => 'bucket', 'resourceId' => 'abc123', 'region' => 'us', ]; @@ -310,7 +310,7 @@ public function testConstructorInitializesWithData(): void 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', - 'resource' => 'bucket', + 'resourceType' => 'bucket', 'resourceId' => 'abc123', ]; @@ -323,7 +323,7 @@ public function testConstructorInitializesWithData(): void $this->assertEquals('/v1/storage/files', $metric->getPath()); $this->assertEquals('POST', $metric->getMethod()); $this->assertEquals('201', $metric->getStatus()); - $this->assertEquals('bucket', $metric->getResource()); + $this->assertEquals('bucket', $metric->getResourceType()); $this->assertEquals('abc123', $metric->getResourceId()); } @@ -399,7 +399,7 @@ public function testEventGettersReturnNullWhenNotSet(): void $this->assertNull($metric->getPath()); $this->assertNull($metric->getMethod()); $this->assertNull($metric->getStatus()); - $this->assertNull($metric->getResource()); + $this->assertNull($metric->getResourceType()); $this->assertNull($metric->getResourceId()); } @@ -412,13 +412,13 @@ public function testEventGettersReturnCorrectValues(): void 'path' => '/v1/databases', 'method' => 'GET', 'status' => '200', - 'resource' => 'database', + 'resourceType' => 'database', 'resourceId' => 'db123', ]); $this->assertEquals('/v1/databases', $metric->getPath()); $this->assertEquals('GET', $metric->getMethod()); $this->assertEquals('200', $metric->getStatus()); - $this->assertEquals('database', $metric->getResource()); + $this->assertEquals('database', $metric->getResourceType()); $this->assertEquals('db123', $metric->getResourceId()); } @@ -606,7 +606,7 @@ public function testEventColumnsConstant(): void { $expected = [ 'path', 'method', 'status', - 'service', 'resource', 'resourceId', 'resourceInternalId', + 'service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId', 'country', 'region', 'hostname', 'osCode', 'osName', 'osVersion', @@ -622,7 +622,7 @@ public function testEventColumnsConstant(): void */ public function testGaugeColumnsConstant(): void { - $expected = ['service', 'resource', 'teamId', 'teamInternalId', 'resourceId', 'resourceInternalId']; + $expected = ['service', 'resourceType', 'teamId', 'teamInternalId', 'resourceId', 'resourceInternalId']; $this->assertSame($expected, Metric::GAUGE_COLUMNS); } diff --git a/tests/Usage/UsageBase.php b/tests/Usage/UsageBase.php index a104b7e..ea126c4 100644 --- a/tests/Usage/UsageBase.php +++ b/tests/Usage/UsageBase.php @@ -27,9 +27,9 @@ public function createUsageMetrics(): void { // Events: additive metrics $this->assertTrue($this->usage->addBatch([ - ['tenant' => '1', 'metric' => 'requests', 'value' => 100, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resource' => 'project', 'resourceId' => 'p1']], - ['tenant' => '1', 'metric' => 'requests', 'value' => 150, 'tags' => ['region' => 'us-west', 'path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resource' => 'database', 'resourceId' => 'db1']], - ['tenant' => '1', 'metric' => 'bandwidth', 'value' => 5000, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', 'resource' => 'bucket', 'resourceId' => 'b1']], + ['tenant' => '1', 'metric' => 'requests', 'value' => 100, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resourceType' => 'project', 'resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'requests', 'value' => 150, 'tags' => ['region' => 'us-west', 'path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resourceType' => 'database', 'resourceId' => 'db1']], + ['tenant' => '1', 'metric' => 'bandwidth', 'value' => 5000, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', 'resourceType' => 'bucket', 'resourceId' => 'b1']], ], Usage::TYPE_EVENT)); // Gauges: point-in-time snapshots From 1300b074e182aa9e5bdad71d8cdb12b4947c2856 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 03:51:53 +0000 Subject: [PATCH 02/11] feat(schema): add ip dimension to event columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an event-only `ip` dimension that records the caller IP address associated with each event row. IPv4 and IPv6 forms fit into the 45-char column (IPv6 dotted-quad max). The dimension is not added to gauges — gauges are point-in-time snapshots that already carry their identity via resourceType/resourceId and don't need a per-caller attribution axis. Details: - Metric::EVENT_COLUMNS gains 'ip' and Metric::getEventSchema() adds the matching String column (size 45, nullable). - Metric::getEventIndexes() indexes 'ip' with a bloom_filter — high cardinality per tenant makes a set-index a poor fit. - Metric::getIp() typed accessor added. - ClickHouse adapter: 'ip' joins the LowCardinality(Nullable(String)) list so the physical column mirrors the other host/network dims. - 'ip' is intentionally NOT added to the base-table ORDER BY / primary key — that shape stays (tenant, metric, time, id) so per-metric time range scans keep skipping granules efficiently. - Backward-compatible: callers that never set 'ip' keep working; the column is nullable and Metric::extractColumns() maps missing tags to null. Tests cover both event-only presence and the round-trip through getIp() for IPv4 + IPv6 forms. --- src/Usage/Adapter/ClickHouse.php | 2 +- src/Usage/Metric.php | 19 ++++++++++++++--- tests/Usage/MetricTest.php | 35 ++++++++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 9029ebc..d1bc659 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -1127,7 +1127,7 @@ private function getColumnType(string $id, string $type = 'event'): string 'clientType', 'clientCode', 'clientName', 'clientVersion', 'clientEngine', 'clientEngineVersion', 'deviceName', 'deviceBrand', 'deviceModel', - 'hostname', + 'hostname', 'ip', ]; if (in_array($id, $lowCardinality, true)) { diff --git a/src/Usage/Metric.php b/src/Usage/Metric.php index 23f15bc..d902d20 100644 --- a/src/Usage/Metric.php +++ b/src/Usage/Metric.php @@ -53,7 +53,7 @@ class Metric extends ArrayObject 'path', 'method', 'status', 'service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId', - 'country', 'region', 'hostname', + 'country', 'region', 'hostname', 'ip', 'osCode', 'osName', 'osVersion', 'clientType', 'clientCode', 'clientName', 'clientVersion', 'clientEngine', 'clientEngineVersion', @@ -81,7 +81,7 @@ class Metric extends ArrayObject * - service: API service segment (storage, databases, …) * - resourceType / resourceId / resourceInternalId: resource identity * - teamId / teamInternalId: owning team identity - * - country / region / hostname: geographic + caller origin + * - country / region / hostname / ip: geographic + caller origin * - osCode / osName / osVersion: parsed user-agent OS fields * - clientType / clientCode / clientName / clientVersion: parsed client * - clientEngine / clientEngineVersion: parsed client engine @@ -305,6 +305,18 @@ public function getHostname(): ?string return is_string($v) ? $v : null; } + /** + * Get caller IP address (event metrics). + * + * Stored as-is. IPv4 dotted-decimal (up to 15 chars) or IPv6 + * (up to 45 chars including v4-mapped forms). + */ + public function getIp(): ?string + { + $v = $this->getAttribute('ip', null); + return is_string($v) ? $v : null; + } + /** * Get OS short code (event metrics). */ @@ -618,6 +630,7 @@ public static function getEventSchema(): array $stringColumn('country', 2), $stringColumn('region', 64), $stringColumn('hostname', 255), + $stringColumn('ip', 45), $stringColumn('osCode', 256), $stringColumn('osName', 256), $stringColumn('osVersion', 255), @@ -715,7 +728,7 @@ public static function getEventIndexes(): array 'path', 'method', 'status', 'service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId', - 'country', 'region', 'hostname', + 'country', 'region', 'hostname', 'ip', 'osName', 'clientType', 'clientName', 'deviceName', ]; diff --git a/tests/Usage/MetricTest.php b/tests/Usage/MetricTest.php index 5afdace..90eba04 100644 --- a/tests/Usage/MetricTest.php +++ b/tests/Usage/MetricTest.php @@ -101,7 +101,7 @@ public function testEventIndexesCoverNewFilterableColumns(): void foreach ([ 'path', 'method', 'status', 'service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId', - 'country', 'region', 'hostname', + 'country', 'region', 'hostname', 'ip', 'osName', 'clientType', 'clientName', 'deviceName', ] as $col) { $this->assertContains($col, $indexed, "Event indexes missing {$col}"); @@ -608,7 +608,7 @@ public function testEventColumnsConstant(): void 'path', 'method', 'status', 'service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId', - 'country', 'region', 'hostname', + 'country', 'region', 'hostname', 'ip', 'osCode', 'osName', 'osVersion', 'clientType', 'clientCode', 'clientName', 'clientVersion', 'clientEngine', 'clientEngineVersion', @@ -696,4 +696,35 @@ public function testDroppedGettersDoNotExist(): void $this->assertFalse(method_exists(Metric::class, 'getUserAgent')); $this->assertFalse(method_exists(Metric::class, 'getTags')); } + + /** + * The ip dimension is event-only and round-trips through the typed + * accessor. + */ + public function testGetIpReturnsAddress(): void + { + $m = new Metric(['ip' => '203.0.113.42']); + $this->assertSame('203.0.113.42', $m->getIp()); + + $m6 = new Metric(['ip' => '2001:db8::1']); + $this->assertSame('2001:db8::1', $m6->getIp()); + + $missing = new Metric([]); + $this->assertNull($missing->getIp()); + } + + /** + * ip is event-only; the gauge column set must not include it. + */ + public function testIpIsEventOnly(): void + { + $this->assertContains('ip', Metric::EVENT_COLUMNS); + $this->assertNotContains('ip', Metric::GAUGE_COLUMNS); + + $eventIds = array_column(Metric::getEventSchema(), '$id'); + $this->assertContains('ip', $eventIds); + + $gaugeIds = array_column(Metric::getGaugeSchema(), '$id'); + $this->assertNotContains('ip', $gaugeIds); + } } From b053db80e3de246013567f01b04845f5666459f6 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 03:53:46 +0000 Subject: [PATCH 03/11] feat(events): accept queued timestamp on Accumulator::collect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers can now pass an optional \DateTime to Accumulator::collect() so the row lands at the moment the metric was originally emitted rather than the moment the buffer happens to flush. Backward-compatible: callers that don't pass a time keep the previous now()-at-write behaviour, and the ClickHouse adapter still writes now() when no time is threaded through. Semantics: - Events fold into the buffered entry as before; the earliest supplied time wins so a queue that fills a bucket over multiple collect() calls doesn't slide forward on late arrivals. - Gauges are last-write-wins on value, and the queued time follows the same rule — the latest supplied time replaces the previous one along with the value. Wire-up: - Accumulator::collect() gains a nullable \DateTime $time param; entries carry an optional 'time' key in the buffer. - Usage::addBatch phpdoc widened to advertise the optional time field on each metric row (DateTime|string, since the adapter already accepted strings elsewhere). - ClickHouse::addBatch() reads $metricData['time'], validates the shape (DateTime|string), and threads it into formatDateTime(); null / invalid falls back to now() as before. Tests cover: omitted-time keeps the field out of the payload, threaded DateTime survives to the adapter batch, event fold preserves the earliest time, gauge last-write-wins picks the latest. --- src/Usage/Accumulator.php | 33 +++++++++++++++--- src/Usage/Adapter/ClickHouse.php | 10 +++++- src/Usage/Usage.php | 2 +- tests/Usage/AccumulatorTest.php | 57 ++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/Usage/Accumulator.php b/src/Usage/Accumulator.php index 965c229..3f116ba 100644 --- a/src/Usage/Accumulator.php +++ b/src/Usage/Accumulator.php @@ -21,7 +21,12 @@ class Accumulator * use last-write-wins. Tenant is part of the key so metrics for different * tenants never collapse into one entry. * - * @var array}> + * Each entry may carry the `time` at which the event was originally + * emitted; the adapter uses it when writing so buffered rows land at + * the queued moment rather than the flush moment. Missing time means + * the adapter picks now() on write. + * + * @var array, time?: \DateTime}> */ private array $buffer = []; @@ -44,14 +49,25 @@ public function __construct(Usage $usage) * For gauge type: last-write-wins semantics. * No period fan-out — raw timestamps are used. * + * When $time is provided, it is threaded through to the adapter so + * the row is written at the moment it was originally emitted rather + * than at the flush moment. When $time is null the adapter picks + * now() on write (unchanged behaviour). + * + * For events with the same (tenant, metric, tags) tuple, only the + * first non-null time survives — later calls sum values into the + * existing bucket and preserve the earliest queued time. Gauges use + * last-write-wins, so the most recently supplied time wins. + * * @param string $tenant Tenant scope (shared-tables mode) * @param string $metric Metric name * @param int $value Value * @param string $type Metric type: 'event' or 'gauge' * @param array $tags Optional tags + * @param \DateTime|null $time Optional queued timestamp; null => now() on write * @return self */ - public function collect(string $tenant, string $metric, int $value, string $type, array $tags = []): self + public function collect(string $tenant, string $metric, int $value, string $type, array $tags = [], ?\DateTime $time = null): self { // Compare against '' rather than empty(): the string "0" is a valid // tenant/metric id but empty("0") is true in PHP. @@ -77,17 +93,26 @@ public function collect(string $tenant, string $metric, int $value, string $type $key = md5(json_encode([$tenant, $metric, $type, $canonicalTags], JSON_THROW_ON_ERROR)); if ($type === Usage::TYPE_EVENT && isset($this->buffer[$key])) { - // Additive: sum values for the same tenant + metric + tags combination + // Additive: sum values for the same tenant + metric + tags combination. + // Preserve the earliest queued time — later calls fold in without + // moving the bucket's timestamp forward. $this->buffer[$key]['value'] += $value; + if ($time !== null && !isset($this->buffer[$key]['time'])) { + $this->buffer[$key]['time'] = $time; + } } else { // New event entry, or gauge (last-write-wins) - $this->buffer[$key] = [ + $entry = [ 'tenant' => $tenant, 'metric' => $metric, 'value' => $value, 'type' => $type, 'tags' => $tags, ]; + if ($time !== null) { + $entry['time'] = $time; + } + $this->buffer[$key] = $entry; } return $this; diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index d1bc659..45b249e 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -1296,11 +1296,19 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN $columns = Metric::extractColumns($tags, $type); + // Callers (e.g. Accumulator) can pin the row to the moment + // the metric was originally emitted rather than the flush + // moment. Missing / invalid time falls back to now(). + $rawTime = $metricData['time'] ?? null; + $emittedAt = ($rawTime instanceof DateTime || is_string($rawTime)) + ? $rawTime + : null; + $row = array_merge([ 'id' => $this->generateId(), 'metric' => $metric, 'value' => $value, - 'time' => $this->formatDateTime(null), + 'time' => $this->formatDateTime($emittedAt), ], $columns); if ($this->sharedTables) { diff --git a/src/Usage/Usage.php b/src/Usage/Usage.php index 7897158..a5f65bc 100644 --- a/src/Usage/Usage.php +++ b/src/Usage/Usage.php @@ -66,7 +66,7 @@ public function setup(): void * Each metric carries its own `tenant` (shared-tables mode), so a single * batch may span multiple tenants. * - * @param array}> $metrics + * @param array, time?: \DateTime|string}> $metrics * @param string $type Metric type: 'event' or 'gauge' * @param int $batchSize Maximum number of metrics per INSERT statement * @return bool diff --git a/tests/Usage/AccumulatorTest.php b/tests/Usage/AccumulatorTest.php index f63808a..5ee133a 100644 --- a/tests/Usage/AccumulatorTest.php +++ b/tests/Usage/AccumulatorTest.php @@ -287,4 +287,61 @@ public function testInvalidTypeThrows(): void $this->expectException(\InvalidArgumentException::class); $this->accumulator->collect('t1', 'requests', 10, 'invalid'); } + + public function testCollectWithoutTimeOmitsField(): void + { + // Callers that don't set $time must not surface a null/empty time + // in the buffered entry — the adapter interprets "no time" as + // "use now() at write". + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT); + + $this->assertTrue($this->accumulator->flush()); + $this->assertArrayNotHasKey('time', $this->adapter->batches[0]['metrics'][0]); + } + + public function testCollectThreadsQueuedTimeToBatch(): void + { + $emittedAt = new \DateTime('2026-04-15 12:34:56'); + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT, [], $emittedAt); + + $this->assertTrue($this->accumulator->flush()); + + $entry = $this->adapter->batches[0]['metrics'][0]; + $this->assertArrayHasKey('time', $entry); + $this->assertSame($emittedAt, $entry['time']); + } + + public function testEventCollectPreservesEarliestQueuedTime(): void + { + // Two collect() calls fold into one entry; the earliest queued + // time survives so buckets don't slide forward on late arrivals. + $earlier = new \DateTime('2026-04-15 12:00:00'); + $later = new \DateTime('2026-04-15 12:05:00'); + + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT, [], $earlier); + $this->accumulator->collect('t1', 'requests', 5, Usage::TYPE_EVENT, [], $later); + + $this->assertEquals(1, $this->accumulator->count()); + $this->assertTrue($this->accumulator->flush()); + + $entry = $this->adapter->batches[0]['metrics'][0]; + $this->assertEquals(15, $entry['value']); + $this->assertSame($earlier, $entry['time']); + } + + public function testGaugeCollectUsesLastWriteWinsForTime(): void + { + // Gauge collect() is last-write-wins on value; the queued time + // supplied on the last call wins alongside it. + $t1 = new \DateTime('2026-04-15 12:00:00'); + $t2 = new \DateTime('2026-04-15 12:05:00'); + + $this->accumulator->collect('t1', 'storage', 100, Usage::TYPE_GAUGE, [], $t1); + $this->accumulator->collect('t1', 'storage', 200, Usage::TYPE_GAUGE, [], $t2); + + $this->assertTrue($this->accumulator->flush()); + $entry = $this->adapter->batches[0]['metrics'][0]; + $this->assertEquals(200, $entry['value']); + $this->assertSame($t2, $entry['time']); + } } From 6fcd7ae50128bc9bdf14c855cdce9d4cabdb01d4 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 03:57:36 +0000 Subject: [PATCH 04/11] feat(gauges): fill missing buckets with last-known value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gauge time-series queries now carry the last observed value forward into buckets that have no snapshot inside the requested window, instead of collapsing to zero. Event time series keep the previous zero-fill semantics — additive metrics genuinely mean "no activity" when a bucket has no rows, whereas gauge metrics represent point-in-time state that persists across buckets. Design choice: the fill runs in PHP after getTimeSeriesFromTable() emits its `FORMAT JSON` result rather than as a ClickHouse `WITH FILL ... INTERPOLATE` clause on the aggregation query. Two reasons: 1. The outer getTimeSeries() already normalizes bucket keys before returning to the caller, so the fill has to run at that boundary regardless of what the SQL does. 2. Per-metric fill type (zero vs LOCF) depends on which table produced the row, which is tracked at PHP level via a metric->type map. Sinking that decision into SQL would require issuing one query per (metric, type) pair; keeping it in PHP costs O(buckets) iteration per metric and preserves the single-query read path. Semantics of the LOCF pass (locfFillTimeSeries): - Buckets before the first observed snapshot in the window get 0 — we haven't seen any value yet. - Buckets aligned to a snapshot get that snapshot's value. - Buckets between snapshots get the most recent earlier snapshot's value. - Multiple snapshots landing in the same bucket collapse to the latest by date string, matching argMax(value, time) on the write path. Type-map tracking: getTimeSeries() records which side (event / gauge) returned data for each metric name, then routes each metric through the matching fill helper. Metrics that returned no data fall back to the caller-supplied $type (or zero-fill when $type is null) so the existing shape of `data: [ {value:0, date:...}, ... ]` is preserved. Return shape is unchanged: array<{value: float, date: string}> per metric — the only observable difference is that gauge buckets now carry values across gaps. --- src/Usage/Adapter/ClickHouse.php | 109 +++++++++++++++++-- tests/Usage/Adapter/ClickHouseTest.php | 138 +++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 7 deletions(-) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 45b249e..812bb0c 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -2683,6 +2683,13 @@ public function getTimeSeries(string $tenant, array $metrics, string $interval, $typesToQuery[] = Usage::TYPE_GAUGE; } + // Track which side produced rows for each metric so the fill pass + // can choose zero-fill (events) vs last-observation-carried-forward + // (gauges). Event and gauge tables enforce disjoint metric names + // at read time — see the eventTotal/gaugeTotal collision guard in + // getTotal() — so a single metric never straddles both branches. + $metricTypes = []; + foreach ($typesToQuery as $queryType) { // Skip a table when its schema can't satisfy every filter attribute // (e.g. `path` on a gauge query); avoids "Invalid attribute name" @@ -2699,6 +2706,10 @@ public function getTimeSeries(string $tenant, array $metrics, string $interval, continue; } + if (!empty($metricData['data'])) { + $metricTypes[$metricName] = $queryType; + } + $output[$metricName]['total'] += $metricData['total']; $output[$metricName]['data'] = array_merge( $output[$metricName]['data'], @@ -2707,15 +2718,29 @@ public function getTimeSeries(string $tenant, array $metrics, string $interval, } } - // Zero-fill gaps if requested + // Fill gaps if requested. Events are additive → missing bucket + // means 0. Gauges are point-in-time state → a missing bucket + // between two snapshots should carry the previous value forward, + // otherwise a sub-hour interval read shows the metric collapsing + // to zero every time no snapshot happened to land in the bucket. if ($zeroFill) { foreach ($output as $metricName => &$metricData) { - $metricData['data'] = $this->zeroFillTimeSeries( - $metricData['data'], - $interval, - $startDate, - $endDate - ); + $fillType = $metricTypes[$metricName] ?? $type ?? Usage::TYPE_EVENT; + if ($fillType === Usage::TYPE_GAUGE) { + $metricData['data'] = $this->locfFillTimeSeries( + $metricData['data'], + $interval, + $startDate, + $endDate + ); + } else { + $metricData['data'] = $this->zeroFillTimeSeries( + $metricData['data'], + $interval, + $startDate, + $endDate + ); + } } unset($metricData); } @@ -2860,6 +2885,76 @@ private function zeroFillTimeSeries(array $data, string $interval, string $start return $result; } + /** + * Last-observation-carried-forward fill for gauge time series. + * + * Gauges are point-in-time state, so a bucket with no snapshot is + * not "zero" — it's "same value as the most recent snapshot". + * Buckets before the first snapshot in the window fall back to 0 + * (no earlier value has been observed in the requested window). + * + * A PHP-side LOCF pass is chosen over ClickHouse `WITH FILL ... + * INTERPOLATE` because getTimeSeriesFromTable() emits its result + * via `FORMAT JSON` after a plain GROUP BY / ORDER BY — the fill + * happens per (metric, bucket) row without a nested subquery — and + * because the outer merge step in getTimeSeries() already normalizes + * bucket keys, so the fill has to run in the same place regardless. + * + * Multiple points in the same bucket collapse to the most recent + * point's value (last-write-wins), matching argMax(value, time) + * semantics on the write side. + * + * @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 locfFillTimeSeries(array $data, string $interval, string $startDate, string $endDate): array + { + $format = $interval === '1h' ? 'Y-m-d\TH:00:00+00:00' : 'Y-m-d\T00:00:00+00:00'; + $step = $interval === '1h' ? '+1 hour' : '+1 day'; + + // Bucket -> most-recent value in that bucket. Order data by + // date so a later row overwrites an earlier one in the same + // bucket; the query side already orders by bucket ASC but the + // outer merge may have concatenated per-type slices, so we sort + // here defensively. + usort($data, fn (array $a, array $b): int => strcmp($a['date'], $b['date'])); + + $existing = []; + foreach ($data as $point) { + $dt = new DateTime($point['date']); + $key = $dt->format($format); + $existing[$key] = $point['value']; + } + + $start = new DateTime($startDate); + $end = new DateTime($endDate); + + $result = []; + $current = clone $start; + $lastValue = 0.0; + $seenAny = false; + + while ($current <= $end) { + $key = $current->format($format); + if (array_key_exists($key, $existing)) { + $lastValue = $existing[$key]; + $seenAny = true; + } + $result[] = [ + // Before the first observed bucket in the window: fall + // back to 0 rather than fabricating a value. + 'value' => $seenAny ? $lastValue : 0.0, + 'date' => $key, + ]; + $current->modify($step); + } + + return $result; + } + /** * Get total value for a single metric. * diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index b111c40..83aea81 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -1167,4 +1167,142 @@ public function testEqualRejectsEmptyValues(): void new Query(Query::TYPE_EQUAL, 'metric', []), ], Usage::TYPE_EVENT); } + + /** + * Gauge getTimeSeries() carries the last-observed value forward + * across buckets that have no snapshot, instead of collapsing to + * zero. Two hourly snapshots six hours apart should backfill the + * middle buckets with the earlier snapshot's value; buckets after + * the last snapshot should stick at that snapshot's value; buckets + * before the first snapshot fall back to 0 (no observation yet). + */ + public function testGaugeTimeSeriesFillsMissingBucketsWithLastKnownValue(): void + { + $this->usage->purge('1', [], Usage::TYPE_GAUGE); + + $windowStart = (new \DateTime('2026-06-01 00:00:00'))->format('Y-m-d H:i:s'); + $windowEnd = (new \DateTime('2026-06-01 10:00:00'))->format('Y-m-d H:i:s'); + + // Two snapshots inside the window: 02:00 -> 100, 08:00 -> 300. + // We ingest them with an explicit `time` so bucket alignment is + // deterministic. The addBatch payload accepts a `time` field + // when threaded through (see commit that adds queued-time on + // Accumulator::collect); this test exercises the same path. + $this->assertTrue($this->usage->addBatch([ + [ + 'tenant' => '1', + 'metric' => 'gauge-locf-fill', + 'value' => 100, + 'time' => new \DateTime('2026-06-01 02:00:00'), + 'tags' => [], + ], + [ + 'tenant' => '1', + 'metric' => 'gauge-locf-fill', + 'value' => 300, + 'time' => new \DateTime('2026-06-01 08:00:00'), + 'tags' => [], + ], + ], Usage::TYPE_GAUGE)); + + $results = $this->usage->getTimeSeries( + '1', + ['gauge-locf-fill'], + '1h', + $windowStart, + $windowEnd, + [], + true, + Usage::TYPE_GAUGE, + ); + + $this->assertArrayHasKey('gauge-locf-fill', $results); + $data = $results['gauge-locf-fill']['data']; + + $indexed = []; + foreach ($data as $point) { + $key = (new \DateTime($point['date']))->format('H'); + $indexed[$key] = $point['value']; + } + + $expected = [ + // Pre-first-snapshot buckets fall back to 0. + '00' => 0.0, + '01' => 0.0, + // First snapshot lands at 02:00. + '02' => 100.0, + // 03..07 carry the 100 forward until the next snapshot. + '03' => 100.0, + '04' => 100.0, + '05' => 100.0, + '06' => 100.0, + '07' => 100.0, + // Second snapshot at 08:00 updates the value. + '08' => 300.0, + // Post-snapshot buckets stick at 300. + '09' => 300.0, + '10' => 300.0, + ]; + foreach ($expected as $hour => $value) { + $actual = $indexed[$hour] ?? null; + $this->assertNotNull($actual, "missing bucket {$hour}:00"); + $this->assertSame($value, $actual, "hour {$hour}:00 filled with wrong value"); + } + } + + /** + * Event getTimeSeries() must keep the classic zero-fill semantics: + * buckets with no additive activity remain 0, and are NOT carried + * forward from earlier buckets. + */ + public function testEventTimeSeriesStillZeroFillsMissingBuckets(): void + { + $this->usage->purge('1', [], Usage::TYPE_EVENT); + + $windowStart = (new \DateTime('2026-06-02 00:00:00'))->format('Y-m-d H:i:s'); + $windowEnd = (new \DateTime('2026-06-02 04:00:00'))->format('Y-m-d H:i:s'); + + // Single event at 01:00 with value 50. Later buckets must be 0, + // not 50. + $this->assertTrue($this->usage->addBatch([ + [ + 'tenant' => '1', + 'metric' => 'event-zero-fill', + 'value' => 50, + 'time' => new \DateTime('2026-06-02 01:00:00'), + 'tags' => [], + ], + ], Usage::TYPE_EVENT)); + + $results = $this->usage->getTimeSeries( + '1', + ['event-zero-fill'], + '1h', + $windowStart, + $windowEnd, + [], + true, + Usage::TYPE_EVENT, + ); + + $data = $results['event-zero-fill']['data']; + $indexed = []; + foreach ($data as $point) { + $key = (new \DateTime($point['date']))->format('H'); + $indexed[$key] = $point['value']; + } + + $expected = [ + '00' => 0.0, + '01' => 50.0, + '02' => 0.0, + '03' => 0.0, + '04' => 0.0, + ]; + foreach ($expected as $hour => $value) { + $actual = $indexed[$hour] ?? null; + $this->assertNotNull($actual, "missing bucket {$hour}:00"); + $this->assertSame($value, $actual, "hour {$hour}:00 filled with wrong value"); + } + } } From b97651fdc7a8093769d2adaf4c37b7777886c739 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 03:57:54 +0000 Subject: [PATCH 05/11] docs(changelog): unreleased notes for the four schema/behavior changes --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c1e25..75b08dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## Unreleased — resourceType rename + queued time + ip dim + gauge fill + +### Breaking + +- Renamed the `resource` dimension to `resourceType` across the + events, gauges, and events-daily tables. All Metric column + constants, schema definitions, indexes, projections + (`p_by_resource*` → `p_by_resourceType*`), materialized-view + dimension list, and the `Metric::getResource()` getter (now + `Metric::getResourceType()`) follow the new name. Callers must + rename the `resource` tag key on writes and any queries that + filter/group by the old name. A one-time ClickHouse column rename + plus data backfill is required for existing deployments; that + migration lives in the cloud repo, not in this library. + +### Added + +- `Accumulator::collect()` accepts an optional `\DateTime $time` + argument. When provided, the row is written at that moment; when + omitted the ClickHouse adapter still stamps `now()`. Events with + the same (tenant, metric, tags) fold into the earliest supplied + time; gauges follow last-write-wins on both value and time. +- `Usage::addBatch()` payloads may carry a `time` field per row + (`DateTime|string`) — the ClickHouse adapter threads it through + `formatDateTime()`. +- New event-only `ip` dimension in `Metric::EVENT_COLUMNS` and + `Metric::getEventSchema()`. Stored as + `LowCardinality(Nullable(String))` in ClickHouse, indexed with a + `bloom_filter`, size 45 chars (IPv6 max). Gauges are unchanged. + `Metric::getIp()` is a typed accessor. The base-table primary key + is intentionally not extended with `ip`. +- Gauge time-series reads now carry the last observed value forward + across empty buckets inside the window (LOCF) instead of collapsing + to zero. Events keep zero-fill. Return shape is unchanged. + ## 0.4.0 — Per-dimension ClickHouse projections and auto-routing This release accelerates grouped reads against the events and gauges From 58d33c0b38e670ac0ec7b56d9134805cddc1916d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 04:47:18 +0000 Subject: [PATCH 06/11] fix(tests): update DDL expectations after resource -> resourceType rename The rename shipped in this branch missed a handful of test-side expected-DDL strings and one numeric-type assertion that changed under the gauge LOCF path. Sync the test expectations to the current library output. --- src/Usage/Adapter/ClickHouse.php | 4 ++-- tests/Usage/Adapter/ClickHouseSchemaTest.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 812bb0c..6edc534 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -2863,7 +2863,7 @@ private function zeroFillTimeSeries(array $data, string $interval, string $start $dt = new DateTime($point['date']); $key = $dt->format($format); // If multiple points in the same bucket, sum them - $existing[$key] = ($existing[$key] ?? 0) + $point['value']; + $existing[$key] = ($existing[$key] ?? 0.0) + $point['value']; } // Generate all time buckets in range @@ -2876,7 +2876,7 @@ private function zeroFillTimeSeries(array $data, string $interval, string $start while ($current <= $end) { $key = $current->format($format); $result[] = [ - 'value' => $existing[$key] ?? 0, + 'value' => $existing[$key] ?? 0.0, 'date' => $key, ]; $current->modify($step); diff --git a/tests/Usage/Adapter/ClickHouseSchemaTest.php b/tests/Usage/Adapter/ClickHouseSchemaTest.php index 37c9e84..c641d1d 100644 --- a/tests/Usage/Adapter/ClickHouseSchemaTest.php +++ b/tests/Usage/Adapter/ClickHouseSchemaTest.php @@ -93,7 +93,7 @@ public function testGaugesTableCarriesServiceAndResource(): void $ddl = $this->showCreate($this->resolveTableName($this->adapter, 'getGaugesTableName')); $this->assertStringContainsString('`service` LowCardinality(Nullable(String))', $ddl); - $this->assertStringContainsString('`resource` LowCardinality(Nullable(String))', $ddl); + $this->assertStringContainsString('`resourceType` LowCardinality(Nullable(String))', $ddl); $this->assertStringContainsString("`time` DateTime64(3, 'UTC') CODEC(Delta(4), LZ4)", $ddl); $this->assertStringContainsString('`resourceId` Nullable(String) CODEC(ZSTD(3))', $ddl); $this->assertStringContainsString('`teamId` Nullable(String) CODEC(ZSTD(3))', $ddl); @@ -104,7 +104,7 @@ public function testGaugesTableSwapsBloomForSetOnLowCardinality(): void $ddl = $this->showCreate($this->resolveTableName($this->adapter, 'getGaugesTableName')); $this->assertStringContainsString('`index-service` service TYPE set(0)', $ddl); - $this->assertStringContainsString('`index-resource` resource TYPE set(0)', $ddl); + $this->assertStringContainsString('`index-resourceType` resourceType TYPE set(0)', $ddl); $this->assertStringContainsString('`index-resourceId` resourceId TYPE bloom_filter', $ddl); $this->assertStringContainsString('`index-teamId` teamId TYPE bloom_filter', $ddl); } @@ -158,7 +158,7 @@ public function testSetupBackfillsServiceResourceOnLegacyGaugesTable(): void } $this->assertStringContainsString('`service` LowCardinality(Nullable(String))', $ddl); - $this->assertStringContainsString('`resource` LowCardinality(Nullable(String))', $ddl); + $this->assertStringContainsString('`resourceType` LowCardinality(Nullable(String))', $ddl); } private function showCreate(string $table): string From 849ac52b7749ce33297fe8ef12b7ca4bfd07fcc7 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 05:07:24 +0000 Subject: [PATCH 07/11] fix(events): keep earliest time on out-of-order buffer merges Bring the implementation in line with the docblock/PR description promise: when two collect() calls fold into the same buffer key with different queued times, keep the earlier one. Previously the first non-null time won regardless of value, so an out-of-order redelivery could stamp the merged row with a later timestamp than the true earliest one. --- src/Usage/Accumulator.php | 5 +++-- tests/Usage/AccumulatorTest.php | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Usage/Accumulator.php b/src/Usage/Accumulator.php index 3f116ba..e74e287 100644 --- a/src/Usage/Accumulator.php +++ b/src/Usage/Accumulator.php @@ -95,9 +95,10 @@ public function collect(string $tenant, string $metric, int $value, string $type if ($type === Usage::TYPE_EVENT && isset($this->buffer[$key])) { // Additive: sum values for the same tenant + metric + tags combination. // Preserve the earliest queued time — later calls fold in without - // moving the bucket's timestamp forward. + // moving the bucket's timestamp forward, even when they arrive + // out of order (e.g. an earlier event redelivered after a later one). $this->buffer[$key]['value'] += $value; - if ($time !== null && !isset($this->buffer[$key]['time'])) { + if ($time !== null && (!isset($this->buffer[$key]['time']) || $time < $this->buffer[$key]['time'])) { $this->buffer[$key]['time'] = $time; } } else { diff --git a/tests/Usage/AccumulatorTest.php b/tests/Usage/AccumulatorTest.php index 5ee133a..8495c48 100644 --- a/tests/Usage/AccumulatorTest.php +++ b/tests/Usage/AccumulatorTest.php @@ -329,6 +329,25 @@ public function testEventCollectPreservesEarliestQueuedTime(): void $this->assertSame($earlier, $entry['time']); } + public function testEventCollectKeepsEarliestTimeOnOutOfOrderMerge(): void + { + // Out-of-order redelivery: the later time arrives FIRST, the earlier + // time second. The merged bucket must carry the earlier time so + // buckets never slide forward when late-arriving events fold in. + $later = new \DateTime('2026-04-15 12:05:00'); + $earlier = new \DateTime('2026-04-15 12:00:00'); + + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT, [], $later); + $this->accumulator->collect('t1', 'requests', 5, Usage::TYPE_EVENT, [], $earlier); + + $this->assertEquals(1, $this->accumulator->count()); + $this->assertTrue($this->accumulator->flush()); + + $entry = $this->adapter->batches[0]['metrics'][0]; + $this->assertEquals(15, $entry['value']); + $this->assertSame($earlier, $entry['time']); + } + public function testGaugeCollectUsesLastWriteWinsForTime(): void { // Gauge collect() is last-write-wins on value; the queued time From 7a3770294595a35953c3ade8465aee42b97ed6b1 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 05:14:20 +0000 Subject: [PATCH 08/11] style: trim verbose comments across the schema changes Comments were justifying design decisions and restating code behaviour; the design rationale belongs in the commit bodies where it already is. Kept only the non-obvious invariants (last-write-wins on write, LOCF fallback for pre-window gauge buckets). --- src/Usage/Accumulator.php | 39 +++------------------------ src/Usage/Adapter/ClickHouse.php | 45 ++++---------------------------- src/Usage/Metric.php | 3 --- 3 files changed, 9 insertions(+), 78 deletions(-) diff --git a/src/Usage/Accumulator.php b/src/Usage/Accumulator.php index e74e287..b898be9 100644 --- a/src/Usage/Accumulator.php +++ b/src/Usage/Accumulator.php @@ -16,16 +16,6 @@ class Accumulator private Usage $usage; /** - * In-memory buffer for metrics. - * Keyed by "{tenant}:{metric}:{type}:{tagsHash}" — events are summed, gauges - * use last-write-wins. Tenant is part of the key so metrics for different - * tenants never collapse into one entry. - * - * Each entry may carry the `time` at which the event was originally - * emitted; the adapter uses it when writing so buffered rows land at - * the queued moment rather than the flush moment. Missing time means - * the adapter picks now() on write. - * * @var array, time?: \DateTime}> */ private array $buffer = []; @@ -45,27 +35,10 @@ public function __construct(Usage $usage) /** * Collect a metric into the in-memory buffer for deferred flushing. * - * For event type: multiple collect() calls for the same metric are summed. - * For gauge type: last-write-wins semantics. - * No period fan-out — raw timestamps are used. - * - * When $time is provided, it is threaded through to the adapter so - * the row is written at the moment it was originally emitted rather - * than at the flush moment. When $time is null the adapter picks - * now() on write (unchanged behaviour). - * - * For events with the same (tenant, metric, tags) tuple, only the - * first non-null time survives — later calls sum values into the - * existing bucket and preserve the earliest queued time. Gauges use - * last-write-wins, so the most recently supplied time wins. + * Events fold additively; earliest non-null time wins on merge. + * Gauges use last-write-wins. * - * @param string $tenant Tenant scope (shared-tables mode) - * @param string $metric Metric name - * @param int $value Value - * @param string $type Metric type: 'event' or 'gauge' - * @param array $tags Optional tags - * @param \DateTime|null $time Optional queued timestamp; null => now() on write - * @return self + * @param array $tags */ public function collect(string $tenant, string $metric, int $value, string $type, array $tags = [], ?\DateTime $time = null): self { @@ -93,16 +66,12 @@ public function collect(string $tenant, string $metric, int $value, string $type $key = md5(json_encode([$tenant, $metric, $type, $canonicalTags], JSON_THROW_ON_ERROR)); if ($type === Usage::TYPE_EVENT && isset($this->buffer[$key])) { - // Additive: sum values for the same tenant + metric + tags combination. - // Preserve the earliest queued time — later calls fold in without - // moving the bucket's timestamp forward, even when they arrive - // out of order (e.g. an earlier event redelivered after a later one). + // earliest time wins on merge $this->buffer[$key]['value'] += $value; if ($time !== null && (!isset($this->buffer[$key]['time']) || $time < $this->buffer[$key]['time'])) { $this->buffer[$key]['time'] = $time; } } else { - // New event entry, or gauge (last-write-wins) $entry = [ 'tenant' => $tenant, 'metric' => $metric, diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 6edc534..9f3edd0 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -1296,9 +1296,6 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN $columns = Metric::extractColumns($tags, $type); - // Callers (e.g. Accumulator) can pin the row to the moment - // the metric was originally emitted rather than the flush - // moment. Missing / invalid time falls back to now(). $rawTime = $metricData['time'] ?? null; $emittedAt = ($rawTime instanceof DateTime || is_string($rawTime)) ? $rawTime @@ -2683,11 +2680,6 @@ public function getTimeSeries(string $tenant, array $metrics, string $interval, $typesToQuery[] = Usage::TYPE_GAUGE; } - // Track which side produced rows for each metric so the fill pass - // can choose zero-fill (events) vs last-observation-carried-forward - // (gauges). Event and gauge tables enforce disjoint metric names - // at read time — see the eventTotal/gaugeTotal collision guard in - // getTotal() — so a single metric never straddles both branches. $metricTypes = []; foreach ($typesToQuery as $queryType) { @@ -2718,11 +2710,6 @@ public function getTimeSeries(string $tenant, array $metrics, string $interval, } } - // Fill gaps if requested. Events are additive → missing bucket - // means 0. Gauges are point-in-time state → a missing bucket - // between two snapshots should carry the previous value forward, - // otherwise a sub-hour interval read shows the metric collapsing - // to zero every time no snapshot happened to land in the bucket. if ($zeroFill) { foreach ($output as $metricName => &$metricData) { $fillType = $metricTypes[$metricName] ?? $type ?? Usage::TYPE_EVENT; @@ -2886,28 +2873,12 @@ private function zeroFillTimeSeries(array $data, string $interval, string $start } /** - * Last-observation-carried-forward fill for gauge time series. + * Fill missing gauge buckets by carrying the last observation forward. * - * Gauges are point-in-time state, so a bucket with no snapshot is - * not "zero" — it's "same value as the most recent snapshot". - * Buckets before the first snapshot in the window fall back to 0 - * (no earlier value has been observed in the requested window). + * Multiple points in the same bucket collapse to the most recent point's + * value (last-write-wins), matching argMax(value, time) on the write side. * - * A PHP-side LOCF pass is chosen over ClickHouse `WITH FILL ... - * INTERPOLATE` because getTimeSeriesFromTable() emits its result - * via `FORMAT JSON` after a plain GROUP BY / ORDER BY — the fill - * happens per (metric, bucket) row without a nested subquery — and - * because the outer merge step in getTimeSeries() already normalizes - * bucket keys, so the fill has to run in the same place regardless. - * - * Multiple points in the same bucket collapse to the most recent - * point's value (last-write-wins), matching argMax(value, time) - * semantics on the write side. - * - * @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 * @return array */ private function locfFillTimeSeries(array $data, string $interval, string $startDate, string $endDate): array @@ -2915,11 +2886,6 @@ private function locfFillTimeSeries(array $data, string $interval, string $start $format = $interval === '1h' ? 'Y-m-d\TH:00:00+00:00' : 'Y-m-d\T00:00:00+00:00'; $step = $interval === '1h' ? '+1 hour' : '+1 day'; - // Bucket -> most-recent value in that bucket. Order data by - // date so a later row overwrites an earlier one in the same - // bucket; the query side already orders by bucket ASC but the - // outer merge may have concatenated per-type slices, so we sort - // here defensively. usort($data, fn (array $a, array $b): int => strcmp($a['date'], $b['date'])); $existing = []; @@ -2944,8 +2910,7 @@ private function locfFillTimeSeries(array $data, string $interval, string $start $seenAny = true; } $result[] = [ - // Before the first observed bucket in the window: fall - // back to 0 rather than fabricating a value. + // Pre-window buckets fall back to 0 rather than fabricating a value. 'value' => $seenAny ? $lastValue : 0.0, 'date' => $key, ]; diff --git a/src/Usage/Metric.php b/src/Usage/Metric.php index d902d20..c68b485 100644 --- a/src/Usage/Metric.php +++ b/src/Usage/Metric.php @@ -307,9 +307,6 @@ public function getHostname(): ?string /** * Get caller IP address (event metrics). - * - * Stored as-is. IPv4 dotted-decimal (up to 15 chars) or IPv6 - * (up to 45 chars including v4-mapped forms). */ public function getIp(): ?string { From 78f689e8cc7aef9c1a592011be32b42216d8d05a Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 05:26:51 +0000 Subject: [PATCH 09/11] fix(schema): backfill ip on existing events tables Existing deploys use CREATE TABLE IF NOT EXISTS, so an events table created before this PR never gets the new ip column via setup(). Add an ensureEventDimColumns() guard that mirrors the gauge equivalent - one ADD COLUMN IF NOT EXISTS ip ALTER, run right after createTable(). --- src/Usage/Adapter/ClickHouse.php | 14 +++++ tests/Usage/Adapter/ClickHouseSchemaTest.php | 59 ++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 9f3edd0..e9dfe40 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -727,6 +727,8 @@ public function setup(): void $this->getEventIndexes() ); + $this->ensureEventDimColumns(); + // --- Events daily table (SummingMergeTree) --- $this->createDailyTable(); @@ -795,6 +797,18 @@ private function ensureGaugeDimColumns(): void $this->query($sql); } + /** Backfill the ip column on an existing events table (setup uses IF NOT EXISTS, so pre-existing tables never receive it). */ + private function ensureEventDimColumns(): void + { + $eventsTable = $this->escapeIdentifier($this->database) + . '.' . $this->escapeIdentifier($this->getEventsTableName()); + + $sql = "ALTER TABLE {$eventsTable} " + . 'ADD COLUMN IF NOT EXISTS ip LowCardinality(Nullable(String))'; + + $this->query($sql); + } + /** * Idempotently add a projection to a base table. Projection columns are * (metric, time, [tenant,] ...dims, aggregate) and the GROUP BY shape diff --git a/tests/Usage/Adapter/ClickHouseSchemaTest.php b/tests/Usage/Adapter/ClickHouseSchemaTest.php index c641d1d..46d0842 100644 --- a/tests/Usage/Adapter/ClickHouseSchemaTest.php +++ b/tests/Usage/Adapter/ClickHouseSchemaTest.php @@ -161,6 +161,65 @@ public function testSetupBackfillsServiceResourceOnLegacyGaugesTable(): void $this->assertStringContainsString('`resourceType` LowCardinality(Nullable(String))', $ddl); } + public function testSetupBackfillsIpOnLegacyEventsTable(): void + { + $legacyAdapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_schema_legacy_event', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); + $database = $this->databaseName($legacyAdapter); + $eventsTable = $this->resolveTableName($legacyAdapter, 'getEventsTableName'); + $dailyTable = $this->resolveTableName($legacyAdapter, 'getEventsDailyTableName'); + $dailyMv = $this->resolveTableName($legacyAdapter, 'getTableName') . '_events_daily_mv'; + $fullName = "`{$database}`.`{$eventsTable}`"; + $fullDaily = "`{$database}`.`{$dailyTable}`"; + $fullMv = "`{$database}`.`{$dailyMv}`"; + + $this->queryRaw($legacyAdapter, "DROP TABLE IF EXISTS {$fullMv}"); + $this->queryRaw($legacyAdapter, "DROP TABLE IF EXISTS {$fullDaily}"); + $this->queryRaw($legacyAdapter, "DROP TABLE IF EXISTS {$fullName}"); + $this->queryRaw($legacyAdapter, " + CREATE TABLE {$fullName} ( + id String, + metric String, + value Int64, + time DateTime64(3, 'UTC'), + service LowCardinality(Nullable(String)), + resourceType LowCardinality(Nullable(String)), + resourceId Nullable(String), + resourceInternalId Nullable(String), + teamId Nullable(String), + teamInternalId Nullable(String), + tenant Nullable(String) + ) + ENGINE = MergeTree() + ORDER BY (tenant, metric, time, id) + PARTITION BY toYYYYMM(time) + SETTINGS allow_nullable_key = 1 + "); + + $usage = new Usage($legacyAdapter); + $usage->setup(); + + $rawString = $this->queryRaw($legacyAdapter, "SHOW CREATE TABLE {$fullName} FORMAT JSON"); + $json = json_decode($rawString, true); + $ddl = ''; + if (is_array($json) && isset($json['data']) && is_array($json['data']) && isset($json['data'][0]) && is_array($json['data'][0])) { + $statement = $json['data'][0]['statement'] ?? ''; + if (is_string($statement)) { + $ddl = $statement; + } + } + + $this->assertStringContainsString('`ip` LowCardinality(Nullable(String))', $ddl); + } + private function showCreate(string $table): string { $database = $this->databaseName($this->adapter); From 2bf95189cb4713759d53315393ea3e6f8708ef31 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 05:37:03 +0000 Subject: [PATCH 10/11] fix(schema): ensure all event-projection dim columns before setup adds projections ensureEventDimColumns() was only adding ip, but pre-existing events tables may also lack path / country / service (the columns EVENT_PROJECTIONS reference). setup() would then fail on ADD PROJECTION p_by_path because the source column doesn't exist. Extend the guard to cover every dim column the projection slate references, matching the gauge helper's pattern. Also extend ensureGaugeDimColumns() to add resourceId. GAUGE_PROJECTIONS references resourceId (p_by_resourceId, p_by_resourceType_resourceId), and resourceId was added to the gauge schema after the very first release (pre-May 2026 deployments started with just metric/value/time/tags), so the same drift window applies. Types match Metric.php: LowCardinality Nullable(String) for the low-card dims, Nullable(String) for path and resourceId. --- src/Usage/Adapter/ClickHouse.php | 16 +++++++--------- tests/Usage/Adapter/ClickHouseSchemaTest.php | 3 +++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index e9dfe40..3c7880a 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -778,13 +778,7 @@ private function setLightweightMutationProjectionMode(string $baseTable): void $this->query($sql); } - /** - * Backfill the service / resourceType columns on an existing gauges table. - * setup() uses CREATE TABLE IF NOT EXISTS, so deployments that came up - * before these columns were added never receive them — the gauge - * projections would then fail because their SELECT references columns - * the source table lacks. - */ + /** Backfill dimension columns referenced by gauge projections on pre-existing tables. */ private function ensureGaugeDimColumns(): void { $gaugesTable = $this->escapeIdentifier($this->database) @@ -792,18 +786,22 @@ private function ensureGaugeDimColumns(): void $sql = "ALTER TABLE {$gaugesTable} " . 'ADD COLUMN IF NOT EXISTS service LowCardinality(Nullable(String)), ' - . 'ADD COLUMN IF NOT EXISTS resourceType LowCardinality(Nullable(String))'; + . 'ADD COLUMN IF NOT EXISTS resourceType LowCardinality(Nullable(String)), ' + . 'ADD COLUMN IF NOT EXISTS resourceId Nullable(String)'; $this->query($sql); } - /** Backfill the ip column on an existing events table (setup uses IF NOT EXISTS, so pre-existing tables never receive it). */ + /** Backfill dimension columns referenced by event projections on pre-existing tables. */ private function ensureEventDimColumns(): void { $eventsTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($this->getEventsTableName()); $sql = "ALTER TABLE {$eventsTable} " + . 'ADD COLUMN IF NOT EXISTS path Nullable(String), ' + . 'ADD COLUMN IF NOT EXISTS country LowCardinality(Nullable(String)), ' + . 'ADD COLUMN IF NOT EXISTS service LowCardinality(Nullable(String)), ' . 'ADD COLUMN IF NOT EXISTS ip LowCardinality(Nullable(String))'; $this->query($sql); diff --git a/tests/Usage/Adapter/ClickHouseSchemaTest.php b/tests/Usage/Adapter/ClickHouseSchemaTest.php index 46d0842..1831e07 100644 --- a/tests/Usage/Adapter/ClickHouseSchemaTest.php +++ b/tests/Usage/Adapter/ClickHouseSchemaTest.php @@ -218,6 +218,9 @@ public function testSetupBackfillsIpOnLegacyEventsTable(): void } $this->assertStringContainsString('`ip` LowCardinality(Nullable(String))', $ddl); + $this->assertStringContainsString('`path` Nullable(String)', $ddl); + $this->assertStringContainsString('`country` LowCardinality(Nullable(String))', $ddl); + $this->assertStringContainsString('`service` LowCardinality(Nullable(String))', $ddl); } private function showCreate(string $table): string From 03a4483b6ef747bdaf6a09379c34ae0938056d32 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 2 Jul 2026 07:18:01 +0000 Subject: [PATCH 11/11] fix(schema): ensure every insert column, not a hand-picked subset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureEventDimColumns and ensureGaugeDimColumns were only backfilling the columns the projections referenced. But the insert column list is broader — any missing column there fails every insert on legacy tables. Iterate EVENT_COLUMNS / GAUGE_COLUMNS and add IF NOT EXISTS for each optional dim; skip the base primary-key columns which must exist by construction. --- src/Usage/Adapter/ClickHouse.php | 46 +++++++----- tests/Usage/Adapter/ClickHouseSchemaTest.php | 76 ++++++++++++-------- 2 files changed, 74 insertions(+), 48 deletions(-) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 3c7880a..8a88899 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -778,33 +778,41 @@ private function setLightweightMutationProjectionMode(string $baseTable): void $this->query($sql); } - /** Backfill dimension columns referenced by gauge projections on pre-existing tables. */ + private const BASE_KEY_COLUMNS = ['id', 'metric', 'value', 'time', 'tenant']; + private function ensureGaugeDimColumns(): void { - $gaugesTable = $this->escapeIdentifier($this->database) - . '.' . $this->escapeIdentifier($this->getGaugesTableName()); - - $sql = "ALTER TABLE {$gaugesTable} " - . 'ADD COLUMN IF NOT EXISTS service LowCardinality(Nullable(String)), ' - . 'ADD COLUMN IF NOT EXISTS resourceType LowCardinality(Nullable(String)), ' - . 'ADD COLUMN IF NOT EXISTS resourceId Nullable(String)'; - - $this->query($sql); + $this->ensureDimColumns($this->getGaugesTableName(), Metric::GAUGE_COLUMNS, 'gauge'); } - /** Backfill dimension columns referenced by event projections on pre-existing tables. */ private function ensureEventDimColumns(): void { - $eventsTable = $this->escapeIdentifier($this->database) - . '.' . $this->escapeIdentifier($this->getEventsTableName()); + $this->ensureDimColumns($this->getEventsTableName(), Metric::EVENT_COLUMNS, 'event'); + } + + /** + * @param array $columns + */ + private function ensureDimColumns(string $tableName, array $columns, string $type): void + { + $escapedTable = $this->escapeIdentifier($this->database) + . '.' . $this->escapeIdentifier($tableName); + + $adds = []; + foreach ($columns as $column) { + if (in_array($column, self::BASE_KEY_COLUMNS, true)) { + continue; + } + $adds[] = 'ADD COLUMN IF NOT EXISTS ' + . $this->escapeIdentifier($column) + . ' ' . $this->getColumnType($column, $type); + } - $sql = "ALTER TABLE {$eventsTable} " - . 'ADD COLUMN IF NOT EXISTS path Nullable(String), ' - . 'ADD COLUMN IF NOT EXISTS country LowCardinality(Nullable(String)), ' - . 'ADD COLUMN IF NOT EXISTS service LowCardinality(Nullable(String)), ' - . 'ADD COLUMN IF NOT EXISTS ip LowCardinality(Nullable(String))'; + if ($adds === []) { + return; + } - $this->query($sql); + $this->query("ALTER TABLE {$escapedTable} " . implode(', ', $adds)); } /** diff --git a/tests/Usage/Adapter/ClickHouseSchemaTest.php b/tests/Usage/Adapter/ClickHouseSchemaTest.php index 1831e07..281ef27 100644 --- a/tests/Usage/Adapter/ClickHouseSchemaTest.php +++ b/tests/Usage/Adapter/ClickHouseSchemaTest.php @@ -4,6 +4,7 @@ use Utopia\Tests\Usage\Adapter\ClickHouseTestCase; use Utopia\Usage\Adapter\ClickHouse as ClickHouseAdapter; +use Utopia\Usage\Metric; use Utopia\Usage\Usage; /** @@ -132,10 +133,6 @@ public function testSetupBackfillsServiceResourceOnLegacyGaugesTable(): void metric String, value Int64, time DateTime64(3), - resourceId Nullable(String), - resourceInternalId Nullable(String), - teamId Nullable(String), - teamInternalId Nullable(String), tenant Nullable(String) ) ENGINE = MergeTree() @@ -147,18 +144,11 @@ public function testSetupBackfillsServiceResourceOnLegacyGaugesTable(): void $usage = new Usage($legacyAdapter); $usage->setup(); - $rawString = $this->queryRaw($legacyAdapter, "SHOW CREATE TABLE {$fullName} FORMAT JSON"); - $json = json_decode($rawString, true); - $ddl = ''; - if (is_array($json) && isset($json['data']) && is_array($json['data']) && isset($json['data'][0]) && is_array($json['data'][0])) { - $statement = $json['data'][0]['statement'] ?? ''; - if (is_string($statement)) { - $ddl = $statement; - } - } + $ddl = $this->showCreateFor($legacyAdapter, $fullName); - $this->assertStringContainsString('`service` LowCardinality(Nullable(String))', $ddl); - $this->assertStringContainsString('`resourceType` LowCardinality(Nullable(String))', $ddl); + foreach ($this->expectedDimAssertions(Metric::GAUGE_COLUMNS, 'gauge') as $expected) { + $this->assertStringContainsString($expected, $ddl); + } } public function testSetupBackfillsIpOnLegacyEventsTable(): void @@ -190,12 +180,6 @@ public function testSetupBackfillsIpOnLegacyEventsTable(): void metric String, value Int64, time DateTime64(3, 'UTC'), - service LowCardinality(Nullable(String)), - resourceType LowCardinality(Nullable(String)), - resourceId Nullable(String), - resourceInternalId Nullable(String), - teamId Nullable(String), - teamInternalId Nullable(String), tenant Nullable(String) ) ENGINE = MergeTree() @@ -207,20 +191,54 @@ public function testSetupBackfillsIpOnLegacyEventsTable(): void $usage = new Usage($legacyAdapter); $usage->setup(); - $rawString = $this->queryRaw($legacyAdapter, "SHOW CREATE TABLE {$fullName} FORMAT JSON"); + $ddl = $this->showCreateFor($legacyAdapter, $fullName); + + foreach ($this->expectedDimAssertions(Metric::EVENT_COLUMNS, 'event') as $expected) { + $this->assertStringContainsString($expected, $ddl); + } + } + + /** + * @param array $columns + * @return array + */ + private function expectedDimAssertions(array $columns, string $type): array + { + $lowCardinality = [ + 'country', 'region', 'service', 'resourceType', + 'osCode', 'osName', 'osVersion', + 'clientType', 'clientCode', 'clientName', 'clientVersion', + 'clientEngine', 'clientEngineVersion', + 'deviceName', 'deviceBrand', 'deviceModel', + 'hostname', 'ip', + ]; + + $baseKey = ['id', 'metric', 'value', 'time', 'tenant']; + + $expected = []; + foreach ($columns as $column) { + if (in_array($column, $baseKey, true)) { + continue; + } + $suffix = in_array($column, $lowCardinality, true) + ? 'LowCardinality(Nullable(String))' + : 'Nullable(String)'; + $expected[] = "`{$column}` {$suffix}"; + } + return $expected; + } + + private function showCreateFor(ClickHouseAdapter $adapter, string $fullName): string + { + $rawString = $this->queryRaw($adapter, "SHOW CREATE TABLE {$fullName} FORMAT JSON"); $json = json_decode($rawString, true); - $ddl = ''; if (is_array($json) && isset($json['data']) && is_array($json['data']) && isset($json['data'][0]) && is_array($json['data'][0])) { $statement = $json['data'][0]['statement'] ?? ''; if (is_string($statement)) { - $ddl = $statement; + return $statement; } } - - $this->assertStringContainsString('`ip` LowCardinality(Nullable(String))', $ddl); - $this->assertStringContainsString('`path` Nullable(String)', $ddl); - $this->assertStringContainsString('`country` LowCardinality(Nullable(String))', $ddl); - $this->assertStringContainsString('`service` LowCardinality(Nullable(String))', $ddl); + return ''; } private function showCreate(string $table): string