From 7eafc976e5975e55e8d0950813d4da7fae59e55b Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:44:43 +0100 Subject: [PATCH 1/9] refactor(usage): extract buffering into a standalone Accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move collect()/flush() and the in-memory buffer out of Usage into a dedicated Accumulator that consumers instantiate against an adapter. Replace the shouldFlush()/threshold policy with raw signals — count() and elapsedSeconds() — so callers compose their own flush decision. Usage is now a pure adapter facade. Buffer behaviour is covered by a new DB-free AccumulatorTest using a recording fake adapter; the buffer tests are removed from UsageBase. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 25 ++-- src/Usage/Accumulator.php | 169 ++++++++++++++++++++++ src/Usage/Usage.php | 245 -------------------------------- tests/Usage/AccumulatorTest.php | 236 ++++++++++++++++++++++++++++++ tests/Usage/UsageBase.php | 182 ------------------------ 5 files changed, 420 insertions(+), 437 deletions(-) create mode 100644 src/Usage/Accumulator.php create mode 100644 tests/Usage/AccumulatorTest.php diff --git a/README.md b/README.md index 1d3c1f9..df9cedc 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,13 @@ Event-specific columns (see `Metric::EVENT_COLUMNS`): `path`, `method`, `status` `deviceModel`. ```php +use Utopia\Usage\Accumulator; + +// Buffer metrics in memory and flush them in batch +$accumulator = new Accumulator($adapter); + // Collect events — values accumulate in-memory buffer (summed per metric) -$usage->collect('bandwidth', 5000, Usage::TYPE_EVENT, [ +$accumulator->collect('bandwidth', 5000, Usage::TYPE_EVENT, [ 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', @@ -115,8 +120,8 @@ Gauge-specific columns (see `Metric::GAUGE_COLUMNS`): `teamId`, ```php // Collect gauges — last value wins per metric in buffer -$usage->collect('users', 1500, Usage::TYPE_GAUGE); -$usage->collect('storage.size', 1048576, Usage::TYPE_GAUGE, [ +$accumulator->collect('users', 1500, Usage::TYPE_GAUGE); +$accumulator->collect('storage.size', 1048576, Usage::TYPE_GAUGE, [ 'teamId' => 'team_x', 'teamInternalId' => '7', 'resourceId' => 'abc123', @@ -126,15 +131,15 @@ $usage->collect('storage.size', 1048576, Usage::TYPE_GAUGE, [ ### Flushing +The accumulator exposes raw signals — `count()` (buffered entries) and +`elapsedSeconds()` (seconds since last flush) — and leaves the flush policy to +the caller. + ```php -// Check if flush is recommended (threshold or interval reached) -if ($usage->shouldFlush()) { - $usage->flush(); // Writes events to events table, gauges to gauges table +// Flush when the buffer grows large or enough time has passed +if ($accumulator->count() >= 5000 || $accumulator->elapsedSeconds() >= 10) { + $accumulator->flush(); // Writes events to events table, gauges to gauges table } - -// Configure thresholds -$usage->setFlushThreshold(5000); // Flush after 5000 collect() calls (default: 10,000) -$usage->setFlushInterval(10); // Flush after 10 seconds (default: 20) ``` ### Batch Writes diff --git a/src/Usage/Accumulator.php b/src/Usage/Accumulator.php new file mode 100644 index 0000000..c10540b --- /dev/null +++ b/src/Usage/Accumulator.php @@ -0,0 +1,169 @@ +}> + */ + private array $buffer = []; + + /** @var float Timestamp of the last flush */ + private float $flushedAt; + + /** + * @param Adapter $adapter The adapter to flush buffered metrics to + */ + public function __construct(Adapter $adapter) + { + $this->adapter = $adapter; + $this->flushedAt = microtime(true); + } + + /** + * Collect a metric into the in-memory buffer for deferred flushing. + * + * For event type: multiple collect() calls for the same metric are summed. + * For gauge type: last-write-wins semantics. + * No period fan-out — raw timestamps are used. + * + * @param string $metric Metric name + * @param int $value Value + * @param string $type Metric type: 'event' or 'gauge' + * @param array $tags Optional tags + * @return self + */ + public function collect(string $metric, int $value, string $type, array $tags = []): self + { + if (empty($metric)) { + throw new \InvalidArgumentException('Metric name cannot be empty'); + } + if ($value < 0) { + throw new \InvalidArgumentException('Value cannot be negative'); + } + if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) { + throw new \InvalidArgumentException("Invalid metric type '{$type}'. Allowed: " . Usage::TYPE_EVENT . ', ' . Usage::TYPE_GAUGE); + } + + $tagsHash = !empty($tags) ? md5(json_encode($tags, JSON_THROW_ON_ERROR)) : ''; + $key = $metric . ':' . $type . ':' . $tagsHash; + + if ($type === Usage::TYPE_EVENT && isset($this->buffer[$key])) { + // Additive: sum values for the same metric + tags combination + $this->buffer[$key]['value'] += $value; + } else { + // New event entry, or gauge (last-write-wins) + $this->buffer[$key] = [ + 'metric' => $metric, + 'value' => $value, + 'type' => $type, + 'tags' => $tags, + ]; + } + + return $this; + } + + /** + * Flush the in-memory buffer to storage. + * + * Separates buffered metrics into events and gauges, then writes each batch + * to the appropriate table via addBatch(). Only entries whose batch write + * succeeds are removed from the buffer, so a partial failure preserves + * the unwritten metrics for retry on the next flush. + * + * If addBatch() throws mid-flush, any earlier successful batches have + * already been cleared from the buffer — the exception is allowed to + * propagate so the caller can observe the failure. + * + * @return bool True if all batches succeeded (or buffer was empty) + * @throws \Exception + */ + public function flush(): bool + { + if (empty($this->buffer)) { + $this->flushedAt = microtime(true); + return true; + } + + // Separate events and gauges; keep track of buffer keys so we can + // selectively unset only the entries whose write succeeded. + $eventKeys = []; + $gaugeKeys = []; + $events = []; + $gauges = []; + + foreach ($this->buffer as $key => $entry) { + if ($entry['type'] === Usage::TYPE_EVENT) { + $events[] = $entry; + $eventKeys[] = $key; + } else { + $gauges[] = $entry; + $gaugeKeys[] = $key; + } + } + + $overallResult = true; + + // Flush events — clear buffer entries only on success. + if (!empty($events)) { + if ($this->adapter->addBatch($events, Usage::TYPE_EVENT)) { + foreach ($eventKeys as $key) { + unset($this->buffer[$key]); + } + } else { + $overallResult = false; + } + } + + // Flush gauges — clear buffer entries only on success. + if (!empty($gauges)) { + if ($this->adapter->addBatch($gauges, Usage::TYPE_GAUGE)) { + foreach ($gaugeKeys as $key) { + unset($this->buffer[$key]); + } + } else { + $overallResult = false; + } + } + + $this->flushedAt = microtime(true); + + return $overallResult; + } + + /** + * Number of unique metric entries currently buffered. + * + * @return int + */ + public function count(): int + { + return count($this->buffer); + } + + /** + * Seconds elapsed since the last flush. + * + * @return float + */ + public function elapsedSeconds(): float + { + return microtime(true) - $this->flushedAt; + } +} diff --git a/src/Usage/Usage.php b/src/Usage/Usage.php index 3097de1..446ff2a 100644 --- a/src/Usage/Usage.php +++ b/src/Usage/Usage.php @@ -17,31 +17,8 @@ class Usage public const TYPE_EVENT = 'event'; public const TYPE_GAUGE = 'gauge'; - private const DEFAULT_FLUSH_THRESHOLD = 10_000; - private const DEFAULT_FLUSH_INTERVAL = 20; - private Adapter $adapter; - /** - * In-memory buffer for metrics. - * Keyed by "{metric}:{type}" — events are summed, gauges use last-write-wins. - * - * @var array}> - */ - private array $buffer = []; - - /** @var int Number of collect() calls since last flush */ - private int $bufferCount = 0; - - /** @var int Flush when buffer reaches this many entries */ - private int $flushThreshold = self::DEFAULT_FLUSH_THRESHOLD; - - /** @var int Flush when this many seconds have elapsed since last flush */ - private int $flushInterval = self::DEFAULT_FLUSH_INTERVAL; - - /** @var float Timestamp of the last flush */ - private float $lastFlushTime; - /** * Constructor. * @@ -50,7 +27,6 @@ class Usage public function __construct(Adapter $adapter) { $this->adapter = $adapter; - $this->lastFlushTime = microtime(true); } /** @@ -266,236 +242,15 @@ public function sumDailyBatch(array $metrics, array $queries = []): array /** * Set the tenant ID for multi-tenant support. * - * Flushes the buffer first so any pending metrics are written under the - * previous tenant — buffered entries don't carry adapter context. - * * @param string|null $tenant * @return $this * @throws \Exception */ public function setTenant(?string $tenant): self { - $this->flush(); if (method_exists($this->adapter, 'setTenant')) { $this->adapter->setTenant($tenant); } return $this; } - - /** - * Collect a metric into the in-memory buffer for deferred flushing. - * - * For event type: multiple collect() calls for the same metric are summed. - * For gauge type: last-write-wins semantics. - * No period fan-out — raw timestamps are used. - * - * @param string $metric Metric name - * @param int $value Value - * @param string $type Metric type: 'event' or 'gauge' - * @param array $tags Optional tags - * @return self - */ - public function collect(string $metric, int $value, string $type, array $tags = []): self - { - if (empty($metric)) { - throw new \InvalidArgumentException('Metric name cannot be empty'); - } - if ($value < 0) { - throw new \InvalidArgumentException('Value cannot be negative'); - } - if ($type !== self::TYPE_EVENT && $type !== self::TYPE_GAUGE) { - throw new \InvalidArgumentException("Invalid metric type '{$type}'. Allowed: " . self::TYPE_EVENT . ', ' . self::TYPE_GAUGE); - } - - $tagsHash = !empty($tags) ? md5(json_encode($tags, JSON_THROW_ON_ERROR)) : ''; - $key = $metric . ':' . $type . ':' . $tagsHash; - - if ($type === self::TYPE_EVENT) { - // Additive: sum values for the same metric + tags combination - if (isset($this->buffer[$key])) { - $this->buffer[$key]['value'] += $value; - } else { - $this->buffer[$key] = [ - 'metric' => $metric, - 'value' => $value, - 'type' => $type, - 'tags' => $tags, - ]; - } - } else { - // Gauge: last-write-wins - $this->buffer[$key] = [ - 'metric' => $metric, - 'value' => $value, - 'type' => $type, - 'tags' => $tags, - ]; - } - - $this->bufferCount++; - - return $this; - } - - /** - * Flush the in-memory buffer to storage. - * - * Separates buffered metrics into events and gauges, then writes each batch - * to the appropriate table via addBatch(). Only entries whose batch write - * succeeds are removed from the buffer, so a partial failure preserves - * the unwritten metrics for retry on the next flush. - * - * If addBatch() throws mid-flush, any earlier successful batches have - * already been cleared from the buffer — the exception is allowed to - * propagate so the caller can observe the failure. - * - * @return bool True if all batches succeeded (or buffer was empty) - * @throws \Exception - */ - public function flush(): bool - { - if (empty($this->buffer)) { - $this->lastFlushTime = microtime(true); - return true; - } - - // Separate events and gauges; keep track of buffer keys so we can - // selectively unset only the entries whose write succeeded. - $eventKeys = []; - $gaugeKeys = []; - $events = []; - $gauges = []; - - foreach ($this->buffer as $key => $entry) { - if ($entry['type'] === self::TYPE_EVENT) { - $events[] = $entry; - $eventKeys[] = $key; - } else { - $gauges[] = $entry; - $gaugeKeys[] = $key; - } - } - - $overallResult = true; - - // Flush events — clear buffer entries only on success. - if (!empty($events)) { - if ($this->adapter->addBatch($events, self::TYPE_EVENT)) { - foreach ($eventKeys as $key) { - unset($this->buffer[$key]); - } - } else { - $overallResult = false; - } - } - - // Flush gauges — clear buffer entries only on success. - if (!empty($gauges)) { - if ($this->adapter->addBatch($gauges, self::TYPE_GAUGE)) { - foreach ($gaugeKeys as $key) { - unset($this->buffer[$key]); - } - } else { - $overallResult = false; - } - } - - $this->bufferCount = count($this->buffer); - $this->lastFlushTime = microtime(true); - - return $overallResult; - } - - /** - * Check if the buffer should be flushed based on thresholds. - * - * Returns true if either: - * - The number of collect() calls meets the flush threshold - * - The time since last flush exceeds the flush interval - * - * @return bool - */ - public function shouldFlush(): bool - { - if ($this->bufferCount >= $this->flushThreshold) { - return true; - } - - $elapsed = microtime(true) - $this->lastFlushTime; - if ($elapsed >= $this->flushInterval) { - return true; - } - - return false; - } - - /** - * Get the number of collect() calls since the last flush. - * - * @return int - */ - public function getBufferCount(): int - { - return $this->bufferCount; - } - - /** - * Get the number of unique metric entries in the buffer. - * - * @return int - */ - public function getBufferSize(): int - { - return count($this->buffer); - } - - /** - * Set the flush threshold (number of collect() calls before flush is recommended). - * - * @param int $threshold Must be >= 1 - * @return self - */ - public function setFlushThreshold(int $threshold): self - { - if ($threshold < 1) { - throw new \InvalidArgumentException('Flush threshold must be at least 1'); - } - $this->flushThreshold = $threshold; - return $this; - } - - /** - * Set the flush interval in seconds. - * - * @param int $seconds Must be >= 1 - * @return self - */ - public function setFlushInterval(int $seconds): self - { - if ($seconds < 1) { - throw new \InvalidArgumentException('Flush interval must be at least 1 second'); - } - $this->flushInterval = $seconds; - return $this; - } - - /** - * Get the flush threshold. - * - * @return int - */ - public function getFlushThreshold(): int - { - return $this->flushThreshold; - } - - /** - * Get the flush interval in seconds. - * - * @return int - */ - public function getFlushInterval(): int - { - return $this->flushInterval; - } } diff --git a/tests/Usage/AccumulatorTest.php b/tests/Usage/AccumulatorTest.php new file mode 100644 index 0000000..fe06790 --- /dev/null +++ b/tests/Usage/AccumulatorTest.php @@ -0,0 +1,236 @@ +, type: string}> */ + public array $batches = []; + + public bool $succeed = true; + + public function getName(): string + { + return 'recording'; + } + + /** + * @return array + */ + public function healthCheck(): array + { + return ['healthy' => true]; + } + + public function setup(): void + { + } + + /** + * @param array $metrics + */ + public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool + { + if ($this->succeed) { + $this->batches[] = ['metrics' => $metrics, 'type' => $type]; + } + return $this->succeed; + } + + /** + * @return array + */ + public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + { + return []; + } + + public function getTotal(string $metric, array $queries = [], ?string $type = null): int + { + return 0; + } + + /** + * @return array + */ + public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + { + return []; + } + + public function purge(array $queries = [], ?string $type = null): bool + { + return true; + } + + /** + * @return array + */ + public function find(array $queries = [], ?string $type = null): array + { + return []; + } + + public function count(array $queries = [], ?string $type = null, ?int $max = null): int + { + return 0; + } + + public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + { + return 0; + } + + /** + * @return array + */ + public function findDaily(array $queries = []): array + { + return []; + } + + public function sumDaily(array $queries = [], string $attribute = 'value'): int + { + return 0; + } + + /** + * @return array + */ + public function sumDailyBatch(array $metrics, array $queries = []): array + { + return []; + } +} + +class AccumulatorTest extends TestCase +{ + private RecordingAdapter $adapter; + + private Accumulator $accumulator; + + protected function setUp(): void + { + $this->adapter = new RecordingAdapter(); + $this->accumulator = new Accumulator($this->adapter); + } + + public function testEventsSumByKey(): void + { + $this->accumulator->collect('requests', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('requests', 20, Usage::TYPE_EVENT); + $this->accumulator->collect('requests', 30, Usage::TYPE_EVENT); + + // Same metric + tags = 1 entry, values summed + $this->assertEquals(1, $this->accumulator->count()); + + $this->assertTrue($this->accumulator->flush()); + + $this->assertCount(1, $this->adapter->batches); + $this->assertEquals(Usage::TYPE_EVENT, $this->adapter->batches[0]['type']); + $this->assertEquals(60, $this->adapter->batches[0]['metrics'][0]['value']); + } + + public function testTagsPartitionEntries(): void + { + $this->accumulator->collect('requests', 10, Usage::TYPE_EVENT, ['region' => 'us']); + $this->accumulator->collect('requests', 20, Usage::TYPE_EVENT, ['region' => 'eu']); + + // Distinct tags = distinct entries + $this->assertEquals(2, $this->accumulator->count()); + } + + public function testGaugesUseLastWriteWins(): void + { + $this->accumulator->collect('storage', 100, Usage::TYPE_GAUGE); + $this->accumulator->collect('storage', 200, Usage::TYPE_GAUGE); + $this->accumulator->collect('storage', 300, Usage::TYPE_GAUGE); + + $this->assertEquals(1, $this->accumulator->count()); + + $this->assertTrue($this->accumulator->flush()); + + $this->assertEquals(Usage::TYPE_GAUGE, $this->adapter->batches[0]['type']); + $this->assertEquals(300, $this->adapter->batches[0]['metrics'][0]['value']); + } + + public function testFlushSeparatesEventsAndGauges(): void + { + $this->accumulator->collect('requests', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('storage', 100, Usage::TYPE_GAUGE); + + $this->assertTrue($this->accumulator->flush()); + + // One batch per type + $this->assertCount(2, $this->adapter->batches); + $types = [$this->adapter->batches[0]['type'], $this->adapter->batches[1]['type']]; + $this->assertContains(Usage::TYPE_EVENT, $types); + $this->assertContains(Usage::TYPE_GAUGE, $types); + + // Buffer cleared on success + $this->assertEquals(0, $this->accumulator->count()); + } + + public function testFailedFlushRetainsBuffer(): void + { + $this->accumulator->collect('requests', 10, Usage::TYPE_EVENT); + + $this->adapter->succeed = false; + $this->assertFalse($this->accumulator->flush()); + + // Nothing written, entry preserved for retry + $this->assertCount(0, $this->adapter->batches); + $this->assertEquals(1, $this->accumulator->count()); + + // A later successful flush drains the buffer + $this->adapter->succeed = true; + $this->assertTrue($this->accumulator->flush()); + $this->assertEquals(0, $this->accumulator->count()); + } + + public function testFlushEmptyBuffer(): void + { + $this->assertTrue($this->accumulator->flush()); + $this->assertCount(0, $this->adapter->batches); + $this->assertEquals(0, $this->accumulator->count()); + } + + public function testElapsedSignal(): void + { + $this->assertLessThan(1.0, $this->accumulator->elapsedSeconds()); + + sleep(1); + + $this->assertGreaterThanOrEqual(1.0, $this->accumulator->elapsedSeconds()); + } + + public function testEmptyMetricNameThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Metric name cannot be empty'); + $this->accumulator->collect('', 10, Usage::TYPE_EVENT); + } + + public function testNegativeValueThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value cannot be negative'); + $this->accumulator->collect('requests', -1, Usage::TYPE_EVENT); + } + + public function testInvalidTypeThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->accumulator->collect('requests', 10, 'invalid'); + } +} diff --git a/tests/Usage/UsageBase.php b/tests/Usage/UsageBase.php index 1dcbd3f..ecd69f8 100644 --- a/tests/Usage/UsageBase.php +++ b/tests/Usage/UsageBase.php @@ -291,188 +291,6 @@ public function testPurgeWithQueries(): void $this->assertEquals(10, $sum); } - public function testCollectAndFlush(): void - { - $this->usage->purge(); - - // collect() accumulates in memory, nothing written yet - $this->usage->collect('collect-metric', 10, Usage::TYPE_EVENT); - $this->usage->collect('collect-metric', 20, Usage::TYPE_EVENT); - $this->usage->collect('collect-metric', 30, Usage::TYPE_EVENT); - - // Buffer should have accumulated values - $this->assertEquals(3, $this->usage->getBufferCount()); - // 1 unique metric:type key = 1 buffer entry (events sum) - $this->assertEquals(1, $this->usage->getBufferSize()); - - // Nothing in storage yet - $sum = $this->usage->sum([ - Query::equal('metric', ['collect-metric']), - ], 'value', Usage::TYPE_EVENT); - $this->assertEquals(0, $sum); - - // Flush writes to storage - $this->assertTrue($this->usage->flush()); - - // Buffer should be empty after flush - $this->assertEquals(0, $this->usage->getBufferCount()); - $this->assertEquals(0, $this->usage->getBufferSize()); - - // Storage should have accumulated value (10 + 20 + 30 = 60) - $sum = $this->usage->sum([ - Query::equal('metric', ['collect-metric']), - ], 'value', Usage::TYPE_EVENT); - $this->assertEquals(60, $sum); - } - - public function testCollectMultipleMetrics(): void - { - $this->usage->purge(); - - $this->usage->collect('metric-a', 10, Usage::TYPE_EVENT); - $this->usage->collect('metric-b', 20, Usage::TYPE_EVENT); - $this->usage->collect('metric-a', 5, Usage::TYPE_EVENT); - - // 2 unique metric:type keys = 2 buffer entries - $this->assertEquals(2, $this->usage->getBufferSize()); - $this->assertEquals(3, $this->usage->getBufferCount()); - - $this->assertTrue($this->usage->flush()); - - $sumA = $this->usage->sum([ - Query::equal('metric', ['metric-a']), - ], 'value', Usage::TYPE_EVENT); - $sumB = $this->usage->sum([ - Query::equal('metric', ['metric-b']), - ], 'value', Usage::TYPE_EVENT); - - $this->assertEquals(15, $sumA); - $this->assertEquals(20, $sumB); - } - - public function testCollectGaugeAndFlush(): void - { - $this->usage->purge(); - - // collect with gauge type uses last-write-wins semantics - $this->usage->collect('gauge-collect', 100, Usage::TYPE_GAUGE); - $this->usage->collect('gauge-collect', 200, Usage::TYPE_GAUGE); - $this->usage->collect('gauge-collect', 300, Usage::TYPE_GAUGE); - - // 1 unique metric:type key = 1 buffer entry (gauge: last-write-wins) - $this->assertEquals(1, $this->usage->getBufferSize()); - $this->assertEquals(3, $this->usage->getBufferCount()); - - $this->assertTrue($this->usage->flush()); - - // Should have last value (300), not summed - $total = $this->usage->getTotal('gauge-collect', [], Usage::TYPE_GAUGE); - $this->assertEquals(300, $total); - } - - public function testMixedCollectEventAndGauge(): void - { - $this->usage->purge(); - - // Mix both types in the same buffer - $this->usage->collect('inc-mixed', 10, Usage::TYPE_EVENT); - $this->usage->collect('inc-mixed', 20, Usage::TYPE_EVENT); - $this->usage->collect('set-mixed', 100, Usage::TYPE_GAUGE); - $this->usage->collect('set-mixed', 200, Usage::TYPE_GAUGE); - - // inc: 1 metric:event key = 1, gauge: 1 metric:gauge key = 1 - $this->assertEquals(2, $this->usage->getBufferSize()); - $this->assertEquals(4, $this->usage->getBufferCount()); - - $this->assertTrue($this->usage->flush()); - - // Event: summed (10 + 20 = 30) - $this->assertEquals(30, $this->usage->getTotal('inc-mixed', [], Usage::TYPE_EVENT)); - - // Gauge: last value (200) - $this->assertEquals(200, $this->usage->getTotal('set-mixed', [], Usage::TYPE_GAUGE)); - } - - public function testShouldFlushByThreshold(): void - { - $this->usage->setFlushThreshold(3); - - $this->assertFalse($this->usage->shouldFlush()); - - $this->usage->collect('threshold-test', 1, Usage::TYPE_EVENT); - $this->usage->collect('threshold-test', 1, Usage::TYPE_EVENT); - - $this->assertFalse($this->usage->shouldFlush()); - - $this->usage->collect('threshold-test', 1, Usage::TYPE_EVENT); - - $this->assertTrue($this->usage->shouldFlush()); - - // Clean up - $this->usage->flush(); - $this->usage->setFlushThreshold(10_000); // reset - } - - public function testShouldFlushByInterval(): void - { - $this->usage->setFlushInterval(1); - - $this->usage->collect('interval-test', 1, Usage::TYPE_EVENT); - - // Right after collect, interval hasn't elapsed - $this->assertFalse($this->usage->shouldFlush()); - - // Wait for interval to elapse - sleep(2); - - $this->assertTrue($this->usage->shouldFlush()); - - // Clean up - $this->usage->flush(); - $this->usage->setFlushInterval(20); // reset - } - - public function testFlushEmptyBuffer(): void - { - // Flushing an empty buffer should succeed - $this->assertTrue($this->usage->flush()); - $this->assertEquals(0, $this->usage->getBufferCount()); - $this->assertEquals(0, $this->usage->getBufferSize()); - } - - public function testFlushThresholdConfiguration(): void - { - $this->usage->setFlushThreshold(500); - $this->assertEquals(500, $this->usage->getFlushThreshold()); - - $this->usage->setFlushInterval(30); - $this->assertEquals(30, $this->usage->getFlushInterval()); - - // Invalid values - $this->expectException(\InvalidArgumentException::class); - $this->usage->setFlushThreshold(0); - } - - public function testCollectValidation(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Metric name cannot be empty'); - $this->usage->collect('', 10, Usage::TYPE_EVENT); - } - - public function testCollectNegativeValueValidation(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Value cannot be negative'); - $this->usage->collect('test', -1, Usage::TYPE_EVENT); - } - - public function testCollectInvalidTypeValidation(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->usage->collect('test', 10, 'invalid'); - } - public function testWithQueries(): void { $results = $this->usage->find([ From 7fedf503e71cab296121f7956c23ee24ed0402b3 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:49:50 +0100 Subject: [PATCH 2/9] feat(usage): make tenant explicit, drop stateful setTenant Thread tenant through the API instead of carrying it as mutable adapter state, so a single Usage/adapter is safe across tenants and coroutines. - All read/query methods take `string $tenant` as the first parameter; addBatch carries `tenant` per metric row (a batch may span tenants). - Remove setTenant from Usage and both adapters. ClickHouse injects the tenant as a synthetic query filter (shared-tables only) via scopeToTenant(); Database scopes the underlying db per call. - Accumulator.collect() takes tenant first and keys the buffer by tenant. - Add Tenant decorator: binds a tenant once and forwards to Usage with it pre-filled (stamping addBatch rows) for single-tenant callers. Tests: inline adapter construction (drop makeAdapter helper), pass tenant explicitly, add a dedicated TenantTest, update README. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 105 ++++--- src/Usage/Accumulator.php | 14 +- src/Usage/Adapter.php | 35 ++- src/Usage/Adapter/ClickHouse.php | 123 ++++---- src/Usage/Adapter/Database.php | 76 +++-- src/Usage/Tenant.php | 144 +++++++++ src/Usage/Usage.php | 70 +++-- tests/Benchmark/BenchmarkBase.php | 3 +- tests/Benchmark/EventsBench.php | 20 +- tests/Benchmark/GaugesBench.php | 8 +- tests/Usage/AccumulatorTest.php | 63 ++-- .../Adapter/ClickHouseDimRoutingTest.php | 27 +- .../Adapter/ClickHouseGaugeDimRoutingTest.php | 27 +- tests/Usage/Adapter/ClickHouseRoutingTest.php | 53 ++-- tests/Usage/Adapter/ClickHouseSchemaTest.php | 22 +- tests/Usage/Adapter/ClickHouseTest.php | 280 +++++++++--------- tests/Usage/Adapter/ClickHouseTestCase.php | 37 +-- tests/Usage/Adapter/DatabaseTest.php | 22 +- tests/Usage/TenantTest.php | 205 +++++++++++++ tests/Usage/UsageBase.php | 138 ++++----- 20 files changed, 949 insertions(+), 523 deletions(-) create mode 100644 src/Usage/Tenant.php create mode 100644 tests/Usage/TenantTest.php diff --git a/README.md b/README.md index df9cedc..14a27f6 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ composer require utopia-php/usage use Utopia\Usage\Usage; use Utopia\Usage\Adapter\ClickHouse; -// Configuration is fixed at construction; only the tenant changes per request. +// Configuration is fixed at construction. The adapter is fully stateless — +// the tenant is passed explicitly on every call (see below). $adapter = new ClickHouse( host: 'clickhouse-server', username: 'default', @@ -46,7 +47,6 @@ $adapter = new ClickHouse( namespace: 'my_app', sharedTables: true, ); -$adapter->setTenant('project_123'); $usage = new Usage($adapter); $usage->setup(); // Creates events, gauges, and daily MV tables @@ -65,6 +65,35 @@ $usage = new Usage($adapter); $usage->setup(); ``` +## Multi-tenancy + +`Usage` is stateless: every query/mutation takes the tenant as its first +argument, and `addBatch` carries a `tenant` on each metric row (so one batch can +span tenants). This makes a single `Usage` instance safe to share across +tenants and coroutines. + +```php +$usage->getTotal('project_123', 'bandwidth'); // read for one tenant +$usage->addBatch([ + ['tenant' => 'project_123', 'metric' => 'bandwidth', 'value' => 5000, 'tags' => []], + ['tenant' => 'project_456', 'metric' => 'bandwidth', 'value' => 2000, 'tags' => []], +], Usage::TYPE_EVENT); +``` + +Callers that only ever touch one tenant can bind it once with the `Tenant` +decorator, which forwards to `Usage` with the tenant pre-filled (and stamps it +onto every `addBatch` row): + +```php +use Utopia\Usage\Tenant; + +$tenant = new Tenant($usage, 'project_123'); +$tenant->getTotal('bandwidth'); // no tenant argument +$tenant->addBatch([ + ['metric' => 'bandwidth', 'value' => 5000, 'tags' => []], // tenant stamped automatically +], Usage::TYPE_EVENT); +``` + ## Metric Types ### Events (Additive) @@ -84,8 +113,8 @@ use Utopia\Usage\Accumulator; // Buffer metrics in memory and flush them in batch $accumulator = new Accumulator($adapter); -// Collect events — values accumulate in-memory buffer (summed per metric) -$accumulator->collect('bandwidth', 5000, Usage::TYPE_EVENT, [ +// Collect events — tenant first; values accumulate in-memory (summed per tenant+metric) +$accumulator->collect('project_123', 'bandwidth', 5000, Usage::TYPE_EVENT, [ 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', @@ -119,9 +148,9 @@ Gauge-specific columns (see `Metric::GAUGE_COLUMNS`): `teamId`, `teamInternalId`, `resourceId`, `resourceInternalId`. ```php -// Collect gauges — last value wins per metric in buffer -$accumulator->collect('users', 1500, Usage::TYPE_GAUGE); -$accumulator->collect('storage.size', 1048576, Usage::TYPE_GAUGE, [ +// Collect gauges — last value wins per tenant+metric in buffer +$accumulator->collect('project_123', 'users', 1500, Usage::TYPE_GAUGE); +$accumulator->collect('project_123', 'storage.size', 1048576, Usage::TYPE_GAUGE, [ 'teamId' => 'team_x', 'teamInternalId' => '7', 'resourceId' => 'abc123', @@ -145,14 +174,14 @@ if ($accumulator->count() >= 5000 || $accumulator->elapsedSeconds() >= 10) { ### Batch Writes ```php -// Write directly without buffering +// Write directly without buffering — each row carries its tenant $usage->addBatch([ - ['metric' => 'requests', 'value' => 100, 'tags' => ['path' => '/v1/users', 'method' => 'GET']], - ['metric' => 'bandwidth', 'value' => 50000, 'tags' => ['country' => 'DE', 'region' => 'fra']], + ['tenant' => 'project_123', 'metric' => 'requests', 'value' => 100, 'tags' => ['path' => '/v1/users', 'method' => 'GET']], + ['tenant' => 'project_123', 'metric' => 'bandwidth', 'value' => 50000, 'tags' => ['country' => 'DE', 'region' => 'fra']], ], Usage::TYPE_EVENT); $usage->addBatch([ - ['metric' => 'users', 'value' => 42, 'tags' => ['teamId' => 'team_x']], + ['tenant' => 'project_123', 'metric' => 'users', 'value' => 42, 'tags' => ['teamId' => 'team_x']], ], Usage::TYPE_GAUGE); ``` @@ -163,8 +192,8 @@ $usage->addBatch([ ```php use Utopia\Query\Query; -// Find events filtered by metric and time range -$metrics = $usage->find([ +// Find events filtered by metric and time range (tenant first) +$metrics = $usage->find('project_123', [ Query::equal('metric', ['bandwidth']), Query::equal('country', ['US']), Query::greaterThanEqual('time', '2026-01-01'), @@ -173,12 +202,12 @@ $metrics = $usage->find([ ], Usage::TYPE_EVENT); // Find gauges -$gauges = $usage->find([ +$gauges = $usage->find('project_123', [ Query::equal('metric', ['users', 'storage.size']), ], Usage::TYPE_GAUGE); // Query both tables (type = null) -$all = $usage->find([ +$all = $usage->find('project_123', [ Query::equal('metric', ['bandwidth']), ]); ``` @@ -187,12 +216,13 @@ $all = $usage->find([ ```php // Get total for a single metric (SUM for events, latest for gauges) -$total = $usage->getTotal('bandwidth', [ +$total = $usage->getTotal('project_123', 'bandwidth', [ Query::greaterThanEqual('time', '2026-03-01'), ], Usage::TYPE_EVENT); // Batch totals — single query with GROUP BY $totals = $usage->getTotalBatch( + 'project_123', ['bandwidth', 'executions', 'requests'], [Query::greaterThanEqual('time', '2026-03-01')], Usage::TYPE_EVENT @@ -205,6 +235,7 @@ $totals = $usage->getTotalBatch( // Get time series with query-time aggregation // Events: SUM per bucket, Gauges: argMax per bucket $series = $usage->getTimeSeries( + tenant: 'project_123', metrics: ['bandwidth', 'requests'], interval: '1d', // '1h' or '1d' startDate: '2026-03-01', @@ -222,20 +253,21 @@ The daily materialized view pre-aggregates events by `metric + tenant + day` for ```php // Sum a single metric from the daily table -$total = $usage->sumDaily([ +$total = $usage->sumDaily('project_123', [ Query::equal('metric', ['bandwidth']), Query::between('time', '2026-03-01', '2026-04-01'), ]); // Sum multiple metrics in one query $totals = $usage->sumDailyBatch( + 'project_123', ['bandwidth', 'executions', 'storage.size'], [Query::between('time', '2026-03-01', '2026-04-01')] ); // Returns: ['bandwidth' => 5000000, 'executions' => 12345, 'storage.size' => 0] // Find daily aggregated rows -$rows = $usage->findDaily([ +$rows = $usage->findDaily('project_123', [ Query::equal('metric', ['bandwidth']), Query::orderDesc('time'), Query::limit(30), @@ -246,12 +278,12 @@ $rows = $usage->findDaily([ ```php // Purge all event metrics older than 90 days -$usage->purge([ +$usage->purge('project_123', [ Query::lessThan('time', '2026-01-01'), ], Usage::TYPE_EVENT); // Purge all gauge metrics -$usage->purge([], Usage::TYPE_GAUGE); +$usage->purge('project_123', [], Usage::TYPE_GAUGE); ``` ## Architecture @@ -326,22 +358,23 @@ $usage->purge([], Usage::TYPE_GAUGE); Extend `Utopia\Usage\Adapter` and implement: - `getName()`, `setup()`, `healthCheck()` -- `addBatch(array $metrics, string $type, int $batchSize): bool` -- `find(array $queries, ?string $type): array` -- `count(array $queries, ?string $type): int` -- `sum(array $queries, string $attribute, ?string $type): int` -- `getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries, bool $zeroFill, ?string $type): array` -- `getTotal(string $metric, array $queries, ?string $type): int` -- `getTotalBatch(array $metrics, array $queries, ?string $type): array` -- `findDaily(array $queries): array` -- `sumDaily(array $queries, string $attribute): int` -- `sumDailyBatch(array $metrics, array $queries): array` -- `purge(array $queries, ?string $type): bool` -- `setTenant(?string $tenant): self` - -Namespace, database, shared-tables mode, async inserts, query logging and the -dual-read sample rate are set once via the adapter constructor (see "Using -ClickHouse Adapter" above). Only the tenant changes over an instance's lifetime. +- `addBatch(array $metrics, string $type, int $batchSize): bool` (each metric carries its own `tenant`) +- `find(string $tenant, array $queries, ?string $type): array` +- `count(string $tenant, array $queries, ?string $type, ?int $max): int` +- `sum(string $tenant, array $queries, string $attribute, string $type): int` +- `getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries, bool $zeroFill, ?string $type): array` +- `getTotal(string $tenant, string $metric, array $queries, ?string $type): int` +- `getTotalBatch(string $tenant, array $metrics, array $queries, ?string $type): array` +- `findDaily(string $tenant, array $queries): array` +- `sumDaily(string $tenant, array $queries, string $attribute): int` +- `sumDailyBatch(string $tenant, array $metrics, array $queries): array` +- `purge(string $tenant, array $queries, ?string $type): bool` + +All configuration — namespace, database, shared-tables mode, async inserts, +query logging — is set once via the adapter constructor (see "Using ClickHouse +Adapter" above). Adapters hold no per-request state; the tenant is passed +explicitly on every call, so one instance is safe to share across tenants and +coroutines. ## System Requirements diff --git a/src/Usage/Accumulator.php b/src/Usage/Accumulator.php index c10540b..d544e50 100644 --- a/src/Usage/Accumulator.php +++ b/src/Usage/Accumulator.php @@ -17,9 +17,11 @@ class Accumulator /** * In-memory buffer for metrics. - * Keyed by "{metric}:{type}:{tagsHash}" — events are summed, gauges use last-write-wins. + * 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. * - * @var array}> + * @var array}> */ private array $buffer = []; @@ -42,13 +44,14 @@ public function __construct(Adapter $adapter) * For gauge type: last-write-wins semantics. * No period fan-out — raw timestamps are used. * + * @param string $tenant Tenant scope (shared-tables mode) * @param string $metric Metric name * @param int $value Value * @param string $type Metric type: 'event' or 'gauge' * @param array $tags Optional tags * @return self */ - public function collect(string $metric, int $value, string $type, array $tags = []): self + public function collect(string $tenant, string $metric, int $value, string $type, array $tags = []): self { if (empty($metric)) { throw new \InvalidArgumentException('Metric name cannot be empty'); @@ -61,14 +64,15 @@ public function collect(string $metric, int $value, string $type, array $tags = } $tagsHash = !empty($tags) ? md5(json_encode($tags, JSON_THROW_ON_ERROR)) : ''; - $key = $metric . ':' . $type . ':' . $tagsHash; + $key = $tenant . ':' . $metric . ':' . $type . ':' . $tagsHash; if ($type === Usage::TYPE_EVENT && isset($this->buffer[$key])) { - // Additive: sum values for the same metric + tags combination + // Additive: sum values for the same tenant + metric + tags combination $this->buffer[$key]['value'] += $value; } else { // New event entry, or gauge (last-write-wins) $this->buffer[$key] = [ + 'tenant' => $tenant, 'metric' => $metric, 'value' => $value, 'type' => $type, diff --git a/src/Usage/Adapter.php b/src/Usage/Adapter.php index 20c53ed..ddbf659 100644 --- a/src/Usage/Adapter.php +++ b/src/Usage/Adapter.php @@ -28,7 +28,10 @@ abstract public function setup(): void; * For events, path/method/status/resource/resourceId are extracted from tags * into dedicated columns; remaining tags stay in the tags JSON. * - * @param array}> $metrics + * Each metric carries its own `tenant` (shared-tables mode), so a single + * batch may span multiple tenants. + * + * @param array}> $metrics * @param string $type Metric type: 'event' or 'gauge' — determines which table to write to * @param int $batchSize Maximum number of metrics per INSERT statement */ @@ -40,6 +43,7 @@ abstract public function addBatch(array $metrics, string $type, int $batchSize = * Groups data by the specified interval (1h or 1d) and applies * SUM for event metrics and argMax for gauge metrics. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array $metrics List of metric names * @param string $interval Aggregation interval: '1h' or '1d' * @param string $startDate Start datetime string @@ -49,7 +53,7 @@ abstract public function addBatch(array $metrics, string $type, int $batchSize = * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return array}> */ - abstract public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array; + abstract public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array; /** * Get total value for a single metric. @@ -57,42 +61,46 @@ abstract public function getTimeSeries(array $metrics, string $interval, string * Returns sum for event metrics, latest value for gauge metrics. * When $type is null, queries both tables. * + * @param string $tenant Tenant scope (shared-tables mode) * @param string $metric Metric name * @param array<\Utopia\Query\Query> $queries Additional query filters * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return int */ - abstract public function getTotal(string $metric, array $queries = [], ?string $type = null): int; + abstract public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int; /** * Get totals for multiple metrics in a single query. * * Returns sum for event metrics, latest value for gauge metrics. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array $metrics List of metric names * @param array<\Utopia\Query\Query> $queries Additional query filters * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return array */ - abstract public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array; + abstract public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array; /** * Purge usage metrics matching the given queries. * When no queries are provided, all metrics are deleted. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string|null $type Metric type: 'event', 'gauge', or null (purge both) */ - abstract public function purge(array $queries = [], ?string $type = null): bool; + abstract public function purge(string $tenant, array $queries = [], ?string $type = null): bool; /** * Find metrics using Query objects. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return array */ - abstract public function find(array $queries = [], ?string $type = null): array; + abstract public function find(string $tenant, array $queries = [], ?string $type = null): array; /** * Count metrics using Query objects. @@ -102,12 +110,13 @@ abstract public function find(array $queries = [], ?string $type = null): array; * This keeps large counts cheap for endpoints that only need a capped * total. When $max is null the count is unbounded. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string|null $type Metric type: 'event', 'gauge', or null (count both) * @param int|null $max Optional upper bound for the count (inclusive) * @return int */ - abstract public function count(array $queries = [], ?string $type = null, ?int $max = null): int; + abstract public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int; /** * Sum metric values using Query objects. @@ -115,12 +124,13 @@ abstract public function count(array $queries = [], ?string $type = null, ?int $ * Events-only by default because summing gauges is semantically meaningless * (adding point-in-time snapshots doesn't produce a useful total). * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string $attribute Attribute to sum (default: 'value') * @param string $type Metric type: 'event' or 'gauge' * @return int */ - abstract public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int; + abstract public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int; /** * Find event metrics from the pre-aggregated daily table. @@ -130,10 +140,11 @@ abstract public function sum(array $queries = [], string $attribute = 'value', s * Note: Daily MV only stores event metrics. This method always queries * the daily events table — gauges are never pre-aggregated. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries Filters (metric, time range, resource, etc.) * @return array */ - abstract public function findDaily(array $queries = []): array; + abstract public function findDaily(string $tenant, array $queries = []): array; /** * Sum event metric values from the pre-aggregated daily table. @@ -141,11 +152,12 @@ abstract public function findDaily(array $queries = []): array; * Note: Daily MV only stores event metrics. This method always queries * the daily events table — gauges are never pre-aggregated. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string $attribute Attribute to sum (default: 'value') * @return int */ - abstract public function sumDaily(array $queries = [], string $attribute = 'value'): int; + abstract public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int; /** * Sum multiple event metrics from the pre-aggregated daily table in one query. @@ -153,9 +165,10 @@ abstract public function sumDaily(array $queries = [], string $attribute = 'valu * Note: Daily MV only stores event metrics. This method always queries * the daily events table — gauges are never pre-aggregated. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array $metrics List of metric names * @param array<\Utopia\Query\Query> $queries Additional filters (e.g. date range) * @return array Metric name => sum value */ - abstract public function sumDailyBatch(array $metrics, array $queries = []): array; + abstract public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array; } diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 6b87e7b..d866b13 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -98,8 +98,6 @@ class ClickHouse extends SQL private readonly RequestFactory $requestFactory; - protected ?string $tenant = null; - protected readonly bool $sharedTables; protected readonly string $namespace; @@ -372,20 +370,24 @@ private function escapeIdentifier(string $identifier): string } /** - * Set the tenant ID for multi-tenant support. + * Prepend a tenant filter to the query list (shared-tables mode only). * - * Tenant is the one piece of adapter configuration that legitimately - * changes over an instance's lifetime — a single adapter serves many - * tenants across requests — so it stays a mutable setter while the rest - * of the configuration is fixed at construction. + * In non-shared mode the tables have no tenant column, so the tenant is + * ignored. Threading the scope in as a normal query lets the existing + * parse/where pipeline bind and filter it like any other column. * - * @param string|null $tenant - * @return self + * @param array $queries + * @return array */ - public function setTenant(?string $tenant): self + private function scopeToTenant(string $tenant, array $queries): array { - $this->tenant = $tenant; - return $this; + if (!$this->sharedTables) { + return $queries; + } + + array_unshift($queries, Query::equal('tenant', [$tenant])); + + return $queries; } /** @@ -1258,11 +1260,11 @@ private function validateMetricsBatch(array $metrics, string $type): void $tags = $metricData['tags'] ?? []; $this->validateMetricData($metric, $value, $type, $tags, $index); - if (array_key_exists('$tenant', $metricData)) { - $tenantValue = $metricData['$tenant']; + if (array_key_exists('tenant', $metricData)) { + $tenantValue = $metricData['tenant']; if ($tenantValue !== null && !is_string($tenantValue)) { - throw new Exception("Metric #{$index}: '\$tenant' must be a string or null, got " . gettype($tenantValue)); + throw new Exception("Metric #{$index}: 'tenant' must be a string or null, got " . gettype($tenantValue)); } } } @@ -1341,11 +1343,7 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN */ private function resolveTenantFromMetric(array $metricData): ?string { - $tenant = array_key_exists('$tenant', $metricData) ? $metricData['$tenant'] : $this->tenant; - - if ($tenant === null) { - return null; - } + $tenant = $metricData['tenant'] ?? null; if (is_string($tenant)) { return $tenant; @@ -1367,10 +1365,12 @@ private function resolveTenantFromMetric(array $metricData): ?string * @return array * @throws Exception */ - public function find(array $queries = [], ?string $type = null): array + public function find(string $tenant, array $queries = [], ?string $type = null): array { $this->setOperationContext('find()'); + $queries = $this->scopeToTenant($tenant, $queries); + if ($type !== null) { return $this->findFromTable($queries, $type); } @@ -1695,10 +1695,12 @@ private function parseAggregatedResults(string $result, string $type = 'event'): * @return int * @throws Exception */ - public function count(array $queries = [], ?string $type = null, ?int $max = null): int + public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int { $this->setOperationContext('count()'); + $queries = $this->scopeToTenant($tenant, $queries); + if ($type !== null) { return $this->countFromTable($queries, $type, $max); } @@ -1782,10 +1784,12 @@ private function countFromTable(array $queries, string $type, ?int $max = null): * @return int * @throws Exception */ - public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int { $this->setOperationContext('sum()'); + $queries = $this->scopeToTenant($tenant, $queries); + if ($type === Usage::TYPE_EVENT && $attribute === 'value') { return $this->routedSum($queries, 'sum'); } @@ -2497,10 +2501,12 @@ private function sumFromTable(array $queries, string $attribute, string $type): * @return array * @throws Exception */ - public function findDaily(array $queries = []): array + public function findDaily(string $tenant, array $queries = []): array { $this->setOperationContext('findDaily()'); + $queries = $this->scopeToTenant($tenant, $queries); + $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); foreach ($queries as $query) { @@ -2545,10 +2551,12 @@ public function findDaily(array $queries = []): array * @return int * @throws Exception */ - public function sumDaily(array $queries = [], string $attribute = 'value'): int + public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int { $this->setOperationContext('sumDaily()'); + $queries = $this->scopeToTenant($tenant, $queries); + $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); $this->validateDailyAttributeName($attribute); $escapedAttribute = $this->escapeIdentifier($attribute); @@ -2578,7 +2586,7 @@ public function sumDaily(array $queries = [], string $attribute = 'value'): int * @return array * @throws Exception */ - public function sumDailyBatch(array $metrics, array $queries = []): array + public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array { if (empty($metrics)) { return []; @@ -2586,6 +2594,8 @@ public function sumDailyBatch(array $metrics, array $queries = []): array $this->setOperationContext('sumDailyBatch()'); + $queries = $this->scopeToTenant($tenant, $queries); + foreach ($queries as $query) { $attr = $query->getAttribute(); if (!empty($attr)) { @@ -2655,7 +2665,7 @@ public function sumDailyBatch(array $metrics, array $queries = []): array * @return array}> * @throws Exception */ - public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array { if (empty($metrics)) { return []; @@ -2667,6 +2677,8 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat $this->setOperationContext('getTimeSeries()'); + $queries = $this->scopeToTenant($tenant, $queries); + // Initialize result structure $output = []; foreach ($metrics as $metric) { @@ -2758,13 +2770,7 @@ private function getTimeSeriesFromTable(array $metrics, string $interval, string $params['start_date'] = $this->formatDateTime($startDate); $params['end_date'] = $this->formatDateTime($endDate); - // Build tenant filter - $tenantFilter = ''; - if ($this->sharedTables && $this->tenant !== null) { - $tenantFilter = ' AND tenant = {tenant:Nullable(String)}'; - $params['tenant'] = $this->tenant; - } - + // Tenant scoping arrives as a normal query filter (see scopeToTenant). $additionalWhere = ''; if (!empty($additionalFilters)) { $additionalWhere = ' AND ' . implode(' AND ', $additionalFilters); @@ -2782,7 +2788,7 @@ private function getTimeSeriesFromTable(array $metrics, string $interval, string FROM {$fromTable} WHERE metric IN ({$metricInClause}) AND time BETWEEN {start_date:DateTime64(3, 'UTC')} AND {end_date:DateTime64(3, 'UTC')} - {$tenantFilter}{$additionalWhere} + {$additionalWhere} GROUP BY metric, bucket ORDER BY bucket ASC FORMAT JSON @@ -2876,10 +2882,12 @@ private function zeroFillTimeSeries(array $data, string $interval, string $start * @return int * @throws Exception */ - public function getTotal(string $metric, array $queries = [], ?string $type = null): int + public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int { $this->setOperationContext('getTotal()'); + $queries = $this->scopeToTenant($tenant, $queries); + if ($type === Usage::TYPE_EVENT) { return $this->getTotalFromEvents($metric, $queries); } @@ -2970,7 +2978,7 @@ private function getTotalFromGauges(string $metric, array $queries): int * @return array * @throws Exception */ - public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array { if (empty($metrics)) { return []; @@ -2978,6 +2986,8 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type $this->setOperationContext('getTotalBatch()'); + $queries = $this->scopeToTenant($tenant, $queries); + // Initialize all metrics to 0 $totals = \array_fill_keys($metrics, 0); @@ -3066,29 +3076,20 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type /** * Build WHERE clause from filters with optional tenant filtering. * + * Tenant scoping is threaded in as a normal query filter (see + * scopeToTenant()), so it arrives here already folded into $filters. + * * @param array $filters * @param array $params - * @param bool $includeTenant * @return array{clause: string, params: array} */ - private function buildWhereClause(array $filters, array $params = [], bool $includeTenant = true): array + private function buildWhereClause(array $filters, array $params = []): array { - $conditions = $filters; - $whereParams = $params; - - if ($includeTenant) { - $tenantFilter = $this->getTenantFilter(); - if ($tenantFilter) { - $conditions[] = $tenantFilter; - $whereParams['tenant'] = $this->tenant; - } - } - - $clause = !empty($conditions) ? ' WHERE ' . implode(' AND ', $conditions) : ''; + $clause = !empty($filters) ? ' WHERE ' . implode(' AND ', $filters) : ''; return [ 'clause' => $clause, - 'params' => $whereParams + 'params' => $params ]; } @@ -3679,20 +3680,6 @@ private function getSelectColumns(string $type = 'event'): string return implode(', ', $columns); } - /** - * Build tenant filter clause. - * - * @return string - */ - private function getTenantFilter(): string - { - if (!$this->sharedTables || $this->tenant === null) { - return ''; - } - - return "tenant = {tenant:Nullable(String)}"; - } - /** * Purge usage metrics matching the given queries. * Deletes from the specified table(s). @@ -3708,10 +3695,12 @@ private function getTenantFilter(): string * @param string|null $type 'event', 'gauge', or null (purge both) * @throws Exception */ - public function purge(array $queries = [], ?string $type = null): bool + public function purge(string $tenant, array $queries = [], ?string $type = null): bool { $this->setOperationContext('purge()'); + $queries = $this->scopeToTenant($tenant, $queries); + $typesToPurge = []; if ($type === Usage::TYPE_EVENT || $type === null) { $typesToPurge[] = Usage::TYPE_EVENT; diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index b008be6..f76859b 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -122,7 +122,10 @@ protected function getColumnDefinition(string $id, string $type = 'event'): stri * Database adapter uses a single collection for both types. The $type parameter * is stored as a field in each document for query-time differentiation. * - * @param array}> $metrics + * Each metric carries its own `tenant` (shared-tables mode), so a single + * batch may span multiple tenants. + * + * @param array}> $metrics * @param string $type Metric type: 'event' or 'gauge' * @param int $batchSize * @return bool @@ -131,7 +134,7 @@ protected function getColumnDefinition(string $id, string $type = 'event'): stri public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool { $this->db->getAuthorization()->skip(function () use ($metrics, $type, $batchSize) { - $documents = []; + $entries = []; foreach ($metrics as $metric) { if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) { throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: event, gauge"); @@ -155,12 +158,14 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b 'time' => (new \DateTime())->format('Y-m-d H:i:s.v'), ], $columns); - $documents[] = new Document($docData); + $tenant = $metric['tenant'] ?? null; + $entries[] = ['tenant' => is_string($tenant) ? $tenant : null, 'doc' => new Document($docData)]; } - foreach (array_chunk($documents, max(1, $batchSize)) as $chunk) { - foreach ($chunk as $doc) { - $this->db->createDocument($this->collection, $doc); + foreach (array_chunk($entries, max(1, $batchSize)) as $chunk) { + foreach ($chunk as $entry) { + $this->setDbTenant($entry['tenant']); + $this->db->createDocument($this->collection, $entry['doc']); } } }); @@ -173,6 +178,7 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b * * Stub implementation for Database adapter. * + * @param string $tenant * @param array $metrics * @param string $interval * @param string $startDate @@ -182,7 +188,7 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b * @param string|null $type * @return array}> */ - public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array { // Stub: Database adapter time series not yet implemented $output = []; @@ -197,12 +203,13 @@ public function getTimeSeries(array $metrics, string $interval, string $startDat * * Returns SUM for event metrics, latest value for gauge metrics. * + * @param string $tenant * @param string $metric * @param array $queries * @param string|null $type * @return int */ - public function getTotal(string $metric, array $queries = [], ?string $type = null): int + public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int { $allQueries = array_merge($queries, [ Query::equal('metric', [$metric]), @@ -217,7 +224,7 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu Query::limit(1), ]); /** @var array $gaugeResults */ - $gaugeResults = $this->find($gaugeQueries, $type); + $gaugeResults = $this->find($tenant, $gaugeQueries, $type); if (empty($gaugeResults)) { return 0; } @@ -225,7 +232,7 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu } /** @var array $results */ - $results = $this->find($allQueries, $type); + $results = $this->find($tenant, $allQueries, $type); if (empty($results)) { return 0; @@ -280,12 +287,13 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu /** * Get totals for multiple metrics. * + * @param string $tenant * @param array $metrics * @param array $queries * @param string|null $type * @return array */ - public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array { if (empty($metrics)) { return []; @@ -294,7 +302,7 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type $totals = \array_fill_keys($metrics, 0); foreach ($metrics as $metric) { - $totals[$metric] = $this->getTotal($metric, $queries, $type); + $totals[$metric] = $this->getTotal($tenant, $metric, $queries, $type); } return $totals; @@ -305,15 +313,16 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type * * Events-only by default — summing gauges is semantically meaningless. * + * @param string $tenant * @param array $queries * @param string $attribute * @param string $type 'event' or 'gauge' * @return int */ - public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int { /** @var array $results */ - $results = $this->find($queries, $type); + $results = $this->find($tenant, $queries, $type); $sum = 0; foreach ($results as $result) { @@ -326,26 +335,28 @@ public function sum(array $queries = [], string $attribute = 'value', string $ty /** * Find from daily table — Database adapter falls back to regular find for events. * + * @param string $tenant * @param array $queries * @return array */ - public function findDaily(array $queries = []): array + public function findDaily(string $tenant, array $queries = []): array { - return $this->find($queries, Usage::TYPE_EVENT); + return $this->find($tenant, $queries, Usage::TYPE_EVENT); } /** * Sum multiple metrics from daily table — falls back to individual sumDaily calls. * + * @param string $tenant * @param array<\Utopia\Query\Query> $queries * @return array */ - public function sumDailyBatch(array $metrics, array $queries = []): array + public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array { $totals = \array_fill_keys($metrics, 0); foreach ($metrics as $metric) { $metricQueries = array_merge($queries, [Query::equal('metric', [$metric])]); - $totals[$metric] = $this->sumDaily($metricQueries); + $totals[$metric] = $this->sumDaily($tenant, $metricQueries); } return $totals; } @@ -353,13 +364,14 @@ public function sumDailyBatch(array $metrics, array $queries = []): array /** * Sum from daily table — Database adapter falls back to regular sum for events. * + * @param string $tenant * @param array $queries * @param string $attribute * @return int */ - public function sumDaily(array $queries = [], string $attribute = 'value'): int + public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int { - return $this->sum($queries, $attribute, Usage::TYPE_EVENT); + return $this->sum($tenant, $queries, $attribute, Usage::TYPE_EVENT); } /** @@ -520,11 +532,13 @@ private function validateGroupByQueries(array $queries): void } /** + * @param string $tenant * @param array $queries * @param string|null $type */ - public function purge(array $queries = [], ?string $type = null): bool + public function purge(string $tenant, array $queries = [], ?string $type = null): bool { + $this->setDbTenant($tenant); $queries = $this->withTypeFilter($queries, $type); $this->db->getAuthorization()->skip(function () use ($queries) { @@ -553,12 +567,14 @@ public function purge(array $queries = [], ?string $type = null): bool * so callers can isolate event vs gauge rows. When $type is null both * are returned (caller distinguishes via Metric::getType()). * + * @param string $tenant * @param array $queries * @param string|null $type * @return array */ - public function find(array $queries = [], ?string $type = null): array + public function find(string $tenant, array $queries = [], ?string $type = null): array { + $this->setDbTenant($tenant); $queries = $this->withTypeFilter($queries, $type); /** @var array $result */ @@ -580,13 +596,15 @@ public function find(array $queries = [], ?string $type = null): array * utopia-php/database accepts a `$max` argument that pushes the cap * down into the underlying SQL. * + * @param string $tenant * @param array $queries * @param string|null $type * @param int|null $max Optional upper bound (inclusive) for the count * @return int */ - public function count(array $queries = [], ?string $type = null, ?int $max = null): int + public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int { + $this->setDbTenant($tenant); $queries = $this->withTypeFilter($queries, $type); /** @var int $count */ @@ -623,17 +641,12 @@ private function withTypeFilter(array $queries, ?string $type): array } /** - * Set the tenant ID for multi-tenant support. - * - * Tenant is the one piece of configuration that changes over an - * instance's lifetime (a single adapter serves many tenants across - * requests), so it stays a mutable setter. Namespace and shared-tables - * mode are configured directly on the injected Utopia\Database instance. + * Scope the underlying Utopia\Database instance to a tenant for the next + * operation. The Database adapter requires a numeric tenant ID. * * @param string|null $tenant - * @return self */ - public function setTenant(?string $tenant): self + private function setDbTenant(?string $tenant): void { if ($tenant !== null && !is_numeric($tenant)) { throw new \InvalidArgumentException( @@ -642,6 +655,5 @@ public function setTenant(?string $tenant): self } $this->db->setTenant($tenant !== null ? (int) $tenant : null); - return $this; } } diff --git a/src/Usage/Tenant.php b/src/Usage/Tenant.php new file mode 100644 index 0000000..fb45eb5 --- /dev/null +++ b/src/Usage/Tenant.php @@ -0,0 +1,144 @@ +usage = $usage; + $this->tenant = $tenant; + } + + /** + * Add metrics in batch, stamping this view's tenant onto each entry. + * + * @param array}> $metrics + * @param string $type Metric type: 'event' or 'gauge' + * @param int $batchSize Maximum number of metrics per INSERT statement + * @return bool + * @throws \Exception + */ + public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool + { + foreach ($metrics as &$metric) { + $metric['tenant'] = $this->tenant; + } + unset($metric); + + return $this->usage->addBatch($metrics, $type, $batchSize); + } + + /** + * @param array $metrics + * @param array<\Utopia\Query\Query> $queries + * @return array}> + * @throws \Exception + */ + public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + { + return $this->usage->getTimeSeries($this->tenant, $metrics, $interval, $startDate, $endDate, $queries, $zeroFill, $type); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @throws \Exception + */ + public function getTotal(string $metric, array $queries = [], ?string $type = null): int + { + return $this->usage->getTotal($this->tenant, $metric, $queries, $type); + } + + /** + * @param array $metrics + * @param array<\Utopia\Query\Query> $queries + * @return array + * @throws \Exception + */ + public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + { + return $this->usage->getTotalBatch($this->tenant, $metrics, $queries, $type); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @throws \Exception + */ + public function purge(array $queries = [], ?string $type = null): bool + { + return $this->usage->purge($this->tenant, $queries, $type); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @return array + * @throws \Exception + */ + public function find(array $queries = [], ?string $type = null): array + { + return $this->usage->find($this->tenant, $queries, $type); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @throws \Exception + */ + public function count(array $queries = [], ?string $type = null, ?int $max = null): int + { + return $this->usage->count($this->tenant, $queries, $type, $max); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @throws \Exception + */ + public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + { + return $this->usage->sum($this->tenant, $queries, $attribute, $type); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @return array + * @throws \Exception + */ + public function findDaily(array $queries = []): array + { + return $this->usage->findDaily($this->tenant, $queries); + } + + /** + * @param array<\Utopia\Query\Query> $queries + * @throws \Exception + */ + public function sumDaily(array $queries = [], string $attribute = 'value'): int + { + return $this->usage->sumDaily($this->tenant, $queries, $attribute); + } + + /** + * @param array $metrics + * @param array<\Utopia\Query\Query> $queries + * @return array + * @throws \Exception + */ + public function sumDailyBatch(array $metrics, array $queries = []): array + { + return $this->usage->sumDailyBatch($this->tenant, $metrics, $queries); + } +} diff --git a/src/Usage/Usage.php b/src/Usage/Usage.php index 446ff2a..7897158 100644 --- a/src/Usage/Usage.php +++ b/src/Usage/Usage.php @@ -63,7 +63,10 @@ public function setup(): void * Callers must explicitly pass the metric type so event and gauge * writes are never confused at the call site. * - * @param array}> $metrics + * Each metric carries its own `tenant` (shared-tables mode), so a single + * batch may span multiple tenants. + * + * @param array}> $metrics * @param string $type Metric type: 'event' or 'gauge' * @param int $batchSize Maximum number of metrics per INSERT statement * @return bool @@ -77,6 +80,7 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b /** * Get time series data for metrics. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array $metrics List of metric names * @param string $interval '1h' or '1d' * @param string $startDate Start datetime @@ -87,63 +91,67 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b * @return array}> * @throws \Exception */ - public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array { - return $this->adapter->getTimeSeries($metrics, $interval, $startDate, $endDate, $queries, $zeroFill, $type); + return $this->adapter->getTimeSeries($tenant, $metrics, $interval, $startDate, $endDate, $queries, $zeroFill, $type); } /** * Get total value for a single metric. * + * @param string $tenant Tenant scope (shared-tables mode) * @param string $metric Metric name * @param array<\Utopia\Query\Query> $queries Additional filters * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return int * @throws \Exception */ - public function getTotal(string $metric, array $queries = [], ?string $type = null): int + public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int { - return $this->adapter->getTotal($metric, $queries, $type); + return $this->adapter->getTotal($tenant, $metric, $queries, $type); } /** * Get totals for multiple metrics in a single query. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array $metrics List of metric names * @param array<\Utopia\Query\Query> $queries Additional filters * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return array * @throws \Exception */ - public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array { - return $this->adapter->getTotalBatch($metrics, $queries, $type); + return $this->adapter->getTotalBatch($tenant, $metrics, $queries, $type); } /** * Purge usage metrics matching the given queries. * When no queries are provided, all metrics are deleted. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string|null $type Metric type: 'event', 'gauge', or null (purge both) * @throws \Exception */ - public function purge(array $queries = [], ?string $type = null): bool + public function purge(string $tenant, array $queries = [], ?string $type = null): bool { - return $this->adapter->purge($queries, $type); + return $this->adapter->purge($tenant, $queries, $type); } /** * Find metrics using Query objects. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string|null $type Metric type: 'event', 'gauge', or null (query both) * @return array * @throws \Exception */ - public function find(array $queries = [], ?string $type = null): array + public function find(string $tenant, array $queries = [], ?string $type = null): array { - return $this->adapter->find($queries, $type); + return $this->adapter->find($tenant, $queries, $type); } /** @@ -153,15 +161,16 @@ public function find(array $queries = [], ?string $type = null): array * Callers that only need a capped total (e.g. to render "5000+") should * pass $max so the adapter can short-circuit the count for large tables. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string|null $type Metric type: 'event', 'gauge', or null (count both) * @param int|null $max Optional upper bound for the count (inclusive) * @return int * @throws \Exception */ - public function count(array $queries = [], ?string $type = null, ?int $max = null): int + public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int { - return $this->adapter->count($queries, $type, $max); + return $this->adapter->count($tenant, $queries, $type, $max); } /** @@ -172,19 +181,20 @@ public function count(array $queries = [], ?string $type = null, ?int $max = nul * than producing a useful total. Callers that truly want a gauge sum * must opt in explicitly. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string $attribute Attribute to sum (default: 'value') * @param string $type Metric type: 'event' or 'gauge' * @return int * @throws \Exception */ - public function sum(array $queries = [], string $attribute = 'value', string $type = self::TYPE_EVENT): int + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = self::TYPE_EVENT): int { if ($type !== self::TYPE_EVENT && $type !== self::TYPE_GAUGE) { throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: " . self::TYPE_EVENT . ', ' . self::TYPE_GAUGE); } - return $this->adapter->sum($queries, $attribute, $type); + return $this->adapter->sum($tenant, $queries, $attribute, $type); } /** @@ -195,13 +205,14 @@ public function sum(array $queries = [], string $attribute = 'value', string $ty * Note: Daily MV only stores event metrics. This method always queries * the daily events table — gauges are never pre-aggregated. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @return array * @throws \Exception */ - public function findDaily(array $queries = []): array + public function findDaily(string $tenant, array $queries = []): array { - return $this->adapter->findDaily($queries); + return $this->adapter->findDaily($tenant, $queries); } /** @@ -213,14 +224,15 @@ public function findDaily(array $queries = []): array * Note: Daily MV only stores event metrics. This method always queries * the daily events table — gauges are never pre-aggregated. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array<\Utopia\Query\Query> $queries * @param string $attribute Attribute to sum (default: 'value') * @return int * @throws \Exception */ - public function sumDaily(array $queries = [], string $attribute = 'value'): int + public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int { - return $this->adapter->sumDaily($queries, $attribute); + return $this->adapter->sumDaily($tenant, $queries, $attribute); } /** @@ -229,28 +241,14 @@ public function sumDaily(array $queries = [], string $attribute = 'value'): int * Note: Daily MV only stores event metrics. This method always queries * the daily events table — gauges are never pre-aggregated. * + * @param string $tenant Tenant scope (shared-tables mode) * @param array $metrics List of metric names * @param array<\Utopia\Query\Query> $queries Additional filters (e.g. date range) * @return array Metric name => sum value * @throws \Exception */ - public function sumDailyBatch(array $metrics, array $queries = []): array + public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array { - return $this->adapter->sumDailyBatch($metrics, $queries); - } - - /** - * Set the tenant ID for multi-tenant support. - * - * @param string|null $tenant - * @return $this - * @throws \Exception - */ - public function setTenant(?string $tenant): self - { - if (method_exists($this->adapter, 'setTenant')) { - $this->adapter->setTenant($tenant); - } - return $this; + return $this->adapter->sumDailyBatch($tenant, $metrics, $queries); } } diff --git a/tests/Benchmark/BenchmarkBase.php b/tests/Benchmark/BenchmarkBase.php index c86acce..5500431 100644 --- a/tests/Benchmark/BenchmarkBase.php +++ b/tests/Benchmark/BenchmarkBase.php @@ -65,7 +65,6 @@ protected function setUp(): void database: getenv('CLICKHOUSE_DATABASE') ?: 'default', sharedTables: true, ); - $this->adapter->setTenant($this->tenant); $this->usage = new Usage($this->adapter); $this->usage->setup(); @@ -171,7 +170,7 @@ protected function runRawSql(string $sql): void */ protected function purgeAll(): void { - $this->usage->purge(); + $this->usage->purge($this->tenant); } /** diff --git a/tests/Benchmark/EventsBench.php b/tests/Benchmark/EventsBench.php index 62c9dec..1e4c1e3 100644 --- a/tests/Benchmark/EventsBench.php +++ b/tests/Benchmark/EventsBench.php @@ -25,7 +25,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_sum_30d', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->sum([ + $this->usage->sum($this->tenant, [ Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -34,7 +34,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_timeseries_30d_1h', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupByInterval('time', '1h'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), @@ -45,7 +45,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_count_max_5k', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->count([ + $this->usage->count($this->tenant, [ Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -56,6 +56,7 @@ public function testBenchmarks(): void $batch = []; for ($i = 0; $i < 10000; $i++) { $batch[] = [ + 'tenant' => $this->tenant, 'metric' => 'bench.insert.10k', 'value' => $i, 'tags' => [ @@ -72,7 +73,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_topN_path_30d', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), @@ -83,7 +84,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_topN_country_30d', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('country'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), @@ -94,7 +95,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_topN_service_30d', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), @@ -107,7 +108,7 @@ public function testBenchmarks(): void $todayEnd = (new DateTime('+2 hour'))->format('Y-m-d H:i:s'); $this->runBench('bench_events_topN_path_today_partial', function (string $queryId) use ($todayStart, $todayEnd): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $todayStart), @@ -118,7 +119,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_topN_path_30d_filtered_resource', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), Query::equal('resource', ['function']), @@ -130,7 +131,7 @@ public function testBenchmarks(): void $this->runBench('bench_events_topN_path_country', function (string $queryId) use ($start, $end): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('path'), UsageQuery::groupBy('country'), Query::equal('metric', [$this->metric]), @@ -144,6 +145,7 @@ public function testBenchmarks(): void $batch = []; for ($i = 0; $i < 10000; $i++) { $batch[] = [ + 'tenant' => $this->tenant, 'metric' => 'bench.insert.with_projections', 'value' => $i, 'tags' => [ diff --git a/tests/Benchmark/GaugesBench.php b/tests/Benchmark/GaugesBench.php index 94cc4b0..49bcf01 100644 --- a/tests/Benchmark/GaugesBench.php +++ b/tests/Benchmark/GaugesBench.php @@ -27,7 +27,7 @@ public function testBenchmarks(): void $this->runBench('bench_gauges_latest_in_window', function (string $queryId) use ($start30d, $endPartial): void { $this->adapter->setNextQueryId($queryId); - $this->usage->getTotal( + $this->usage->getTotal($this->tenant, $this->metric, [ Query::greaterThanEqual('time', $start30d), @@ -39,7 +39,7 @@ public function testBenchmarks(): void $this->runBench('bench_gauges_topN_service_30d', function (string $queryId) use ($start30d, $endClosed): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start30d), @@ -50,7 +50,7 @@ public function testBenchmarks(): void $this->runBench('bench_gauges_topN_resource_30d', function (string $queryId) use ($start30d, $endClosed): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('resource'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start30d), @@ -61,7 +61,7 @@ public function testBenchmarks(): void $this->runBench('bench_gauges_topN_service_today_partial', function (string $queryId) use ($start30d, $endPartial): void { $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find($this->tenant, [ UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start30d), diff --git a/tests/Usage/AccumulatorTest.php b/tests/Usage/AccumulatorTest.php index fe06790..98deef7 100644 --- a/tests/Usage/AccumulatorTest.php +++ b/tests/Usage/AccumulatorTest.php @@ -50,12 +50,12 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b /** * @return array */ - public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array { return []; } - public function getTotal(string $metric, array $queries = [], ?string $type = null): int + public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int { return 0; } @@ -63,12 +63,12 @@ public function getTotal(string $metric, array $queries = [], ?string $type = nu /** * @return array */ - public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array { return []; } - public function purge(array $queries = [], ?string $type = null): bool + public function purge(string $tenant, array $queries = [], ?string $type = null): bool { return true; } @@ -76,17 +76,17 @@ public function purge(array $queries = [], ?string $type = null): bool /** * @return array */ - public function find(array $queries = [], ?string $type = null): array + public function find(string $tenant, array $queries = [], ?string $type = null): array { return []; } - public function count(array $queries = [], ?string $type = null, ?int $max = null): int + public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int { return 0; } - public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int { return 0; } @@ -94,12 +94,12 @@ public function sum(array $queries = [], string $attribute = 'value', string $ty /** * @return array */ - public function findDaily(array $queries = []): array + public function findDaily(string $tenant, array $queries = []): array { return []; } - public function sumDaily(array $queries = [], string $attribute = 'value'): int + public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int { return 0; } @@ -107,7 +107,7 @@ public function sumDaily(array $queries = [], string $attribute = 'value'): int /** * @return array */ - public function sumDailyBatch(array $metrics, array $queries = []): array + public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array { return []; } @@ -127,9 +127,9 @@ protected function setUp(): void public function testEventsSumByKey(): void { - $this->accumulator->collect('requests', 10, Usage::TYPE_EVENT); - $this->accumulator->collect('requests', 20, Usage::TYPE_EVENT); - $this->accumulator->collect('requests', 30, Usage::TYPE_EVENT); + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('t1', 'requests', 20, Usage::TYPE_EVENT); + $this->accumulator->collect('t1', 'requests', 30, Usage::TYPE_EVENT); // Same metric + tags = 1 entry, values summed $this->assertEquals(1, $this->accumulator->count()); @@ -143,18 +143,33 @@ public function testEventsSumByKey(): void public function testTagsPartitionEntries(): void { - $this->accumulator->collect('requests', 10, Usage::TYPE_EVENT, ['region' => 'us']); - $this->accumulator->collect('requests', 20, Usage::TYPE_EVENT, ['region' => 'eu']); + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT, ['region' => 'us']); + $this->accumulator->collect('t1', 'requests', 20, Usage::TYPE_EVENT, ['region' => 'eu']); // Distinct tags = distinct entries $this->assertEquals(2, $this->accumulator->count()); } + public function testTenantPartitionsEntries(): void + { + // Same metric + tags but different tenants must not collapse + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('t2', 'requests', 20, Usage::TYPE_EVENT); + + $this->assertEquals(2, $this->accumulator->count()); + + $this->assertTrue($this->accumulator->flush()); + + $tenants = array_column($this->adapter->batches[0]['metrics'], 'tenant'); + sort($tenants); + $this->assertEquals(['t1', 't2'], $tenants); + } + public function testGaugesUseLastWriteWins(): void { - $this->accumulator->collect('storage', 100, Usage::TYPE_GAUGE); - $this->accumulator->collect('storage', 200, Usage::TYPE_GAUGE); - $this->accumulator->collect('storage', 300, Usage::TYPE_GAUGE); + $this->accumulator->collect('t1', 'storage', 100, Usage::TYPE_GAUGE); + $this->accumulator->collect('t1', 'storage', 200, Usage::TYPE_GAUGE); + $this->accumulator->collect('t1', 'storage', 300, Usage::TYPE_GAUGE); $this->assertEquals(1, $this->accumulator->count()); @@ -166,8 +181,8 @@ public function testGaugesUseLastWriteWins(): void public function testFlushSeparatesEventsAndGauges(): void { - $this->accumulator->collect('requests', 10, Usage::TYPE_EVENT); - $this->accumulator->collect('storage', 100, Usage::TYPE_GAUGE); + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('t1', 'storage', 100, Usage::TYPE_GAUGE); $this->assertTrue($this->accumulator->flush()); @@ -183,7 +198,7 @@ public function testFlushSeparatesEventsAndGauges(): void public function testFailedFlushRetainsBuffer(): void { - $this->accumulator->collect('requests', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT); $this->adapter->succeed = false; $this->assertFalse($this->accumulator->flush()); @@ -218,19 +233,19 @@ public function testEmptyMetricNameThrows(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Metric name cannot be empty'); - $this->accumulator->collect('', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('t1', '', 10, Usage::TYPE_EVENT); } public function testNegativeValueThrows(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Value cannot be negative'); - $this->accumulator->collect('requests', -1, Usage::TYPE_EVENT); + $this->accumulator->collect('t1', 'requests', -1, Usage::TYPE_EVENT); } public function testInvalidTypeThrows(): void { $this->expectException(\InvalidArgumentException::class); - $this->accumulator->collect('requests', 10, 'invalid'); + $this->accumulator->collect('t1', 'requests', 10, 'invalid'); } } diff --git a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php index 2f26f02..cef9ed3 100644 --- a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php @@ -28,23 +28,32 @@ class ClickHouseDimRoutingTest extends ClickHouseTestCase protected function setUp(): void { - $this->adapter = $this->makeAdapter('utopia_usage_dim_routing'); + $this->adapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_dim_routing', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); $this->usage = new Usage($this->adapter); $this->usage->setup(); - $this->usage->purge(); + $this->usage->purge('1'); $this->seedHistoricalRow($this->metric, 10, '-5 days', ['path' => '/v1/a', 'method' => 'GET', 'status' => '200', 'service' => 'storage', 'country' => 'us']); $this->seedHistoricalRow($this->metric, 20, '-4 days', ['path' => '/v1/a', 'method' => 'GET', 'status' => '200', 'service' => 'storage', 'country' => 'us']); $this->seedHistoricalRow($this->metric, 30, '-3 days', ['path' => '/v1/b', 'method' => 'POST', 'status' => '201', 'service' => 'databases', 'country' => 'de']); $this->seedHistoricalRow($this->metric, 40, '-3 days', ['path' => '/v1/c', 'method' => 'POST', 'status' => '500', 'service' => 'functions', 'country' => 'fr']); $this->usage->addBatch([ - ['metric' => $this->metric, 'value' => 5, 'tags' => ['path' => '/v1/a', 'method' => 'GET', 'status' => '200', 'service' => 'storage', 'country' => 'us']], + ['tenant' => '1', 'metric' => $this->metric, 'value' => 5, 'tags' => ['path' => '/v1/a', 'method' => 'GET', 'status' => '200', 'service' => 'storage', 'country' => 'us']], ], Usage::TYPE_EVENT); } protected function tearDown(): void { - $this->usage->purge(); + $this->usage->purge('1'); } /** @@ -109,7 +118,7 @@ public function testTopNGroupedQueryRoutesToMatchingProjection(array $dims, stri $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $rolled = $this->usage->find($queries, Usage::TYPE_EVENT); + $rolled = $this->usage->find('1', $queries, Usage::TYPE_EVENT); $this->assertSame($this->rawTotal($start, $end), $this->totalOf($rolled)); $this->assertProjectionUsed($queryId, $expectedProjection); @@ -122,7 +131,7 @@ public function testMultiDimNotInAnyProjectionFallsBackToTable(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupBy('path'), UsageQuery::groupBy('country'), Query::equal('metric', [$this->metric]), @@ -143,7 +152,7 @@ public function testFilterOnExtraColumnStillRoutesToProjectionWhenColumnPresent( $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), Query::equal('resource', ['function']), @@ -165,7 +174,7 @@ public function testSubDayIntervalStillRoutesToProjection(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), @@ -183,7 +192,7 @@ public function testWindowStraddlesTodayStillRoutesToProjection(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $rolled = $this->usage->find([ + $rolled = $this->usage->find('1', [ UsageQuery::groupBy('path'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), diff --git a/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php b/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php index d3c7c01..562a81b 100644 --- a/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseGaugeDimRoutingTest.php @@ -26,10 +26,19 @@ class ClickHouseGaugeDimRoutingTest extends ClickHouseTestCase protected function setUp(): void { - $this->adapter = $this->makeAdapter('utopia_usage_gauge_dim_routing'); + $this->adapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_gauge_dim_routing', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); $this->usage = new Usage($this->adapter); $this->usage->setup(); - $this->usage->purge(); + $this->usage->purge('1'); $this->seedHistoricalRow($this->metric, 100, '-5 days', ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f1']); $this->seedHistoricalRow($this->metric, 200, '-4 days', ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f2']); @@ -37,13 +46,13 @@ protected function setUp(): void $this->seedHistoricalRow($this->metric, 80, '-3 days', ['service' => 'functions', 'resource' => 'function', 'resourceId' => 'fn1']); $this->usage->addBatch([ - ['metric' => $this->metric, 'value' => 999, 'tags' => ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f1']], + ['tenant' => '1', 'metric' => $this->metric, 'value' => 999, 'tags' => ['service' => 'storage', 'resource' => 'file', 'resourceId' => 'f1']], ], Usage::TYPE_GAUGE); } protected function tearDown(): void { - $this->usage->purge(); + $this->usage->purge('1'); } /** @@ -109,7 +118,7 @@ public function testTopGaugesGroupedQueryRoutesToMatchingProjection(array $dims, $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find($queries, Usage::TYPE_GAUGE); + $this->usage->find('1', $queries, Usage::TYPE_GAUGE); $this->assertProjectionUsed($queryId, $expectedProjection); } @@ -123,7 +132,7 @@ public function testGaugesSubDayIntervalStillRoutesToProjection(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), @@ -141,7 +150,7 @@ public function testGaugesFilterOnNonProjectionColumnFallsBackToBaseTable(): voi $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), Query::equal('resourceId', ['x']), @@ -159,7 +168,7 @@ public function testGaugesUngroupedFallsBackToBaseTable(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -175,7 +184,7 @@ public function testGaugesWindowStraddlesTodayStillRoutesToProjection(): void $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupBy('service'), Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), diff --git a/tests/Usage/Adapter/ClickHouseRoutingTest.php b/tests/Usage/Adapter/ClickHouseRoutingTest.php index 4a71e90..a3b3b29 100644 --- a/tests/Usage/Adapter/ClickHouseRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseRoutingTest.php @@ -20,21 +20,30 @@ class ClickHouseRoutingTest extends ClickHouseTestCase protected function setUp(): void { - $this->adapter = $this->makeAdapter('utopia_usage_routing'); + $this->adapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_routing', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); $this->usage = new Usage($this->adapter); $this->usage->setup(); - $this->usage->purge(); + $this->usage->purge('1'); $this->seedHistoricalRow('routed.metric', 100, '-5 days', ['path' => '/v1/a']); $this->seedHistoricalRow('routed.metric', 200, '-3 days', ['path' => '/v1/b']); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'routed.metric', 'value' => 50, 'tags' => ['path' => '/v1/c']], + ['tenant' => '1', 'metric' => 'routed.metric', 'value' => 50, 'tags' => ['path' => '/v1/c']], ], Usage::TYPE_EVENT)); } protected function tearDown(): void { - $this->usage->purge(); + $this->usage->purge('1'); } /** @@ -74,7 +83,7 @@ public function testClosedDayWindowRoutesToDaily(): void $rawSum = $this->sumRaw('routed.metric', $start, $end); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -97,7 +106,7 @@ public function testInclusiveMidnightUpperBoundExcludesEndDayOnDailyRoute(): voi $rawSum = $this->sumRaw('routed.metric', $start, $end); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -121,7 +130,7 @@ public function testMidDayClosedWindowFallsBackToRaw(): void $start = (new DateTime('-7 days 12:30:00', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); $end = (new DateTime('-2 days 12:30:00', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -141,7 +150,7 @@ public function testWindowStraddlesTodayRoutesHybrid(): void $rawSum = $this->sumRaw('routed.metric', $start, $end); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -162,7 +171,7 @@ public function testHybridSumKeepsParamsDistinctWhenMetricFollowsTimeBounds(): v $rawSum = $this->sumRaw('routed.metric', $start, $end); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), Query::equal('metric', ['routed.metric']), @@ -181,7 +190,7 @@ public function testDimensionPresentForcesRaw(): void $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::equal('path', ['/v1/a']), Query::greaterThanEqual('time', $start), @@ -200,7 +209,7 @@ public function testFilterOnNonDailyColumnForcesRaw(): void $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::equal('country', ['us']), Query::greaterThanEqual('time', $start), @@ -222,7 +231,7 @@ public function testMidDayStartWithHybridWindowFallsBackToRaw(): void $start = (new DateTime('-2 days 14:00:00', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); $end = (new DateTime('+1 hour', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', [$metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -241,7 +250,7 @@ public function testIntervalPresentForcesRaw(): void $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ UsageQuery::groupByInterval('time', '1h'), Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), @@ -262,7 +271,7 @@ public function testDuplicateTimeFiltersTakeTightestBound(): void $endLoose = (new DateTime('+5 days', new DateTimeZone('UTC')))->setTime(0, 0, 0)->format('Y-m-d H:i:s'); $endTighter = (new DateTime('-1 day', new DateTimeZone('UTC')))->setTime(0, 0, 0)->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), Query::greaterThanEqual('time', $startTighter), @@ -283,7 +292,7 @@ public function testOpenEndedWindowForcesRaw(): void $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', $start), ], 'value', Usage::TYPE_EVENT); @@ -298,7 +307,7 @@ public function testMalformedTimeStringForcesRaw(): void $this->adapter->clearRouteLog(); try { - $this->usage->sum([ + $this->usage->sum('1', [ Query::equal('metric', ['routed.metric']), Query::greaterThanEqual('time', 'not-a-date'), Query::lessThanEqual('time', 'not-a-date-either'), @@ -321,7 +330,7 @@ public function testMalformedTimeStringForcesRaw(): void */ public function testNarrowPurgeWithNoTimeBoundDoesNotWipeDailyMv(): void { - $this->usage->purge(); + $this->usage->purge('1'); // Seed two metrics on two different days; let the daily MV // capture both as fully closed-day rows. @@ -335,12 +344,12 @@ public function testNarrowPurgeWithNoTimeBoundDoesNotWipeDailyMv(): void // bound. The raw delete narrows on path; the daily side has // no path column so the legacy logic would fall through to // DELETE WHERE 1=1 and wipe both metrics' daily rows. - $this->usage->purge([ + $this->usage->purge('1', [ Query::equal('path', ['/v1/remove']), ], Usage::TYPE_EVENT); $this->adapter->clearRouteLog(); - $keepSum = $this->usage->sum([ + $keepSum = $this->usage->sum('1', [ Query::equal('metric', ['purge.keep']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -358,7 +367,7 @@ public function testNarrowPurgeWithNoTimeBoundDoesNotWipeDailyMv(): void */ public function testValueFilterPurgeDoesNotMatchAggregateDailyRows(): void { - $this->usage->purge(); + $this->usage->purge('1'); // Seed two raw rows whose values sum to 10 on the same day, // so the daily MV row has value = 10. A naive purge with @@ -369,14 +378,14 @@ public function testValueFilterPurgeDoesNotMatchAggregateDailyRows(): void $start = (new DateTime('-7 days', new DateTimeZone('UTC')))->setTime(0, 0, 0)->format('Y-m-d H:i:s'); $end = (new DateTime('-1 day', new DateTimeZone('UTC')))->setTime(0, 0, 0)->format('Y-m-d H:i:s'); - $this->usage->purge([ + $this->usage->purge('1', [ Query::equal('value', [10]), ], Usage::TYPE_EVENT); // value is raw-only, so the routed sum stays on raw — it // sees the still-present rows. The point of this test is the // daily MV; check it directly via sumDaily. - $dailySum = $this->usage->sumDaily([ + $dailySum = $this->usage->sumDaily('1', [ Query::equal('metric', ['purge.value']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), diff --git a/tests/Usage/Adapter/ClickHouseSchemaTest.php b/tests/Usage/Adapter/ClickHouseSchemaTest.php index 50049b6..37c9e84 100644 --- a/tests/Usage/Adapter/ClickHouseSchemaTest.php +++ b/tests/Usage/Adapter/ClickHouseSchemaTest.php @@ -17,7 +17,16 @@ class ClickHouseSchemaTest extends ClickHouseTestCase protected function setUp(): void { - $this->adapter = $this->makeAdapter('utopia_usage_schema'); + $this->adapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_schema', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); $usage = new Usage($this->adapter); $usage->setup(); } @@ -102,7 +111,16 @@ public function testGaugesTableSwapsBloomForSetOnLowCardinality(): void public function testSetupBackfillsServiceResourceOnLegacyGaugesTable(): void { - $legacyAdapter = $this->makeAdapter('utopia_usage_schema_legacy_gauge'); + $legacyAdapter = new ClickHouseAdapter( + getenv('CLICKHOUSE_HOST') ?: 'clickhouse', + getenv('CLICKHOUSE_USER') ?: 'default', + getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse', + (int) (getenv('CLICKHOUSE_PORT') ?: 8123), + (bool) (getenv('CLICKHOUSE_SECURE') ?: false), + namespace: 'utopia_usage_schema_legacy_gauge', + database: getenv('CLICKHOUSE_DATABASE') ?: 'default', + sharedTables: true, + ); $database = $this->databaseName($legacyAdapter); $gaugesTable = $this->resolveTableName($legacyAdapter, 'getGaugesTableName'); $fullName = "`{$database}`.`{$gaugesTable}`"; diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index 28b2abb..aa76f37 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -31,13 +31,12 @@ protected function initializeUsage(): void namespace: 'utopia_usage', database: getenv('CLICKHOUSE_DATABASE') ?: 'default', ); - $adapter->setTenant('1'); $this->usage = new Usage($adapter); $this->usage->setup(); } - public function testMetricTenantOverridesAdapterTenantInBatch(): void + public function testPerMetricTenantInBatch(): void { $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse'; $username = getenv('CLICKHOUSE_USER') ?: 'default'; @@ -55,34 +54,36 @@ public function testMetricTenantOverridesAdapterTenantInBatch(): void database: getenv('CLICKHOUSE_DATABASE') ?: 'default', sharedTables: true, ); - $adapter->setTenant('1'); $usage = new Usage($adapter); $usage->setup(); - $usage->purge(); + $usage->purge('2'); + // The metric carries its own tenant; the batch writes it under tenant '2'. $metrics = [ [ + 'tenant' => '2', 'metric' => 'tenant-override', 'value' => 5, - '$tenant' => '2', 'tags' => [], ], ]; $this->assertTrue($usage->addBatch($metrics, Usage::TYPE_EVENT)); - // Switch adapter scope to the metric tenant to verify the row was stored under the override - $adapter->setTenant('2'); - - $results = $usage->find([ + // Reading under tenant '2' returns the row; tenant '1' must not see it. + $results = $usage->find('2', [ \Utopia\Query\Query::equal('metric', ['tenant-override']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); $this->assertEquals('2', $results[0]->getTenant()); - $usage->purge(); + $this->assertCount(0, $usage->find('1', [ + \Utopia\Query\Query::equal('metric', ['tenant-override']), + ], Usage::TYPE_EVENT)); + + $usage->purge('2'); } /** @@ -91,17 +92,17 @@ public function testMetricTenantOverridesAdapterTenantInBatch(): void public function testAddBatchWithBatchSize(): void { $metrics = [ - ['metric' => 'metric-1', 'value' => 10, 'tags' => []], - ['metric' => 'metric-2', 'value' => 20, 'tags' => []], - ['metric' => 'metric-3', 'value' => 30, 'tags' => []], - ['metric' => 'metric-4', 'value' => 40, 'tags' => []], + ['tenant' => '1', 'metric' => 'metric-1', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'metric-2', 'value' => 20, 'tags' => []], + ['tenant' => '1', 'metric' => 'metric-3', 'value' => 30, 'tags' => []], + ['tenant' => '1', 'metric' => 'metric-4', 'value' => 40, 'tags' => []], ]; // Process with batch size of 2 $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 2)); // Verify all metrics were inserted - $results = $this->usage->find([], Usage::TYPE_EVENT); + $results = $this->usage->find('1', [], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(4, count($results)); } @@ -111,16 +112,16 @@ public function testAddBatchWithBatchSize(): void public function testAddBatchGaugeWithBatchSize(): void { $metrics = [ - ['metric' => 'counter-1', 'value' => 100, 'tags' => []], - ['metric' => 'counter-2', 'value' => 200, 'tags' => []], - ['metric' => 'counter-3', 'value' => 300, 'tags' => []], + ['tenant' => '1', 'metric' => 'counter-1', 'value' => 100, 'tags' => []], + ['tenant' => '1', 'metric' => 'counter-2', 'value' => 200, 'tags' => []], + ['tenant' => '1', 'metric' => 'counter-3', 'value' => 300, 'tags' => []], ]; // Process with batch size of 2 $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_GAUGE, 2)); // Verify gauge metrics were inserted - $results = $this->usage->find([], Usage::TYPE_GAUGE); + $results = $this->usage->find('1', [], Usage::TYPE_GAUGE); $this->assertGreaterThanOrEqual(3, count($results)); } @@ -141,7 +142,7 @@ public function testLargeBatchWithSmallBatchSize(): void $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 10)); // Verify metrics were processed - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['large-batch-metric']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); @@ -152,18 +153,18 @@ public function testLargeBatchWithSmallBatchSize(): void */ public function testGaugeMetricsLastValueWins(): void { - $this->usage->purge([], Usage::TYPE_GAUGE); + $this->usage->purge('1', [], Usage::TYPE_GAUGE); $metrics = [ - ['metric' => 'gauge-test', 'value' => 5, 'tags' => []], - ['metric' => 'gauge-test', 'value' => 10, 'tags' => []], - ['metric' => 'gauge-test', 'value' => 15, 'tags' => []], + ['tenant' => '1', 'metric' => 'gauge-test', 'value' => 5, 'tags' => []], + ['tenant' => '1', 'metric' => 'gauge-test', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'gauge-test', 'value' => 15, 'tags' => []], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_GAUGE)); // Gauge total returns argMax (latest value) - $total = $this->usage->getTotal('gauge-test', [], Usage::TYPE_GAUGE); + $total = $this->usage->getTotal('1', 'gauge-test', [], Usage::TYPE_GAUGE); $this->assertGreaterThanOrEqual(5, $total); } @@ -172,18 +173,18 @@ public function testGaugeMetricsLastValueWins(): void */ public function testEventMetricsAggregate(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $metrics = [ - ['metric' => 'agg-test', 'value' => 5, 'tags' => []], - ['metric' => 'agg-test', 'value' => 10, 'tags' => []], - ['metric' => 'agg-test', 'value' => 15, 'tags' => []], + ['tenant' => '1', 'metric' => 'agg-test', 'value' => 5, 'tags' => []], + ['tenant' => '1', 'metric' => 'agg-test', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'agg-test', 'value' => 15, 'tags' => []], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT)); // Event metrics should sum: 5 + 10 + 15 = 30 - $total = $this->usage->getTotal('agg-test', [], Usage::TYPE_EVENT); + $total = $this->usage->getTotal('1', 'agg-test', [], Usage::TYPE_EVENT); $this->assertEquals(30, $total); } @@ -201,14 +202,14 @@ public function testEmptyBatchClickHouse(): void public function testBatchWithTagsClickHouse(): void { $metrics = [ - ['metric' => 'tagged', 'value' => 10, 'tags' => ['region' => 'us-east', 'path' => '/v1/test', 'method' => 'GET', 'status' => '200']], - ['metric' => 'tagged', 'value' => 20, 'tags' => ['region' => 'us-west', 'path' => '/v1/test', 'method' => 'POST', 'status' => '201']], - ['metric' => 'tagged', 'value' => 15, 'tags' => ['region' => 'eu-west', 'path' => '/v1/test', 'method' => 'GET', 'status' => '200']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 10, 'tags' => ['region' => 'us-east', 'path' => '/v1/test', 'method' => 'GET', 'status' => '200']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 20, 'tags' => ['region' => 'us-west', 'path' => '/v1/test', 'method' => 'POST', 'status' => '201']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 15, 'tags' => ['region' => 'eu-west', 'path' => '/v1/test', 'method' => 'GET', 'status' => '200']], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['tagged']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); @@ -221,7 +222,7 @@ public function testBatchWithTagsClickHouse(): void */ public function testEventColumnsExtractedFromTags(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $metrics = [ [ @@ -258,7 +259,7 @@ public function testEventColumnsExtractedFromTags(): void $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['event-cols-test']), ], Usage::TYPE_EVENT); @@ -297,7 +298,7 @@ public function testEventColumnsExtractedFromTags(): void */ public function testGaugeColumnsRoundTrip(): void { - $this->usage->purge([], Usage::TYPE_GAUGE); + $this->usage->purge('1', [], Usage::TYPE_GAUGE); $this->assertTrue($this->usage->addBatch([ [ @@ -314,7 +315,7 @@ public function testGaugeColumnsRoundTrip(): void ], ], Usage::TYPE_GAUGE)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['gauge-cols-test']), ], Usage::TYPE_GAUGE); @@ -333,18 +334,18 @@ public function testUnknownTagKeyThrows(): void $this->expectException(\Exception::class); $this->expectExceptionMessageMatches("/Unknown column 'bogus'/"); $this->usage->addBatch([ - ['metric' => 'x', 'value' => 1, 'tags' => ['bogus' => 'v']], + ['tenant' => '1', 'metric' => 'x', 'value' => 1, 'tags' => ['bogus' => 'v']], ], Usage::TYPE_EVENT); } public function testCountryLowercased(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'lc-country', 'value' => 1, 'tags' => ['country' => 'US']], + ['tenant' => '1', 'metric' => 'lc-country', 'value' => 1, 'tags' => ['country' => 'US']], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['lc-country']), ], Usage::TYPE_EVENT); @@ -354,12 +355,12 @@ public function testCountryLowercased(): void public function testRegionLowercased(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'lc-region', 'value' => 1, 'tags' => ['region' => 'FR']], + ['tenant' => '1', 'metric' => 'lc-region', 'value' => 1, 'tags' => ['region' => 'FR']], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['lc-region']), ], Usage::TYPE_EVENT); @@ -369,12 +370,12 @@ public function testRegionLowercased(): void public function testEmptyStringCoercedToNull(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'empty-string', 'value' => 1, 'tags' => ['osName' => '']], + ['tenant' => '1', 'metric' => 'empty-string', 'value' => 1, 'tags' => ['osName' => '']], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['empty-string']), ], Usage::TYPE_EVENT); @@ -388,7 +389,7 @@ public function testEmptyStringCoercedToNull(): void */ public function testEventsSchemaPersistsAllNewColumns(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $tags = [ 'path' => '/v1/x', 'method' => 'GET', 'status' => '200', @@ -405,14 +406,14 @@ public function testEventsSchemaPersistsAllNewColumns(): void ]; $this->assertTrue($this->usage->addBatch([ - ['metric' => 'schema-roundtrip', 'value' => 1, 'tags' => $tags], + ['tenant' => '1', 'metric' => 'schema-roundtrip', 'value' => 1, 'tags' => $tags], ], Usage::TYPE_EVENT)); // Filtering on each indexed dimension should be schema-valid. foreach (['service', 'resourceInternalId', 'teamId', 'teamInternalId', 'region', 'hostname', 'osName', 'clientName', 'deviceName'] as $col) { $value = $tags[$col]; $expected = $col === 'region' ? strtolower($value) : $value; - $rows = $this->usage->find([ + $rows = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['schema-roundtrip']), \Utopia\Query\Query::equal($col, [$expected]), ], Usage::TYPE_EVENT); @@ -425,42 +426,42 @@ public function testEventsSchemaPersistsAllNewColumns(): void */ public function testQueryEventsByColumns(): void { - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'req', 'value' => 10, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resource' => 'project', 'resourceId' => 'p1']], - ['metric' => 'req', 'value' => 20, 'tags' => ['path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resource' => 'database', 'resourceId' => 'db1']], - ['metric' => 'req', 'value' => 30, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '404', 'resource' => 'project', 'resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'req', 'value' => 10, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resource' => 'project', 'resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'req', 'value' => 20, 'tags' => ['path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resource' => 'database', 'resourceId' => 'db1']], + ['tenant' => '1', 'metric' => 'req', 'value' => 30, 'tags' => ['path' => '/v1/storage', 'method' => 'GET', 'status' => '404', 'resource' => 'project', 'resourceId' => 'p1']], ], Usage::TYPE_EVENT)); // Filter by path - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('path', ['/v1/storage']), ], Usage::TYPE_EVENT); $this->assertCount(2, $results); // Filter by method - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('method', ['POST']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); $this->assertEquals(20, $results[0]->getValue()); // Filter by status - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('status', ['404']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); $this->assertEquals(30, $results[0]->getValue()); // Filter by resource - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('resource', ['database']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); // Filter by resourceId - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('resourceId', ['db1']), ], Usage::TYPE_EVENT); $this->assertCount(1, $results); @@ -471,13 +472,13 @@ public function testQueryEventsByColumns(): void */ public function testGaugeTableSimpleSchema(): void { - $this->usage->purge([], Usage::TYPE_GAUGE); + $this->usage->purge('1', [], Usage::TYPE_GAUGE); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gauge-simple', 'value' => 500, 'tags' => ['resourceId' => 'r1']], + ['tenant' => '1', 'metric' => 'gauge-simple', 'value' => 500, 'tags' => ['resourceId' => 'r1']], ], Usage::TYPE_GAUGE)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['gauge-simple']), ], Usage::TYPE_GAUGE); @@ -498,18 +499,18 @@ public function testGaugeTableSimpleSchema(): void */ public function testFindBothTables(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'both-test-event', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'both-test-event', 'value' => 10, 'tags' => []], ], Usage::TYPE_EVENT)); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'both-test-gauge', 'value' => 100, 'tags' => []], + ['tenant' => '1', 'metric' => 'both-test-gauge', 'value' => 100, 'tags' => []], ], Usage::TYPE_GAUGE)); // Find from both tables - $results = $this->usage->find([], null); + $results = $this->usage->find('1', [], null); $this->assertGreaterThanOrEqual(2, count($results)); $metricNames = array_map(fn ($m) => $m->getMetric(), $results); @@ -533,7 +534,7 @@ public function testBatchSizeAtMaximum(): void $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 1000)); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ \Utopia\Query\Query::equal('metric', ['boundary-test']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(500, $sum); @@ -545,15 +546,15 @@ public function testBatchSizeAtMaximum(): void public function testBatchSizeOfOne(): void { $metrics = [ - ['metric' => 'size-one-1', 'value' => 10, 'tags' => []], - ['metric' => 'size-one-2', 'value' => 20, 'tags' => []], - ['metric' => 'size-one-3', 'value' => 30, 'tags' => []], + ['tenant' => '1', 'metric' => 'size-one-1', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'size-one-2', 'value' => 20, 'tags' => []], + ['tenant' => '1', 'metric' => 'size-one-3', 'value' => 30, 'tags' => []], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 1)); // All metrics should be inserted - $results = $this->usage->find([], Usage::TYPE_EVENT); + $results = $this->usage->find('1', [], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(3, count($results)); } @@ -574,7 +575,7 @@ public function testDefaultBatchSize(): void // Use default batch size $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT)); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ \Utopia\Query\Query::equal('metric', ['default-batch-test']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(50, $sum); @@ -587,10 +588,10 @@ public function testMetricsWithSpecialCharacters(): void { $specialVal = "Text with \n newline, \t tab, \"quote\", and unicode \u{1F600}"; $this->assertTrue($this->usage->addBatch([ - ['metric' => 'special-metric', 'value' => 1, 'tags' => ['hostname' => $specialVal]], + ['tenant' => '1', 'metric' => 'special-metric', 'value' => 1, 'tags' => ['hostname' => $specialVal]], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['special-metric']), ], Usage::TYPE_EVENT); @@ -605,31 +606,31 @@ public function testMetricsWithSpecialCharacters(): void public function testFindComprehensive(): void { // Cleanup - $this->usage->purge(); + $this->usage->purge('1'); // Setup test data $this->usage->addBatch([ - ['metric' => 'metric-A', 'value' => 10, 'tags' => ['service' => 'cat1']], + ['tenant' => '1', 'metric' => 'metric-A', 'value' => 10, 'tags' => ['service' => 'cat1']], ], Usage::TYPE_EVENT); $this->usage->addBatch([ - ['metric' => 'metric-B', 'value' => 20, 'tags' => ['service' => 'cat2']], + ['tenant' => '1', 'metric' => 'metric-B', 'value' => 20, 'tags' => ['service' => 'cat2']], ], Usage::TYPE_EVENT); // 1. Array Equal (IN) - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(2, count($results)); // 2. Scalar Equal - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('value', [20]), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); $this->assertEquals(20, $results[0]->getValue()); // 3. Less Than - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::lessThan('value', 20), \Utopia\Query\Query::equal('metric', ['metric-A']), ], Usage::TYPE_EVENT); @@ -637,7 +638,7 @@ public function testFindComprehensive(): void $this->assertEquals(10, $results[0]->getValue()); // 4. Greater Than - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::greaterThan('value', 10), \Utopia\Query\Query::equal('metric', ['metric-B']), ], Usage::TYPE_EVENT); @@ -645,20 +646,20 @@ public function testFindComprehensive(): void $this->assertEquals(20, $results[0]->getValue()); // 5. Between - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::between('value', 5, 25), \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(2, count($results)); // 6. Contains (IN alias) - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::contains('metric', ['metric-A']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); // 7. Order Desc - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']), \Utopia\Query\Query::orderDesc('value'), \Utopia\Query\Query::limit(2), @@ -667,7 +668,7 @@ public function testFindComprehensive(): void $this->assertTrue($results[0]->getValue() >= $results[1]->getValue()); // 8. Order Asc - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']), \Utopia\Query\Query::orderAsc('value'), \Utopia\Query\Query::limit(2), @@ -756,7 +757,6 @@ public function testConnectionPooling(): void namespace: 'utopia_usage_pooling_test', database: getenv('CLICKHOUSE_DATABASE') ?: 'default', ); - $adapter->setTenant('1'); $usage = new Usage($adapter); $usage->setup(); @@ -771,10 +771,10 @@ public function testConnectionPooling(): void // Make some requests $usage->addBatch([ - ['metric' => 'pooling.test', 'value' => 100, 'tags' => ['service' => 'value']], + ['tenant' => '1', 'metric' => 'pooling.test', 'value' => 100, 'tags' => ['service' => 'value']], ], Usage::TYPE_EVENT); - $usage->find([], Usage::TYPE_EVENT); - $usage->count([], Usage::TYPE_EVENT); + $usage->find('1', [], Usage::TYPE_EVENT); + $usage->count('1', [], Usage::TYPE_EVENT); // Verify request count increased $newStats = $adapter->getConnectionStats(); @@ -802,13 +802,12 @@ public function testErrorMessagesIncludeContext(): void namespace: 'utopia_usage_error_test', database: 'nonexistent_db_for_testing_errors_12345', ); - $adapter->setTenant('1'); $usage = new Usage($adapter); try { // This should fail because database doesn't exist - $usage->find([], Usage::TYPE_EVENT); + $usage->find('1', [], Usage::TYPE_EVENT); $this->fail('Expected exception was not thrown'); } catch (\Exception $e) { $errorMessage = $e->getMessage(); @@ -849,7 +848,6 @@ public function testAsyncInsertConfiguration(): void // Async inserts enabled, waiting for confirmation. $adapter = $makeAdapter(true, true); - $adapter->setTenant('1'); $stats = $adapter->getConnectionStats(); $this->assertTrue($stats['async_inserts']); @@ -858,13 +856,13 @@ public function testAsyncInsertConfiguration(): void // Verify it works with async inserts enabled $usage = new Usage($adapter); $usage->setup(); - $usage->purge(); + $usage->purge('1'); $this->assertTrue($usage->addBatch([ - ['metric' => 'async-test', 'value' => 42, 'tags' => []], + ['tenant' => '1', 'metric' => 'async-test', 'value' => 42, 'tags' => []], ], Usage::TYPE_EVENT)); - $total = $usage->getTotal('async-test', [], Usage::TYPE_EVENT); + $total = $usage->getTotal('1', 'async-test', [], Usage::TYPE_EVENT); $this->assertEquals(42, $total); // Fire-and-forget mode: async on, no wait for confirmation. @@ -876,22 +874,22 @@ public function testAsyncInsertConfiguration(): void $stats = $makeAdapter(false, true)->getConnectionStats(); $this->assertFalse($stats['async_inserts']); - $usage->purge(); + $usage->purge('1'); } public function testCursorAfterPaginatesEvents(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'cursor-events', 'value' => 1, 'tags' => []], - ['metric' => 'cursor-events', 'value' => 2, 'tags' => []], - ['metric' => 'cursor-events', 'value' => 3, 'tags' => []], - ['metric' => 'cursor-events', 'value' => 4, 'tags' => []], - ['metric' => 'cursor-events', 'value' => 5, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-events', 'value' => 1, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-events', 'value' => 2, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-events', 'value' => 3, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-events', 'value' => 4, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-events', 'value' => 5, 'tags' => []], ], Usage::TYPE_EVENT)); - $page1 = $this->usage->find([ + $page1 = $this->usage->find('1', [ Query::equal('metric', ['cursor-events']), Query::orderAsc('id'), Query::limit(2), @@ -901,7 +899,7 @@ public function testCursorAfterPaginatesEvents(): void $cursor = $page1[count($page1) - 1]; - $page2 = $this->usage->find([ + $page2 = $this->usage->find('1', [ Query::equal('metric', ['cursor-events']), Query::orderAsc('id'), Query::limit(2), @@ -915,16 +913,16 @@ public function testCursorAfterPaginatesEvents(): void public function testCursorBeforeReversesPagination(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'cursor-before', 'value' => 1, 'tags' => []], - ['metric' => 'cursor-before', 'value' => 2, 'tags' => []], - ['metric' => 'cursor-before', 'value' => 3, 'tags' => []], - ['metric' => 'cursor-before', 'value' => 4, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-before', 'value' => 1, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-before', 'value' => 2, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-before', 'value' => 3, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-before', 'value' => 4, 'tags' => []], ], Usage::TYPE_EVENT)); - $all = $this->usage->find([ + $all = $this->usage->find('1', [ Query::equal('metric', ['cursor-before']), Query::orderAsc('id'), Query::limit(10), @@ -932,7 +930,7 @@ public function testCursorBeforeReversesPagination(): void $this->assertCount(4, $all); - $before = $this->usage->find([ + $before = $this->usage->find('1', [ Query::equal('metric', ['cursor-before']), Query::orderAsc('id'), Query::limit(2), @@ -946,21 +944,21 @@ public function testCursorBeforeReversesPagination(): void public function testCursorAcceptsAssociativeArray(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'cursor-array', 'value' => 1, 'tags' => []], - ['metric' => 'cursor-array', 'value' => 2, 'tags' => []], - ['metric' => 'cursor-array', 'value' => 3, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-array', 'value' => 1, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-array', 'value' => 2, 'tags' => []], + ['tenant' => '1', 'metric' => 'cursor-array', 'value' => 3, 'tags' => []], ], Usage::TYPE_EVENT)); - $all = $this->usage->find([ + $all = $this->usage->find('1', [ Query::equal('metric', ['cursor-array']), Query::orderAsc('id'), Query::limit(10), ], Usage::TYPE_EVENT); - $page = $this->usage->find([ + $page = $this->usage->find('1', [ Query::equal('metric', ['cursor-array']), Query::orderAsc('id'), Query::limit(10), @@ -975,7 +973,7 @@ public function testCursorWithoutTypeThrows(): void { $this->expectException(\Exception::class); - $this->usage->find([ + $this->usage->find('1', [ Query::cursorAfter(['id' => 'whatever']), ]); } @@ -987,7 +985,7 @@ public function testCursorWithGroupByIntervalThrows(): void $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -997,18 +995,18 @@ public function testCursorWithGroupByIntervalThrows(): void public function testGroupByServiceDailyAggregates(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gb-service', 'value' => 10, 'tags' => ['service' => 'storage']], - ['metric' => 'gb-service', 'value' => 25, 'tags' => ['service' => 'storage']], - ['metric' => 'gb-service', 'value' => 5, 'tags' => ['service' => 'databases']], + ['tenant' => '1', 'metric' => 'gb-service', 'value' => 10, 'tags' => ['service' => 'storage']], + ['tenant' => '1', 'metric' => 'gb-service', 'value' => 25, 'tags' => ['service' => 'storage']], + ['tenant' => '1', 'metric' => 'gb-service', 'value' => 5, 'tags' => ['service' => 'databases']], ], Usage::TYPE_EVENT)); $start = (new \DateTime())->modify('-1 day')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 day')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1d'), UsageQuery::groupBy('service'), Query::equal('metric', ['gb-service']), @@ -1031,19 +1029,19 @@ public function testGroupByServiceDailyAggregates(): void public function testGroupByMultipleDimensionsHourly(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gb-multi', 'value' => 1, 'tags' => ['service' => 'storage', 'path' => '/v1/a']], - ['metric' => 'gb-multi', 'value' => 2, 'tags' => ['service' => 'storage', 'path' => '/v1/a']], - ['metric' => 'gb-multi', 'value' => 4, 'tags' => ['service' => 'storage', 'path' => '/v1/b']], - ['metric' => 'gb-multi', 'value' => 8, 'tags' => ['service' => 'databases', 'path' => '/v1/a']], + ['tenant' => '1', 'metric' => 'gb-multi', 'value' => 1, 'tags' => ['service' => 'storage', 'path' => '/v1/a']], + ['tenant' => '1', 'metric' => 'gb-multi', 'value' => 2, 'tags' => ['service' => 'storage', 'path' => '/v1/a']], + ['tenant' => '1', 'metric' => 'gb-multi', 'value' => 4, 'tags' => ['service' => 'storage', 'path' => '/v1/b']], + ['tenant' => '1', 'metric' => 'gb-multi', 'value' => 8, 'tags' => ['service' => 'databases', 'path' => '/v1/a']], ], Usage::TYPE_EVENT)); $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), UsageQuery::groupBy('service'), UsageQuery::groupBy('path'), @@ -1071,7 +1069,7 @@ public function testGroupByMultipleDimensionsHourly(): void public function testNotEqualQuery(): void { // Fixture: requests x2, bandwidth x1 in events - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::notEqual('metric', 'requests'), ], Usage::TYPE_EVENT); // bandwidth row only @@ -1083,7 +1081,7 @@ public function testNotEqualQuery(): void public function testNotContainsQuery(): void { - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::notContains('metric', ['requests', 'bandwidth']), ], Usage::TYPE_EVENT); $this->assertCount(0, $results); @@ -1094,7 +1092,7 @@ public function testNotBetweenQuery(): void $past = (new \DateTime())->modify('-2 hour')->format('Y-m-d H:i:s'); $oldPast = (new \DateTime())->modify('-3 hour')->format('Y-m-d H:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::notBetween('time', $oldPast, $past), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(3, count($results)); @@ -1104,13 +1102,13 @@ public function testIsNullAndIsNotNullQueries(): void { // 'country' is a nullable column; fixture rows have no country tag set // so depending on how addBatch persists tags, country may be null or empty. - $isNotNull = $this->usage->find([ + $isNotNull = $this->usage->find('1', [ Query::isNotNull('metric'), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(3, count($isNotNull)); // metric is required so isNull returns nothing - $isNull = $this->usage->find([ + $isNull = $this->usage->find('1', [ Query::isNull('metric'), ], Usage::TYPE_EVENT); $this->assertCount(0, $isNull); @@ -1119,12 +1117,12 @@ public function testIsNullAndIsNotNullQueries(): void public function testStartsWithAndEndsWithQueries(): void { // Fixture: paths /v1/storage, /v1/databases, /v1/storage/files - $startsWith = $this->usage->find([ + $startsWith = $this->usage->find('1', [ Query::startsWith('path', '/v1/'), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(3, count($startsWith)); - $endsWith = $this->usage->find([ + $endsWith = $this->usage->find('1', [ Query::endsWith('path', '/files'), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($endsWith)); @@ -1140,7 +1138,7 @@ public function testContainsRejectsEmptyValues(): void $this->expectException(\Exception::class); $this->expectExceptionMessage('Contains queries require at least one value.'); - $this->usage->find([ + $this->usage->find('1', [ Query::contains('metric', []), ], Usage::TYPE_EVENT); } @@ -1150,7 +1148,7 @@ public function testNotContainsRejectsEmptyValues(): void $this->expectException(\Exception::class); $this->expectExceptionMessage('NotContains queries require at least one value.'); - $this->usage->find([ + $this->usage->find('1', [ Query::notContains('metric', []), ], Usage::TYPE_EVENT); } @@ -1160,7 +1158,7 @@ public function testEqualRejectsEmptyValues(): void $this->expectException(\Exception::class); $this->expectExceptionMessage('Equal queries require at least one value.'); - $this->usage->find([ + $this->usage->find('1', [ new Query(Query::TYPE_EQUAL, 'metric', []), ], Usage::TYPE_EVENT); } diff --git a/tests/Usage/Adapter/ClickHouseTestCase.php b/tests/Usage/Adapter/ClickHouseTestCase.php index b9c2df0..24d9ed4 100644 --- a/tests/Usage/Adapter/ClickHouseTestCase.php +++ b/tests/Usage/Adapter/ClickHouseTestCase.php @@ -9,43 +9,12 @@ /** * Shared base for ClickHouse adapter integration tests. * - * Centralizes the env-driven adapter wiring and a handful of reflection - * helpers that every test in this directory needs (database name lookup, - * private query() / table-name resolver invocation). + * Holds only the reflection helpers that need access to private adapter + * internals (database name lookup, private query() / table-name resolver). + * Adapter construction is intentionally inlined in each test for clarity. */ abstract class ClickHouseTestCase extends TestCase { - /** - * Build a ClickHouse adapter from the standard test env variables. - * Subclasses override namespace and tenant to keep test data isolated. - */ - protected function makeAdapter(string $namespace, ?string $tenant = '1'): ClickHouseAdapter - { - $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse'; - $username = getenv('CLICKHOUSE_USER') ?: 'default'; - $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse'; - $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123); - $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false); - - $database = getenv('CLICKHOUSE_DATABASE') ?: 'default'; - - $adapter = new ClickHouseAdapter( - $host, - $username, - $password, - $port, - $secure, - namespace: $namespace, - database: $database, - sharedTables: true, - ); - if ($tenant !== null) { - $adapter->setTenant($tenant); - } - - return $adapter; - } - /** * Read the adapter's `database` property (private). */ diff --git a/tests/Usage/Adapter/DatabaseTest.php b/tests/Usage/Adapter/DatabaseTest.php index 2d142f8..8087043 100644 --- a/tests/Usage/Adapter/DatabaseTest.php +++ b/tests/Usage/Adapter/DatabaseTest.php @@ -64,7 +64,7 @@ public function testEventColumnsExtractedFromTags(): void $this->markTestSkipped('pdo_mysql extension is not installed'); } - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ [ @@ -90,7 +90,7 @@ public function testEventColumnsExtractedFromTags(): void ], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['event-cols-db']), ], Usage::TYPE_EVENT); @@ -118,7 +118,7 @@ public function testGaugeColumnsRoundTrip(): void $this->markTestSkipped('pdo_mysql extension is not installed'); } - $this->usage->purge([], Usage::TYPE_GAUGE); + $this->usage->purge('1', [], Usage::TYPE_GAUGE); $this->assertTrue($this->usage->addBatch([ [ @@ -133,7 +133,7 @@ public function testGaugeColumnsRoundTrip(): void ], ], Usage::TYPE_GAUGE)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['gauge-cols-db']), ], Usage::TYPE_GAUGE); @@ -154,7 +154,7 @@ public function testUnknownTagKeyThrows(): void $this->expectException(\Exception::class); $this->expectExceptionMessageMatches("/Unknown column 'bogus'/"); $this->usage->addBatch([ - ['metric' => 'x', 'value' => 1, 'tags' => ['bogus' => 'v']], + ['tenant' => '1', 'metric' => 'x', 'value' => 1, 'tags' => ['bogus' => 'v']], ], Usage::TYPE_EVENT); } @@ -164,12 +164,12 @@ public function testCountryAndRegionLowercased(): void $this->markTestSkipped('pdo_mysql extension is not installed'); } - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'lc-db', 'value' => 1, 'tags' => ['country' => 'US', 'region' => 'FR']], + ['tenant' => '1', 'metric' => 'lc-db', 'value' => 1, 'tags' => ['country' => 'US', 'region' => 'FR']], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['lc-db']), ], Usage::TYPE_EVENT); @@ -184,12 +184,12 @@ public function testEmptyStringCoercedToNull(): void $this->markTestSkipped('pdo_mysql extension is not installed'); } - $this->usage->purge([], Usage::TYPE_EVENT); + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'empty-db', 'value' => 1, 'tags' => ['osName' => '']], + ['tenant' => '1', 'metric' => 'empty-db', 'value' => 1, 'tags' => ['osName' => '']], ], Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ \Utopia\Query\Query::equal('metric', ['empty-db']), ], Usage::TYPE_EVENT); diff --git a/tests/Usage/TenantTest.php b/tests/Usage/TenantTest.php new file mode 100644 index 0000000..57775fd --- /dev/null +++ b/tests/Usage/TenantTest.php @@ -0,0 +1,205 @@ +> */ + public array $lastMetrics = []; + + public function getName(): string + { + return 'tenant-recording'; + } + + /** + * @return array + */ + public function healthCheck(): array + { + return ['healthy' => true]; + } + + public function setup(): void + { + } + + /** + * @param array> $metrics + */ + public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool + { + $this->lastMetrics = $metrics; + return true; + } + + /** + * @return array + */ + public function getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + { + $this->lastTenant = $tenant; + return []; + } + + public function getTotal(string $tenant, string $metric, array $queries = [], ?string $type = null): int + { + $this->lastTenant = $tenant; + return 0; + } + + /** + * @return array + */ + public function getTotalBatch(string $tenant, array $metrics, array $queries = [], ?string $type = null): array + { + $this->lastTenant = $tenant; + return []; + } + + public function purge(string $tenant, array $queries = [], ?string $type = null): bool + { + $this->lastTenant = $tenant; + return true; + } + + /** + * @return array + */ + public function find(string $tenant, array $queries = [], ?string $type = null): array + { + $this->lastTenant = $tenant; + return []; + } + + public function count(string $tenant, array $queries = [], ?string $type = null, ?int $max = null): int + { + $this->lastTenant = $tenant; + return 0; + } + + public function sum(string $tenant, array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + { + $this->lastTenant = $tenant; + return 0; + } + + /** + * @return array + */ + public function findDaily(string $tenant, array $queries = []): array + { + $this->lastTenant = $tenant; + return []; + } + + public function sumDaily(string $tenant, array $queries = [], string $attribute = 'value'): int + { + $this->lastTenant = $tenant; + return 0; + } + + /** + * @return array + */ + public function sumDailyBatch(string $tenant, array $metrics, array $queries = []): array + { + $this->lastTenant = $tenant; + return []; + } +} + +class TenantTest extends TestCase +{ + private TenantRecordingAdapter $adapter; + + private Tenant $tenant; + + protected function setUp(): void + { + $this->adapter = new TenantRecordingAdapter(); + $this->tenant = new Tenant(new Usage($this->adapter), 'p1'); + } + + public function testAddBatchStampsBoundTenantOntoEveryMetric(): void + { + $this->tenant->addBatch([ + ['metric' => 'requests', 'value' => 10, 'tags' => []], + ['metric' => 'bandwidth', 'value' => 20, 'tags' => []], + ], Usage::TYPE_EVENT); + + $this->assertEquals('p1', $this->adapter->lastMetrics[0]['tenant']); + $this->assertEquals('p1', $this->adapter->lastMetrics[1]['tenant']); + } + + public function testFindForwardsBoundTenant(): void + { + $this->tenant->find([], Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testCountForwardsBoundTenant(): void + { + $this->tenant->count([], Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testSumForwardsBoundTenant(): void + { + $this->tenant->sum([], 'value', Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testGetTotalForwardsBoundTenant(): void + { + $this->tenant->getTotal('requests', [], Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testGetTotalBatchForwardsBoundTenant(): void + { + $this->tenant->getTotalBatch(['requests'], [], Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testGetTimeSeriesForwardsBoundTenant(): void + { + $this->tenant->getTimeSeries(['requests'], '1h', '2026-01-01 00:00:00', '2026-01-02 00:00:00'); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testPurgeForwardsBoundTenant(): void + { + $this->tenant->purge([], Usage::TYPE_EVENT); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testFindDailyForwardsBoundTenant(): void + { + $this->tenant->findDaily([]); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testSumDailyForwardsBoundTenant(): void + { + $this->tenant->sumDaily([]); + $this->assertEquals('p1', $this->adapter->lastTenant); + } + + public function testSumDailyBatchForwardsBoundTenant(): void + { + $this->tenant->sumDailyBatch(['requests'], []); + $this->assertEquals('p1', $this->adapter->lastTenant); + } +} diff --git a/tests/Usage/UsageBase.php b/tests/Usage/UsageBase.php index ecd69f8..a01f3d4 100644 --- a/tests/Usage/UsageBase.php +++ b/tests/Usage/UsageBase.php @@ -20,35 +20,35 @@ public function setUp(): void public function tearDown(): void { - $this->usage->purge(); + $this->usage->purge('1'); } public function createUsageMetrics(): void { // Events: additive metrics $this->assertTrue($this->usage->addBatch([ - ['metric' => 'requests', 'value' => 100, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resource' => 'project', 'resourceId' => 'p1']], - ['metric' => 'requests', 'value' => 150, 'tags' => ['region' => 'us-west', 'path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resource' => 'database', 'resourceId' => 'db1']], - ['metric' => 'bandwidth', 'value' => 5000, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', 'resource' => 'bucket', 'resourceId' => 'b1']], + ['tenant' => '1', 'metric' => 'requests', 'value' => 100, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage', 'method' => 'GET', 'status' => '200', 'resource' => 'project', 'resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'requests', 'value' => 150, 'tags' => ['region' => 'us-west', 'path' => '/v1/databases', 'method' => 'POST', 'status' => '201', 'resource' => 'database', 'resourceId' => 'db1']], + ['tenant' => '1', 'metric' => 'bandwidth', 'value' => 5000, 'tags' => ['region' => 'us-east', 'path' => '/v1/storage/files', 'method' => 'POST', 'status' => '201', 'resource' => 'bucket', 'resourceId' => 'b1']], ], Usage::TYPE_EVENT)); // Gauges: point-in-time snapshots $this->assertTrue($this->usage->addBatch([ - ['metric' => 'storage', 'value' => 10000, 'tags' => ['resourceId' => 'p1']], + ['tenant' => '1', 'metric' => 'storage', 'value' => 10000, 'tags' => ['resourceId' => 'p1']], ], Usage::TYPE_GAUGE)); } public function testAddBatchEvent(): void { - $this->usage->purge(); + $this->usage->purge('1'); // addBatch with event type -- values should sum $this->assertTrue($this->usage->addBatch([ - ['metric' => 'add-metric', 'value' => 10, 'tags' => []], - ['metric' => 'add-metric', 'value' => 5, 'tags' => []], + ['tenant' => '1', 'metric' => 'add-metric', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'add-metric', 'value' => 5, 'tags' => []], ], Usage::TYPE_EVENT)); - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['add-metric']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(15, $sum); @@ -56,32 +56,32 @@ public function testAddBatchEvent(): void public function testAddBatchGauge(): void { - $this->usage->purge(); + $this->usage->purge('1'); // addBatch with gauge type $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gauge-metric', 'value' => 100, 'tags' => []], - ['metric' => 'gauge-metric', 'value' => 200, 'tags' => []], + ['tenant' => '1', 'metric' => 'gauge-metric', 'value' => 100, 'tags' => []], + ['tenant' => '1', 'metric' => 'gauge-metric', 'value' => 200, 'tags' => []], ], Usage::TYPE_GAUGE)); // getTotal for gauge returns latest value (argMax) - $total = $this->usage->getTotal('gauge-metric', [], Usage::TYPE_GAUGE); + $total = $this->usage->getTotal('1', 'gauge-metric', [], Usage::TYPE_GAUGE); $this->assertGreaterThanOrEqual(100, $total); } public function testAddBatchWithBatchSize(): void { - $this->usage->purge(); + $this->usage->purge('1'); $metrics = [ - ['metric' => 'batch-requests', 'value' => 100, 'tags' => ['region' => 'eu-west']], - ['metric' => 'batch-requests', 'value' => 150, 'tags' => ['region' => 'eu-east']], - ['metric' => 'batch-bandwidth', 'value' => 3000, 'tags' => ['region' => 'eu-west']], + ['tenant' => '1', 'metric' => 'batch-requests', 'value' => 100, 'tags' => ['region' => 'eu-west']], + ['tenant' => '1', 'metric' => 'batch-requests', 'value' => 150, 'tags' => ['region' => 'eu-east']], + ['tenant' => '1', 'metric' => 'batch-bandwidth', 'value' => 3000, 'tags' => ['region' => 'eu-west']], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 2)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['batch-requests']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); @@ -89,7 +89,7 @@ public function testAddBatchWithBatchSize(): void public function testFind(): void { - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['requests']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); @@ -100,7 +100,7 @@ public function testFindWithTimeRange(): void $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), ], Usage::TYPE_EVENT); @@ -109,7 +109,7 @@ public function testFindWithTimeRange(): void public function testCount(): void { - $count = $this->usage->count([ + $count = $this->usage->count('1', [ Query::equal('metric', ['requests']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, $count); @@ -117,7 +117,7 @@ public function testCount(): void public function testSum(): void { - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['requests']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(250, $sum); // 100 + 150 @@ -125,17 +125,17 @@ public function testSum(): void public function testGetTotal(): void { - $total = $this->usage->getTotal('requests', [], Usage::TYPE_EVENT); + $total = $this->usage->getTotal('1', 'requests', [], Usage::TYPE_EVENT); $this->assertEquals(250, $total); // event: SUM - $total = $this->usage->getTotal('storage', [], Usage::TYPE_GAUGE); + $total = $this->usage->getTotal('1', 'storage', [], Usage::TYPE_GAUGE); $this->assertEquals(10000, $total); // gauge: argMax (latest) } public function testGetTotalBatch(): void { // Event metrics batch - $totals = $this->usage->getTotalBatch(['requests', 'bandwidth'], [], Usage::TYPE_EVENT); + $totals = $this->usage->getTotalBatch('1', ['requests', 'bandwidth'], [], Usage::TYPE_EVENT); $this->assertArrayHasKey('requests', $totals); $this->assertArrayHasKey('bandwidth', $totals); @@ -144,13 +144,13 @@ public function testGetTotalBatch(): void $this->assertEquals(5000, $totals['bandwidth']); // Gauge metrics batch - $gaugeTotals = $this->usage->getTotalBatch(['storage'], [], Usage::TYPE_GAUGE); + $gaugeTotals = $this->usage->getTotalBatch('1', ['storage'], [], Usage::TYPE_GAUGE); $this->assertEquals(10000, $gaugeTotals['storage']); } public function testGetTotalBatchWithMissingMetric(): void { - $totals = $this->usage->getTotalBatch(['requests', 'nonexistent-metric'], [], Usage::TYPE_EVENT); + $totals = $this->usage->getTotalBatch('1', ['requests', 'nonexistent-metric'], [], Usage::TYPE_EVENT); $this->assertEquals(250, $totals['requests']); $this->assertEquals(0, $totals['nonexistent-metric']); @@ -158,7 +158,7 @@ public function testGetTotalBatchWithMissingMetric(): void public function testGetTotalBatchEmpty(): void { - $totals = $this->usage->getTotalBatch([]); + $totals = $this->usage->getTotalBatch('1', []); $this->assertEmpty($totals); } @@ -167,7 +167,7 @@ public function testGetTimeSeries(): void $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d H:i:s'); $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d H:i:s'); - $results = $this->usage->getTimeSeries( + $results = $this->usage->getTimeSeries('1', ['requests'], '1h', $start, @@ -188,7 +188,7 @@ public function testGetTimeSeriesMultipleMetrics(): void $start = (new \DateTime())->modify('-1 day')->format('Y-m-d H:i:s'); $end = (new \DateTime())->modify('+1 day')->format('Y-m-d H:i:s'); - $results = $this->usage->getTimeSeries( + $results = $this->usage->getTimeSeries('1', ['requests', 'bandwidth'], '1d', $start, @@ -205,7 +205,7 @@ public function testGetTimeSeriesMultipleMetrics(): void public function testEqualWithArrayValues(): void { // Test equal query with array of values (IN clause) - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['requests', 'bandwidth']), ], Usage::TYPE_EVENT); @@ -216,7 +216,7 @@ public function testEqualWithArrayValues(): void public function testContainsQuery(): void { // Test contains query with multiple values from events - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::contains('metric', ['requests', 'bandwidth']), ], Usage::TYPE_EVENT); @@ -227,7 +227,7 @@ public function testContainsQuery(): void public function testLessThanEqualQuery(): void { $now = (new \DateTime())->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::lessThanEqual('time', $now), ], Usage::TYPE_EVENT); @@ -237,7 +237,7 @@ public function testLessThanEqualQuery(): void public function testGreaterThanEqualQuery(): void { $past = (new \DateTime())->modify('-24 hours')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::greaterThanEqual('time', $past), ], Usage::TYPE_EVENT); @@ -249,15 +249,15 @@ public function testPurge(): void sleep(2); $this->usage->addBatch([ - ['metric' => 'purge-test', 'value' => 999, 'tags' => []], + ['tenant' => '1', 'metric' => 'purge-test', 'value' => 999, 'tags' => []], ], Usage::TYPE_EVENT); sleep(2); - $status = $this->usage->purge([], Usage::TYPE_EVENT); + $status = $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($status); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['purge-test']), ], Usage::TYPE_EVENT); $this->assertEquals(0, count($results)); @@ -265,27 +265,27 @@ public function testPurge(): void public function testPurgeWithQueries(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'purge-keep', 'value' => 10, 'tags' => []], - ['metric' => 'purge-remove', 'value' => 20, 'tags' => []], + ['tenant' => '1', 'metric' => 'purge-keep', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'purge-remove', 'value' => 20, 'tags' => []], ], Usage::TYPE_EVENT)); // Purge only the 'purge-remove' metric - $status = $this->usage->purge([ + $status = $this->usage->purge('1', [ Query::equal('metric', ['purge-remove']), ], Usage::TYPE_EVENT); $this->assertTrue($status); // 'purge-remove' should be gone - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['purge-remove']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(0, $sum); // 'purge-keep' should still exist - $sum = $this->usage->sum([ + $sum = $this->usage->sum('1', [ Query::equal('metric', ['purge-keep']), ], 'value', Usage::TYPE_EVENT); $this->assertEquals(10, $sum); @@ -293,14 +293,14 @@ public function testPurgeWithQueries(): void public function testWithQueries(): void { - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['requests']), Query::limit(1), ], Usage::TYPE_EVENT); $this->assertEquals(1, count($results)); - $results2 = $this->usage->find([ + $results2 = $this->usage->find('1', [ Query::equal('metric', ['requests']), Query::limit(1), Query::offset(1), @@ -317,14 +317,14 @@ public function testEmptyBatch(): void public function testAddBatchWithTags(): void { $metrics = [ - ['metric' => 'tagged', 'value' => 10, 'tags' => ['region' => 'us-east']], - ['metric' => 'tagged', 'value' => 20, 'tags' => ['region' => 'us-west']], - ['metric' => 'tagged', 'value' => 15, 'tags' => ['region' => 'eu-west']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 10, 'tags' => ['region' => 'us-east']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 20, 'tags' => ['region' => 'us-west']], + ['tenant' => '1', 'metric' => 'tagged', 'value' => 15, 'tags' => ['region' => 'eu-west']], ]; $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT)); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ Query::equal('metric', ['tagged']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); @@ -332,21 +332,21 @@ public function testAddBatchWithTags(): void public function testGroupByIntervalHourly(): void { - $this->usage->purge(); + $this->usage->purge('1'); // Insert metrics spread across the current hour $now = new \DateTime(); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gbi-requests', 'value' => 100, 'tags' => []], - ['metric' => 'gbi-requests', 'value' => 50, 'tags' => []], - ['metric' => 'gbi-bandwidth', 'value' => 3000, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-requests', 'value' => 100, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-requests', 'value' => 50, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-bandwidth', 'value' => 3000, 'tags' => []], ], Usage::TYPE_EVENT)); $start = (clone $now)->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (clone $now)->modify('+1 hour')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), Query::equal('metric', ['gbi-requests']), Query::greaterThanEqual('time', $start), @@ -369,17 +369,17 @@ public function testGroupByIntervalHourly(): void public function testGroupByIntervalDaily(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gbi-daily', 'value' => 200, 'tags' => []], - ['metric' => 'gbi-daily', 'value' => 300, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-daily', 'value' => 200, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-daily', 'value' => 300, 'tags' => []], ], Usage::TYPE_EVENT)); $start = (new \DateTime())->modify('-1 day')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 day')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1d'), Query::equal('metric', ['gbi-daily']), Query::greaterThanEqual('time', $start), @@ -399,18 +399,18 @@ public function testGroupByIntervalDaily(): void public function testGroupByIntervalGauge(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gbi-storage', 'value' => 1000, 'tags' => []], - ['metric' => 'gbi-storage', 'value' => 2000, 'tags' => []], - ['metric' => 'gbi-storage', 'value' => 3000, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-storage', 'value' => 1000, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-storage', 'value' => 2000, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-storage', 'value' => 3000, 'tags' => []], ], Usage::TYPE_GAUGE)); $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), Query::equal('metric', ['gbi-storage']), Query::greaterThanEqual('time', $start), @@ -434,17 +434,17 @@ public function testGroupByIntervalInvalidInterval(): void public function testGroupByIntervalWithLimitOffset(): void { - $this->usage->purge(); + $this->usage->purge('1'); $this->assertTrue($this->usage->addBatch([ - ['metric' => 'gbi-limit', 'value' => 10, 'tags' => []], - ['metric' => 'gbi-limit', 'value' => 20, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-limit', 'value' => 10, 'tags' => []], + ['tenant' => '1', 'metric' => 'gbi-limit', 'value' => 20, 'tags' => []], ], Usage::TYPE_EVENT)); $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s'); $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); - $results = $this->usage->find([ + $results = $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), Query::equal('metric', ['gbi-limit']), Query::greaterThanEqual('time', $start), @@ -460,7 +460,7 @@ public function testGroupByUnknownAttributeThrows(): void $this->expectException(\Exception::class); $this->expectExceptionMessageMatches("/groupBy attribute 'not_a_column'/"); - $this->usage->find([ + $this->usage->find('1', [ UsageQuery::groupByInterval('time', '1h'), UsageQuery::groupBy('not_a_column'), Query::equal('metric', ['gbi-requests']), @@ -472,7 +472,7 @@ public function testGroupByWithoutGroupByIntervalReturnsDimOnlyAggregate(): void // Without groupByInterval the result is a flat aggregate per // (metric, …dims) — no time bucketing, ordered by value DESC by // default (top-N table semantics). - $rows = $this->usage->find([ + $rows = $this->usage->find('1', [ UsageQuery::groupBy('service'), Query::equal('metric', ['gbi-requests']), ], Usage::TYPE_EVENT); From 7942cdc1b041610a934dec6925a880e1d5abbe77 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:02:46 +0100 Subject: [PATCH 3/9] fix(usage): tenant-correct purge + satisfy phpstan/pint - ClickHouse: extract sumDailyScoped() so routedSum() stops calling the now-tenant-first sumDaily() with stale args (caught by phpstan). - ClickHouse purge: keep the tenant out of the daily-forwarding decision and apply it only as a scope on the resulting delete, so a purge by a daily-incompatible/raw-only filter no longer wipes the tenant's whole rollup. - Database: tenant key is required per the addBatch shape, drop dead guard. - Tests: stamp tenant on remaining multiline addBatch rows, tighten RecordingAdapter types, pint formatting. Full suite green (236 tests) against mariadb + clickhouse; phpstan + pint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Usage/Adapter/ClickHouse.php | 35 ++++++++++++++++++++------ src/Usage/Adapter/Database.php | 3 +-- tests/Benchmark/GaugesBench.php | 3 ++- tests/Usage/AccumulatorTest.php | 4 +-- tests/Usage/Adapter/ClickHouseTest.php | 5 ++++ tests/Usage/Adapter/DatabaseTest.php | 2 ++ tests/Usage/UsageBase.php | 6 +++-- 7 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index d866b13..42b51c4 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -1811,7 +1811,7 @@ private function routedSum(array $queries, string $operation): int $this->recordRoute($operation, $plan, $route); if ($route === 'daily') { - $total = $this->sumDaily($this->translateInclusiveMidnightForDaily($queries), 'value'); + $total = $this->sumDailyScoped($this->translateInclusiveMidnightForDaily($queries), 'value'); $this->maybeDualRead($queries, $route, $plan, $total); return $total; } @@ -2555,8 +2555,20 @@ public function sumDaily(string $tenant, array $queries = [], string $attribute { $this->setOperationContext('sumDaily()'); - $queries = $this->scopeToTenant($tenant, $queries); + return $this->sumDailyScoped($this->scopeToTenant($tenant, $queries), $attribute); + } + /** + * Sum the daily table for an already tenant-scoped query list. + * + * Internal callers (e.g. routedSum) thread the tenant filter in via the + * query list, so they call this directly to avoid re-scoping. + * + * @param array $queries + * @throws Exception + */ + private function sumDailyScoped(array $queries, string $attribute = 'value'): int + { $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); $this->validateDailyAttributeName($attribute); $escapedAttribute = $this->escapeIdentifier($attribute); @@ -3699,7 +3711,10 @@ public function purge(string $tenant, array $queries = [], ?string $type = null) { $this->setOperationContext('purge()'); - $queries = $this->scopeToTenant($tenant, $queries); + // Tenant scopes the delete, but the daily-table forwarding decision + // (below) must reason about the caller's own filters — so keep the + // unscoped queries around and only fold the tenant into the WHERE. + $scopedQueries = $this->scopeToTenant($tenant, $queries); $typesToPurge = []; if ($type === Usage::TYPE_EVENT || $type === null) { @@ -3713,7 +3728,7 @@ public function purge(string $tenant, array $queries = [], ?string $type = null) $tableName = $this->getTableForType($purgeType); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - $parsed = $this->parseQueries($queries, $purgeType); + $parsed = $this->parseQueries($scopedQueries, $purgeType); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $whereClause = $whereData['clause']; $params = $whereData['params']; @@ -3726,7 +3741,7 @@ public function purge(string $tenant, array $queries = [], ?string $type = null) $this->query($sql, $params); if ($purgeType === Usage::TYPE_EVENT) { - $this->purgeDaily($queries); + $this->purgeDaily($tenant, $queries); } } @@ -3778,10 +3793,11 @@ private function buildStaleRollupPurgeQueries(array $queries, array $safeAttribu * unrelated metrics. The next ingest cycle will overwrite any rows * the caller's raw-table purge left stale. * - * @param array $queries + * @param array $queries The caller's filters (tenant-free); the + * tenant is applied as a scope on the resulting delete. * @throws Exception */ - private function purgeDaily(array $queries): void + private function purgeDaily(string $tenant, array $queries): void { $safeAttributes = array_values(array_filter( self::DAILY_COLUMNS, @@ -3812,6 +3828,11 @@ private function purgeDaily(array $queries): void return; } + // Scope the delete to the tenant only after the compatibility decision, + // so the tenant filter never makes an otherwise-imprecise purge look + // safe to forward to the rollup. + $dailyQueries = $this->scopeToTenant($tenant, $dailyQueries); + $dailyTable = $this->buildTableReference($this->getEventsDailyTableName()); $parsed = $this->parseQueries($dailyQueries, Usage::TYPE_EVENT); diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index f76859b..c4ad91d 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -158,8 +158,7 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b 'time' => (new \DateTime())->format('Y-m-d H:i:s.v'), ], $columns); - $tenant = $metric['tenant'] ?? null; - $entries[] = ['tenant' => is_string($tenant) ? $tenant : null, 'doc' => new Document($docData)]; + $entries[] = ['tenant' => $metric['tenant'], 'doc' => new Document($docData)]; } foreach (array_chunk($entries, max(1, $batchSize)) as $chunk) { diff --git a/tests/Benchmark/GaugesBench.php b/tests/Benchmark/GaugesBench.php index 49bcf01..f84eee2 100644 --- a/tests/Benchmark/GaugesBench.php +++ b/tests/Benchmark/GaugesBench.php @@ -27,7 +27,8 @@ public function testBenchmarks(): void $this->runBench('bench_gauges_latest_in_window', function (string $queryId) use ($start30d, $endPartial): void { $this->adapter->setNextQueryId($queryId); - $this->usage->getTotal($this->tenant, + $this->usage->getTotal( + $this->tenant, $this->metric, [ Query::greaterThanEqual('time', $start30d), diff --git a/tests/Usage/AccumulatorTest.php b/tests/Usage/AccumulatorTest.php index 98deef7..1e80e59 100644 --- a/tests/Usage/AccumulatorTest.php +++ b/tests/Usage/AccumulatorTest.php @@ -14,7 +14,7 @@ */ class RecordingAdapter extends Adapter { - /** @var array, type: string}> */ + /** @var array>, type: string}> */ public array $batches = []; public bool $succeed = true; @@ -37,7 +37,7 @@ public function setup(): void } /** - * @param array $metrics + * @param array> $metrics */ public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool { diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index aa76f37..aa7e031 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -133,6 +133,7 @@ public function testLargeBatchWithSmallBatchSize(): void $metrics = []; for ($i = 0; $i < 100; $i++) { $metrics[] = [ + 'tenant' => '1', 'metric' => 'large-batch-metric', 'value' => $i, 'tags' => ['resourceId' => (string) $i], @@ -226,6 +227,7 @@ public function testEventColumnsExtractedFromTags(): void $metrics = [ [ + 'tenant' => '1', 'metric' => 'event-cols-test', 'value' => 42, 'tags' => [ @@ -302,6 +304,7 @@ public function testGaugeColumnsRoundTrip(): void $this->assertTrue($this->usage->addBatch([ [ + 'tenant' => '1', 'metric' => 'gauge-cols-test', 'value' => 500, 'tags' => [ @@ -526,6 +529,7 @@ public function testBatchSizeAtMaximum(): void $metrics = []; for ($i = 0; $i < 500; $i++) { $metrics[] = [ + 'tenant' => '1', 'metric' => 'boundary-test', 'value' => 1, 'tags' => [], @@ -566,6 +570,7 @@ public function testDefaultBatchSize(): void $metrics = []; for ($i = 0; $i < 50; $i++) { $metrics[] = [ + 'tenant' => '1', 'metric' => 'default-batch-test', 'value' => 1, 'tags' => [], diff --git a/tests/Usage/Adapter/DatabaseTest.php b/tests/Usage/Adapter/DatabaseTest.php index 8087043..5c0ee1e 100644 --- a/tests/Usage/Adapter/DatabaseTest.php +++ b/tests/Usage/Adapter/DatabaseTest.php @@ -68,6 +68,7 @@ public function testEventColumnsExtractedFromTags(): void $this->assertTrue($this->usage->addBatch([ [ + 'tenant' => '1', 'metric' => 'event-cols-db', 'value' => 42, 'tags' => [ @@ -122,6 +123,7 @@ public function testGaugeColumnsRoundTrip(): void $this->assertTrue($this->usage->addBatch([ [ + 'tenant' => '1', 'metric' => 'gauge-cols-db', 'value' => 500, 'tags' => [ diff --git a/tests/Usage/UsageBase.php b/tests/Usage/UsageBase.php index a01f3d4..a104b7e 100644 --- a/tests/Usage/UsageBase.php +++ b/tests/Usage/UsageBase.php @@ -167,7 +167,8 @@ public function testGetTimeSeries(): void $start = (new \DateTime())->modify('-1 hour')->format('Y-m-d H:i:s'); $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d H:i:s'); - $results = $this->usage->getTimeSeries('1', + $results = $this->usage->getTimeSeries( + '1', ['requests'], '1h', $start, @@ -188,7 +189,8 @@ public function testGetTimeSeriesMultipleMetrics(): void $start = (new \DateTime())->modify('-1 day')->format('Y-m-d H:i:s'); $end = (new \DateTime())->modify('+1 day')->format('Y-m-d H:i:s'); - $results = $this->usage->getTimeSeries('1', + $results = $this->usage->getTimeSeries( + '1', ['requests', 'bandwidth'], '1d', $start, From 0404988bd2fd04b40f6a9228a2eb1f011c4c5ea0 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:43:06 +0100 Subject: [PATCH 4/9] refactor(clickhouse): bake tenant into parseQueries, drop scopeToTenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make tenant scoping unavoidable instead of a step callers must remember to compose. parseQueries() — the single chokepoint every read/delete WHERE is built from — now takes the tenant as a required first argument and prepends the tenant filter itself (shared-tables mode). There is no longer any way to produce a WHERE clause that isn't tenant-scoped, and PHP/phpstan flag any caller that omits it. scopeToTenant() is removed; the tenant is threaded down to parseQueries through the private read helpers. purge keeps reasoning about the caller's own (tenant-free) filters for the daily-rollup forwarding decision. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Usage/Adapter/ClickHouse.php | 169 +++++++----------- .../Adapter/ClickHouseDimRoutingTest.php | 2 +- tests/Usage/Adapter/ClickHouseRoutingTest.php | 2 +- 3 files changed, 70 insertions(+), 103 deletions(-) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 42b51c4..f5e6297 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -369,27 +369,6 @@ private function escapeIdentifier(string $identifier): string return '`' . str_replace('`', '``', $identifier) . '`'; } - /** - * Prepend a tenant filter to the query list (shared-tables mode only). - * - * In non-shared mode the tables have no tenant column, so the tenant is - * ignored. Threading the scope in as a normal query lets the existing - * parse/where pipeline bind and filter it like any other column. - * - * @param array $queries - * @return array - */ - private function scopeToTenant(string $tenant, array $queries): array - { - if (!$this->sharedTables) { - return $queries; - } - - array_unshift($queries, Query::equal('tenant', [$tenant])); - - return $queries; - } - /** * Get the base table name with namespace prefix. * @@ -1369,10 +1348,8 @@ public function find(string $tenant, array $queries = [], ?string $type = null): { $this->setOperationContext('find()'); - $queries = $this->scopeToTenant($tenant, $queries); - if ($type !== null) { - return $this->findFromTable($queries, $type); + return $this->findFromTable($tenant, $queries, $type); } // Cursor pagination is per-table — paginating across both events and @@ -1397,10 +1374,10 @@ public function find(string $tenant, array $queries = [], ?string $type = null): // requested limit. Tables whose schema doesn't support every filter // attribute (e.g. `path` on a gauge query) are skipped. $events = $this->queriesMatchType($queries, Usage::TYPE_EVENT) - ? $this->findFromTable($queries, Usage::TYPE_EVENT) + ? $this->findFromTable($tenant, $queries, Usage::TYPE_EVENT) : []; $gauges = $this->queriesMatchType($queries, Usage::TYPE_GAUGE) - ? $this->findFromTable($queries, Usage::TYPE_GAUGE) + ? $this->findFromTable($tenant, $queries, Usage::TYPE_GAUGE) : []; $merged = array_merge($events, $gauges); @@ -1457,12 +1434,12 @@ private function queriesMatchType(array $queries, string $type): bool * @return array * @throws Exception */ - private function findFromTable(array $queries, string $type): array + private function findFromTable(string $tenant, array $queries, string $type): array { $tableName = $this->getTableForType($type); $fromTable = $this->buildTableReference($tableName); - $parsed = $this->parseQueries($queries, $type); + $parsed = $this->parseQueries($tenant, $queries, $type); // Cursor pagination is incompatible with time-bucketed aggregation — // aggregated rows have no stable identity to anchor a keyset cursor on. @@ -1699,10 +1676,8 @@ public function count(string $tenant, array $queries = [], ?string $type = null, { $this->setOperationContext('count()'); - $queries = $this->scopeToTenant($tenant, $queries); - if ($type !== null) { - return $this->countFromTable($queries, $type, $max); + return $this->countFromTable($tenant, $queries, $type, $max); } // Count from both tables. Each per-table count is independently @@ -1710,10 +1685,10 @@ public function count(string $tenant, array $queries = [], ?string $type = null, // Cap the combined total at $max in PHP to honour the contract. // Skip a table when its schema can't satisfy every filter attribute. $events = $this->queriesMatchType($queries, Usage::TYPE_EVENT) - ? $this->countFromTable($queries, Usage::TYPE_EVENT, $max) + ? $this->countFromTable($tenant, $queries, Usage::TYPE_EVENT, $max) : 0; $gauges = $this->queriesMatchType($queries, Usage::TYPE_GAUGE) - ? $this->countFromTable($queries, Usage::TYPE_GAUGE, $max) + ? $this->countFromTable($tenant, $queries, Usage::TYPE_GAUGE, $max) : 0; $total = $events + $gauges; @@ -1734,12 +1709,12 @@ public function count(string $tenant, array $queries = [], ?string $type = null, * @return int * @throws Exception */ - private function countFromTable(array $queries, string $type, ?int $max = null): int + private function countFromTable(string $tenant, array $queries, string $type, ?int $max = null): int { $tableName = $this->getTableForType($type); $fromTable = $this->buildTableReference($tableName); - $parsed = $this->parseQueries($queries, $type); + $parsed = $this->parseQueries($tenant, $queries, $type); $params = $parsed['params']; unset($params['limit'], $params['offset']); @@ -1788,13 +1763,11 @@ public function sum(string $tenant, array $queries = [], string $attribute = 'va { $this->setOperationContext('sum()'); - $queries = $this->scopeToTenant($tenant, $queries); - if ($type === Usage::TYPE_EVENT && $attribute === 'value') { - return $this->routedSum($queries, 'sum'); + return $this->routedSum($tenant, $queries, 'sum'); } - return $this->sumFromTable($queries, $attribute, $type); + return $this->sumFromTable($tenant, $queries, $attribute, $type); } /** @@ -1804,24 +1777,24 @@ public function sum(string $tenant, array $queries = [], string $attribute = 'va * * @param array $queries */ - private function routedSum(array $queries, string $operation): int + private function routedSum(string $tenant, array $queries, string $operation): int { $plan = $this->extractRoutingPlan($queries); $route = $this->selectAggregateSource($plan); $this->recordRoute($operation, $plan, $route); if ($route === 'daily') { - $total = $this->sumDailyScoped($this->translateInclusiveMidnightForDaily($queries), 'value'); - $this->maybeDualRead($queries, $route, $plan, $total); + $total = $this->sumDailyTotal($tenant, $this->translateInclusiveMidnightForDaily($queries), 'value'); + $this->maybeDualRead($tenant, $queries, $route, $plan, $total); return $total; } if ($route === 'hybrid') { - $total = $this->sumHybridDailyAndRaw($queries, $plan); - $this->maybeDualRead($queries, $route, $plan, $total); + $total = $this->sumHybridDailyAndRaw($tenant, $queries, $plan); + $this->maybeDualRead($tenant, $queries, $route, $plan, $total); return $total; } - return $this->sumFromTable($queries, 'value', Usage::TYPE_EVENT); + return $this->sumFromTable($tenant, $queries, 'value', Usage::TYPE_EVENT); } /** @@ -2368,7 +2341,7 @@ private function appendRouteLogEntry(array $entry): void * @param string $route * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string, orderColumns?: array, hasCursor?: bool} $plan */ - private function maybeDualRead(array $queries, string $route, array $plan, int $rolledTotal): void + private function maybeDualRead(string $tenant, array $queries, string $route, array $plan, int $rolledTotal): void { if ($this->dualReadSampleRate <= 0.0) { return; @@ -2378,7 +2351,7 @@ private function maybeDualRead(array $queries, string $route, array $plan, int $ } try { - $rawTotal = $this->sumFromTable($queries, 'value', Usage::TYPE_EVENT); + $rawTotal = $this->sumFromTable($tenant, $queries, 'value', Usage::TYPE_EVENT); } catch (Throwable $e) { return; } @@ -2410,7 +2383,7 @@ private function maybeDualRead(array $queries, string $route, array $plan, int $ * @param array $queries * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string} $plan */ - private function sumHybridDailyAndRaw(array $queries, array $plan): int + private function sumHybridDailyAndRaw(string $tenant, array $queries, array $plan): int { $startOfToday = (new DateTime('today', new DateTimeZone('UTC')))->format('Y-m-d H:i:s.v'); @@ -2418,9 +2391,9 @@ private function sumHybridDailyAndRaw(array $queries, array $plan): int $eventsTable = $this->buildTableReference($this->getEventsTableName()); $split = $this->splitTimeQueries($queries); - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); $dailyParsed = $this->prefixParsedParams( - $this->parseQueries($split['nonTime'], Usage::TYPE_EVENT), + $this->parseQueries($tenant, $split['nonTime'], Usage::TYPE_EVENT), 'd_' ); @@ -2464,7 +2437,7 @@ private function sumHybridDailyAndRaw(array $queries, array $plan): int * @return int * @throws Exception */ - private function sumFromTable(array $queries, string $attribute, string $type): int + private function sumFromTable(string $tenant, array $queries, string $attribute, string $type): int { $tableName = $this->getTableForType($type); $fromTable = $this->buildTableReference($tableName); @@ -2472,7 +2445,7 @@ private function sumFromTable(array $queries, string $attribute, string $type): $this->validateAttributeName($attribute, $type); $escapedAttribute = $this->escapeIdentifier($attribute); - $parsed = $this->parseQueries($queries, $type); + $parsed = $this->parseQueries($tenant, $queries, $type); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $whereClause = $whereData['clause']; @@ -2505,8 +2478,6 @@ public function findDaily(string $tenant, array $queries = []): array { $this->setOperationContext('findDaily()'); - $queries = $this->scopeToTenant($tenant, $queries); - $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); foreach ($queries as $query) { @@ -2515,7 +2486,7 @@ public function findDaily(string $tenant, array $queries = []): array $this->validateDailyAttributeName($attr); } } - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $groupByColumns = $this->sharedTables ? ['tenant'] : []; @@ -2555,19 +2526,17 @@ public function sumDaily(string $tenant, array $queries = [], string $attribute { $this->setOperationContext('sumDaily()'); - return $this->sumDailyScoped($this->scopeToTenant($tenant, $queries), $attribute); + return $this->sumDailyTotal($tenant, $queries, $attribute); } /** - * Sum the daily table for an already tenant-scoped query list. - * - * Internal callers (e.g. routedSum) thread the tenant filter in via the - * query list, so they call this directly to avoid re-scoping. + * Sum the daily table. Split out from sumDaily() so internal callers + * (e.g. routedSum) can reuse it without re-setting the operation context. * * @param array $queries * @throws Exception */ - private function sumDailyScoped(array $queries, string $attribute = 'value'): int + private function sumDailyTotal(string $tenant, array $queries, string $attribute = 'value'): int { $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); $this->validateDailyAttributeName($attribute); @@ -2579,7 +2548,7 @@ private function sumDailyScoped(array $queries, string $attribute = 'value'): in $this->validateDailyAttributeName($attr); } } - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $sql = "SELECT sum({$escapedAttribute}) as total FROM {$fromTable}{$whereData['clause']} FORMAT JSON"; @@ -2606,8 +2575,6 @@ public function sumDailyBatch(string $tenant, array $metrics, array $queries = [ $this->setOperationContext('sumDailyBatch()'); - $queries = $this->scopeToTenant($tenant, $queries); - foreach ($queries as $query) { $attr = $query->getAttribute(); if (!empty($attr)) { @@ -2629,7 +2596,7 @@ public function sumDailyBatch(string $tenant, array $metrics, array $queries = [ } $metricInClause = implode(', ', $metricPlaceholders); - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); $params = array_merge($metricParams, $parsed['params']); $whereData = $this->buildWhereClause($parsed['filters'], $params); @@ -2689,8 +2656,6 @@ public function getTimeSeries(string $tenant, array $metrics, string $interval, $this->setOperationContext('getTimeSeries()'); - $queries = $this->scopeToTenant($tenant, $queries); - // Initialize result structure $output = []; foreach ($metrics as $metric) { @@ -2713,7 +2678,7 @@ public function getTimeSeries(string $tenant, array $metrics, string $interval, continue; } - $typeResult = $this->getTimeSeriesFromTable($metrics, $interval, $startDate, $endDate, $queries, $queryType); + $typeResult = $this->getTimeSeriesFromTable($tenant, $metrics, $interval, $startDate, $endDate, $queries, $queryType); // Merge results foreach ($typeResult as $metricName => $metricData) { @@ -2757,7 +2722,7 @@ public function getTimeSeries(string $tenant, array $metrics, string $interval, * @return array}> * @throws Exception */ - private function getTimeSeriesFromTable(array $metrics, string $interval, string $startDate, string $endDate, array $queries, string $type): array + private function getTimeSeriesFromTable(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries, string $type): array { $timeFunction = self::INTERVAL_FUNCTIONS[$interval]; $tableName = $this->getTableForType($type); @@ -2774,15 +2739,15 @@ private function getTimeSeriesFromTable(array $metrics, string $interval, string $metricInClause = implode(', ', $metricPlaceholders); - // Build additional WHERE conditions from queries - $parsed = $this->parseQueries($queries, $type); + // Build additional WHERE conditions from queries (tenant baked in) + $parsed = $this->parseQueries($tenant, $queries, $type); $additionalFilters = $parsed['filters']; $params = array_merge($metricParams, $parsed['params']); $params['start_date'] = $this->formatDateTime($startDate); $params['end_date'] = $this->formatDateTime($endDate); - // Tenant scoping arrives as a normal query filter (see scopeToTenant). + // Tenant scoping is already folded into $additionalFilters by parseQueries(). $additionalWhere = ''; if (!empty($additionalFilters)) { $additionalWhere = ' AND ' . implode(' AND ', $additionalFilters); @@ -2898,19 +2863,17 @@ public function getTotal(string $tenant, string $metric, array $queries = [], ?s { $this->setOperationContext('getTotal()'); - $queries = $this->scopeToTenant($tenant, $queries); - if ($type === Usage::TYPE_EVENT) { - return $this->getTotalFromEvents($metric, $queries); + return $this->getTotalFromEvents($tenant, $metric, $queries); } if ($type === Usage::TYPE_GAUGE) { - return $this->getTotalFromGauges($metric, $queries); + return $this->getTotalFromGauges($tenant, $metric, $queries); } // Query both tables — event uses SUM, gauge uses argMax - $eventTotal = $this->getTotalFromEvents($metric, $queries); - $gaugeTotal = $this->getTotalFromGauges($metric, $queries); + $eventTotal = $this->getTotalFromEvents($tenant, $metric, $queries); + $gaugeTotal = $this->getTotalFromGauges($tenant, $metric, $queries); if ($eventTotal > 0 && $gaugeTotal > 0) { throw new Exception( @@ -2931,10 +2894,10 @@ public function getTotal(string $tenant, string $metric, array $queries = [], ?s * @return int * @throws Exception */ - private function getTotalFromEvents(string $metric, array $queries): int + private function getTotalFromEvents(string $tenant, string $metric, array $queries): int { $queries[] = Query::equal('metric', [$metric]); - return $this->routedSum($queries, 'getTotal'); + return $this->routedSum($tenant, $queries, 'getTotal'); } /** @@ -2946,7 +2909,7 @@ private function getTotalFromEvents(string $metric, array $queries): int * @return int * @throws Exception */ - private function getTotalFromGauges(string $metric, array $queries): int + private function getTotalFromGauges(string $tenant, string $metric, array $queries): int { $queries[] = Query::equal('metric', [$metric]); @@ -2956,7 +2919,7 @@ private function getTotalFromGauges(string $metric, array $queries): int $tableName = $this->getGaugesTableName(); $fromTable = $this->buildTableReference($tableName); - $parsed = $this->parseQueries($queries, Usage::TYPE_GAUGE); + $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_GAUGE); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $sql = " @@ -2998,8 +2961,6 @@ public function getTotalBatch(string $tenant, array $metrics, array $queries = [ $this->setOperationContext('getTotalBatch()'); - $queries = $this->scopeToTenant($tenant, $queries); - // Initialize all metrics to 0 $totals = \array_fill_keys($metrics, 0); @@ -3028,7 +2989,7 @@ public function getTotalBatch(string $tenant, array $metrics, array $queries = [ } $metricInClause = implode(', ', $metricPlaceholders); - $parsed = $this->parseQueries($queries, $queryType); + $parsed = $this->parseQueries($tenant, $queries, $queryType); $params = array_merge($metricParams, $parsed['params']); $whereData = $this->buildWhereClause($parsed['filters'], $params); @@ -3086,10 +3047,11 @@ public function getTotalBatch(string $tenant, array $metrics, array $queries = [ } /** - * Build WHERE clause from filters with optional tenant filtering. + * Build a WHERE clause from already-parsed filters. * - * Tenant scoping is threaded in as a normal query filter (see - * scopeToTenant()), so it arrives here already folded into $filters. + * Tenant scoping is baked into parseQueries(), so by the time filters + * reach here they are already tenant-scoped — there is no separate step + * to forget. * * @param array $filters * @param array $params @@ -3317,13 +3279,24 @@ private function buildOrderBySql(array $orderAttributes, bool $flip = false): ar /** * Parse Query objects into SQL clauses. * + * Tenant scoping is baked in here rather than left to callers: in + * shared-tables mode a `tenant = …` filter is prepended before parsing, + * so there is no way to produce a WHERE clause that isn't tenant-scoped. + * Every read/delete path funnels through this method, which is why it + * takes the tenant as a required first argument. + * + * @param string $tenant Tenant scope (shared-tables mode) * @param array $queries * @param string $type 'event' or 'gauge' — used for attribute validation * @return array{filters: array, params: array, orderBy?: array, orderAttributes?: array, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array, cursor?: array, cursorDirection?: string} * @throws Exception */ - private function parseQueries(array $queries, string $type = 'event'): array + private function parseQueries(string $tenant, array $queries, string $type = 'event'): array { + if ($this->sharedTables) { + array_unshift($queries, Query::equal('tenant', [$tenant])); + } + $filters = []; $params = []; $orderBy = []; @@ -3711,11 +3684,6 @@ public function purge(string $tenant, array $queries = [], ?string $type = null) { $this->setOperationContext('purge()'); - // Tenant scopes the delete, but the daily-table forwarding decision - // (below) must reason about the caller's own filters — so keep the - // unscoped queries around and only fold the tenant into the WHERE. - $scopedQueries = $this->scopeToTenant($tenant, $queries); - $typesToPurge = []; if ($type === Usage::TYPE_EVENT || $type === null) { $typesToPurge[] = Usage::TYPE_EVENT; @@ -3728,7 +3696,7 @@ public function purge(string $tenant, array $queries = [], ?string $type = null) $tableName = $this->getTableForType($purgeType); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - $parsed = $this->parseQueries($scopedQueries, $purgeType); + $parsed = $this->parseQueries($tenant, $queries, $purgeType); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $whereClause = $whereData['clause']; $params = $whereData['params']; @@ -3828,14 +3796,13 @@ private function purgeDaily(string $tenant, array $queries): void return; } - // Scope the delete to the tenant only after the compatibility decision, - // so the tenant filter never makes an otherwise-imprecise purge look - // safe to forward to the rollup. - $dailyQueries = $this->scopeToTenant($tenant, $dailyQueries); - + // parseQueries() folds the tenant into the WHERE. The compatibility + // decision above deliberately runs on the caller's tenant-free filters, + // so the tenant scope can never make an imprecise purge look safe to + // forward to the rollup. $dailyTable = $this->buildTableReference($this->getEventsDailyTableName()); - $parsed = $this->parseQueries($dailyQueries, Usage::TYPE_EVENT); + $parsed = $this->parseQueries($tenant, $dailyQueries, Usage::TYPE_EVENT); $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $whereClause = $whereData['clause']; diff --git a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php index cef9ed3..b3b6912 100644 --- a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php @@ -227,7 +227,7 @@ private function rawTotal(string $start, string $end): int $reflection = new ReflectionClass($this->adapter); $sumFromTable = $reflection->getMethod('sumFromTable'); $sumFromTable->setAccessible(true); - $result = $sumFromTable->invoke($this->adapter, [ + $result = $sumFromTable->invoke($this->adapter, '1', [ Query::equal('metric', [$this->metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), diff --git a/tests/Usage/Adapter/ClickHouseRoutingTest.php b/tests/Usage/Adapter/ClickHouseRoutingTest.php index a3b3b29..862eee4 100644 --- a/tests/Usage/Adapter/ClickHouseRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseRoutingTest.php @@ -399,7 +399,7 @@ private function sumRaw(string $metric, string $start, string $end): int $reflection = new ReflectionClass($this->adapter); $sumFromTable = $reflection->getMethod('sumFromTable'); $sumFromTable->setAccessible(true); - $result = $sumFromTable->invoke($this->adapter, [ + $result = $sumFromTable->invoke($this->adapter, '1', [ Query::equal('metric', [$metric]), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), From 3aeaa4fa65e7310df00b857b3a9cb31f93492f6f Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:07:29 +0100 Subject: [PATCH 5/9] refactor(usage): Accumulator takes Usage, not Adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flush buffered metrics through the Usage facade instead of holding the adapter directly — the accumulator is a higher-level buffer over Usage. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- src/Usage/Accumulator.php | 14 +++++++------- tests/Usage/AccumulatorTest.php | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 14a27f6..9591632 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Event-specific columns (see `Metric::EVENT_COLUMNS`): `path`, `method`, `status` use Utopia\Usage\Accumulator; // Buffer metrics in memory and flush them in batch -$accumulator = new Accumulator($adapter); +$accumulator = new Accumulator($usage); // Collect events — tenant first; values accumulate in-memory (summed per tenant+metric) $accumulator->collect('project_123', 'bandwidth', 5000, Usage::TYPE_EVENT, [ diff --git a/src/Usage/Accumulator.php b/src/Usage/Accumulator.php index d544e50..05ffe54 100644 --- a/src/Usage/Accumulator.php +++ b/src/Usage/Accumulator.php @@ -5,7 +5,7 @@ /** * In-memory metric accumulator. * - * Buffers collect() calls and flushes them to an adapter in batches. + * Buffers collect() calls and flushes them to a Usage instance in batches. * Events are summed per metric+tags; gauges use last-write-wins. * * The accumulator exposes raw signals — count() and elapsedSeconds() — so @@ -13,7 +13,7 @@ */ class Accumulator { - private Adapter $adapter; + private Usage $usage; /** * In-memory buffer for metrics. @@ -29,11 +29,11 @@ class Accumulator private float $flushedAt; /** - * @param Adapter $adapter The adapter to flush buffered metrics to + * @param Usage $usage The Usage instance to flush buffered metrics to */ - public function __construct(Adapter $adapter) + public function __construct(Usage $usage) { - $this->adapter = $adapter; + $this->usage = $usage; $this->flushedAt = microtime(true); } @@ -126,7 +126,7 @@ public function flush(): bool // Flush events — clear buffer entries only on success. if (!empty($events)) { - if ($this->adapter->addBatch($events, Usage::TYPE_EVENT)) { + if ($this->usage->addBatch($events, Usage::TYPE_EVENT)) { foreach ($eventKeys as $key) { unset($this->buffer[$key]); } @@ -137,7 +137,7 @@ public function flush(): bool // Flush gauges — clear buffer entries only on success. if (!empty($gauges)) { - if ($this->adapter->addBatch($gauges, Usage::TYPE_GAUGE)) { + if ($this->usage->addBatch($gauges, Usage::TYPE_GAUGE)) { foreach ($gaugeKeys as $key) { unset($this->buffer[$key]); } diff --git a/tests/Usage/AccumulatorTest.php b/tests/Usage/AccumulatorTest.php index 1e80e59..e056faa 100644 --- a/tests/Usage/AccumulatorTest.php +++ b/tests/Usage/AccumulatorTest.php @@ -122,7 +122,7 @@ class AccumulatorTest extends TestCase protected function setUp(): void { $this->adapter = new RecordingAdapter(); - $this->accumulator = new Accumulator($this->adapter); + $this->accumulator = new Accumulator(new Usage($this->adapter)); } public function testEventsSumByKey(): void From c0cf170fcf399d37f07025443ee0145d55830a41 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:24:43 +0100 Subject: [PATCH 6/9] =?UTF-8?q?fix(usage):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20collision-safe=20buffer=20key,=20retry=20timing,=20tenant=20?= =?UTF-8?q?guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Accumulator: hash the full (tenant, metric, type, sorted-tags) identity so ambiguous splits like tenant "a"/metric "b:c" vs "a:b"/"c" can't collide, and tag insertion order no longer splits entries. Reject empty tenant. - Accumulator: only restart the flush timer when the buffer fully drains, so a partial-failure retry stays overdue instead of waiting a fresh interval. - ClickHouse: reject a shared-tables batch row without a tenant — it would be invisible to every tenant-scoped read. - Database: document that the adapter owns the injected db's tenant (no set/restore dance; hand it a db dedicated to usage). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Usage/Accumulator.php | 19 ++++++++++++++++--- src/Usage/Adapter/ClickHouse.php | 15 ++++++++++----- src/Usage/Adapter/Database.php | 6 ++++++ tests/Usage/AccumulatorTest.php | 28 ++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/Usage/Accumulator.php b/src/Usage/Accumulator.php index 05ffe54..25d09ca 100644 --- a/src/Usage/Accumulator.php +++ b/src/Usage/Accumulator.php @@ -53,6 +53,9 @@ public function __construct(Usage $usage) */ public function collect(string $tenant, string $metric, int $value, string $type, array $tags = []): self { + if (empty($tenant)) { + throw new \InvalidArgumentException('Tenant cannot be empty'); + } if (empty($metric)) { throw new \InvalidArgumentException('Metric name cannot be empty'); } @@ -63,8 +66,13 @@ public function collect(string $tenant, string $metric, int $value, string $type throw new \InvalidArgumentException("Invalid metric type '{$type}'. Allowed: " . Usage::TYPE_EVENT . ', ' . Usage::TYPE_GAUGE); } - $tagsHash = !empty($tags) ? md5(json_encode($tags, JSON_THROW_ON_ERROR)) : ''; - $key = $tenant . ':' . $metric . ':' . $type . ':' . $tagsHash; + // Hash the full identity so distinct (tenant, metric, type, tags) + // tuples never collide on the key — a raw `:`-join would let + // e.g. tenant "a"/metric "b:c" and tenant "a:b"/metric "c" share one + // entry. Tags are sorted first so key order doesn't matter. + $canonicalTags = $tags; + ksort($canonicalTags); + $key = md5(json_encode([$tenant, $metric, $type, $canonicalTags], JSON_THROW_ON_ERROR)); if ($type === Usage::TYPE_EVENT && isset($this->buffer[$key])) { // Additive: sum values for the same tenant + metric + tags combination @@ -146,7 +154,12 @@ public function flush(): bool } } - $this->flushedAt = microtime(true); + // Only restart the timer when nothing is left pending. On a partial + // failure the retained entries keep aging so elapsedSeconds() reflects + // how overdue they are instead of resetting to a fresh interval. + if (empty($this->buffer)) { + $this->flushedAt = microtime(true); + } return $overallResult; } diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index f5e6297..831f965 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -1239,12 +1239,17 @@ private function validateMetricsBatch(array $metrics, string $type): void $tags = $metricData['tags'] ?? []; $this->validateMetricData($metric, $value, $type, $tags, $index); - if (array_key_exists('tenant', $metricData)) { - $tenantValue = $metricData['tenant']; + $hasTenant = array_key_exists('tenant', $metricData); - if ($tenantValue !== null && !is_string($tenantValue)) { - throw new Exception("Metric #{$index}: 'tenant' must be a string or null, got " . gettype($tenantValue)); - } + // Shared tables filter every read by tenant, so a row written + // without one would be invisible to normal tenant-scoped reads. + // Reject it at write time rather than silently storing dead data. + if ($this->sharedTables && (!$hasTenant || !is_string($metricData['tenant']) || $metricData['tenant'] === '')) { + throw new Exception("Metric #{$index}: 'tenant' is required (non-empty string) when shared tables are enabled"); + } + + if ($hasTenant && $metricData['tenant'] !== null && !is_string($metricData['tenant'])) { + throw new Exception("Metric #{$index}: 'tenant' must be a string or null, got " . gettype($metricData['tenant'])); } } } diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index c4ad91d..4d25bdb 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -643,6 +643,12 @@ private function withTypeFilter(array $queries, ?string $type): array * Scope the underlying Utopia\Database instance to a tenant for the next * operation. The Database adapter requires a numeric tenant ID. * + * The adapter takes ownership of the injected db's tenant: every call + * sets it and none restores it (Utopia\Database has no per-call tenant — + * only the mutable instance tenant — and save/restore around each call is + * its own footgun). Hand this adapter a db dedicated to usage, not one + * whose tenant other code depends on between calls. + * * @param string|null $tenant */ private function setDbTenant(?string $tenant): void diff --git a/tests/Usage/AccumulatorTest.php b/tests/Usage/AccumulatorTest.php index e056faa..2e82bcd 100644 --- a/tests/Usage/AccumulatorTest.php +++ b/tests/Usage/AccumulatorTest.php @@ -229,6 +229,13 @@ public function testElapsedSignal(): void $this->assertGreaterThanOrEqual(1.0, $this->accumulator->elapsedSeconds()); } + public function testEmptyTenantThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Tenant cannot be empty'); + $this->accumulator->collect('', 'requests', 10, Usage::TYPE_EVENT); + } + public function testEmptyMetricNameThrows(): void { $this->expectException(\InvalidArgumentException::class); @@ -236,6 +243,27 @@ public function testEmptyMetricNameThrows(): void $this->accumulator->collect('t1', '', 10, Usage::TYPE_EVENT); } + public function testKeyDistinguishesAmbiguousTenantMetricSplits(): void + { + // tenant "a" + metric "b:c" must not collide with tenant "a:b" + metric "c" + $this->accumulator->collect('a', 'b:c', 10, Usage::TYPE_EVENT); + $this->accumulator->collect('a:b', 'c', 20, Usage::TYPE_EVENT); + + $this->assertEquals(2, $this->accumulator->count()); + } + + public function testTagOrderDoesNotSplitEntries(): void + { + // Same logical tags in different insertion order must sum into one entry + $this->accumulator->collect('t1', 'requests', 10, Usage::TYPE_EVENT, ['teamId' => 't', 'resourceId' => 'r']); + $this->accumulator->collect('t1', 'requests', 20, Usage::TYPE_EVENT, ['resourceId' => 'r', 'teamId' => 't']); + + $this->assertEquals(1, $this->accumulator->count()); + + $this->assertTrue($this->accumulator->flush()); + $this->assertEquals(30, $this->adapter->batches[0]['metrics'][0]['value']); + } + public function testNegativeValueThrows(): void { $this->expectException(\InvalidArgumentException::class); From c2ff9fcc069c6fc628fc075f08d8b4d0ffdb391e Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:33:10 +0100 Subject: [PATCH 7/9] fix(usage): accept tenant/metric id "0" in collect() empty() treats the string "0" as empty, so a tenant or metric literally named "0" was wrongly rejected. Compare against '' instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Usage/Accumulator.php | 6 ++++-- tests/Usage/AccumulatorTest.php | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Usage/Accumulator.php b/src/Usage/Accumulator.php index 25d09ca..965c229 100644 --- a/src/Usage/Accumulator.php +++ b/src/Usage/Accumulator.php @@ -53,10 +53,12 @@ public function __construct(Usage $usage) */ public function collect(string $tenant, string $metric, int $value, string $type, array $tags = []): self { - if (empty($tenant)) { + // Compare against '' rather than empty(): the string "0" is a valid + // tenant/metric id but empty("0") is true in PHP. + if ($tenant === '') { throw new \InvalidArgumentException('Tenant cannot be empty'); } - if (empty($metric)) { + if ($metric === '') { throw new \InvalidArgumentException('Metric name cannot be empty'); } if ($value < 0) { diff --git a/tests/Usage/AccumulatorTest.php b/tests/Usage/AccumulatorTest.php index 2e82bcd..f63808a 100644 --- a/tests/Usage/AccumulatorTest.php +++ b/tests/Usage/AccumulatorTest.php @@ -236,6 +236,17 @@ public function testEmptyTenantThrows(): void $this->accumulator->collect('', 'requests', 10, Usage::TYPE_EVENT); } + public function testTenantZeroIsAccepted(): void + { + // "0" is a valid tenant id even though empty("0") is true in PHP + $this->accumulator->collect('0', 'requests', 10, Usage::TYPE_EVENT); + + $this->assertEquals(1, $this->accumulator->count()); + + $this->assertTrue($this->accumulator->flush()); + $this->assertEquals('0', $this->adapter->batches[0]['metrics'][0]['tenant']); + } + public function testEmptyMetricNameThrows(): void { $this->expectException(\InvalidArgumentException::class); From 6c3b6090f70d7074d1878408651a197ac12c07f6 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:40:16 +0100 Subject: [PATCH 8/9] fix(usage): Tenant decorator rejects an empty bound tenant new Tenant($usage, '') would silently scope every read/write to the empty tenant in shared-tables mode. Reject '' at construction, matching the accumulator/write-side rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Usage/Tenant.php | 8 +++++++- tests/Usage/TenantTest.php | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Usage/Tenant.php b/src/Usage/Tenant.php index fb45eb5..fe5a6f6 100644 --- a/src/Usage/Tenant.php +++ b/src/Usage/Tenant.php @@ -17,10 +17,16 @@ class Tenant /** * @param Usage $usage The underlying (tenant-agnostic) Usage instance - * @param string $tenant Tenant this view is scoped to + * @param string $tenant Tenant this view is scoped to (non-empty) */ public function __construct(Usage $usage, string $tenant) { + // Reject '' at construction: an empty scope would silently read/write + // the empty tenant in shared-tables mode. ("0" is a valid id.) + if ($tenant === '') { + throw new \InvalidArgumentException('Tenant cannot be empty'); + } + $this->usage = $usage; $this->tenant = $tenant; } diff --git a/tests/Usage/TenantTest.php b/tests/Usage/TenantTest.php index 57775fd..4f813cd 100644 --- a/tests/Usage/TenantTest.php +++ b/tests/Usage/TenantTest.php @@ -132,6 +132,13 @@ protected function setUp(): void $this->tenant = new Tenant(new Usage($this->adapter), 'p1'); } + public function testEmptyTenantThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Tenant cannot be empty'); + new Tenant(new Usage($this->adapter), ''); + } + public function testAddBatchStampsBoundTenantOntoEveryMetric(): void { $this->tenant->addBatch([ From 3715eaf17a5a71d851a899057eeed2bc5dc94c28 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:47:36 +0100 Subject: [PATCH 9/9] fix(clickhouse): reject empty tenant on reads in shared-tables mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty tenant compiled to `tenant = ''` and silently read an empty scope. parseQueries() is the single chokepoint every read funnels through, so guard there — matching the write-side and the Accumulator/Tenant rules. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Usage/Adapter/ClickHouse.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 831f965..9cf0cd5 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -3299,6 +3299,12 @@ private function buildOrderBySql(array $orderAttributes, bool $flip = false): ar private function parseQueries(string $tenant, array $queries, string $type = 'event'): array { if ($this->sharedTables) { + // An empty tenant would compile to `tenant = ''` and silently read + // an empty scope. Fail fast instead, like the write side. ("0" is + // a valid tenant id, so check for '' specifically.) + if ($tenant === '') { + throw new Exception('Tenant cannot be empty in shared-tables mode'); + } array_unshift($queries, Query::equal('tenant', [$tenant])); }