@@ -36,7 +36,8 @@ composer require utopia-php/usage
3636use Utopia\Usage\Usage;
3737use Utopia\Usage\Adapter\ClickHouse;
3838
39- // Configuration is fixed at construction; only the tenant changes per request.
39+ // Configuration is fixed at construction. The adapter is fully stateless —
40+ // the tenant is passed explicitly on every call (see below).
4041$adapter = new ClickHouse(
4142 host: 'clickhouse-server',
4243 username: 'default',
@@ -46,7 +47,6 @@ $adapter = new ClickHouse(
4647 namespace: 'my_app',
4748 sharedTables: true,
4849);
49- $adapter->setTenant('project_123');
5050
5151$usage = new Usage($adapter);
5252$usage->setup(); // Creates events, gauges, and daily MV tables
@@ -65,6 +65,35 @@ $usage = new Usage($adapter);
6565$usage->setup();
6666```
6767
68+ ## Multi-tenancy
69+
70+ ` Usage ` is stateless: every query/mutation takes the tenant as its first
71+ argument, and ` addBatch ` carries a ` tenant ` on each metric row (so one batch can
72+ span tenants). This makes a single ` Usage ` instance safe to share across
73+ tenants and coroutines.
74+
75+ ``` php
76+ $usage->getTotal('project_123', 'bandwidth'); // read for one tenant
77+ $usage->addBatch([
78+ ['tenant' => 'project_123', 'metric' => 'bandwidth', 'value' => 5000, 'tags' => []],
79+ ['tenant' => 'project_456', 'metric' => 'bandwidth', 'value' => 2000, 'tags' => []],
80+ ], Usage::TYPE_EVENT);
81+ ```
82+
83+ Callers that only ever touch one tenant can bind it once with the ` Tenant `
84+ decorator, which forwards to ` Usage ` with the tenant pre-filled (and stamps it
85+ onto every ` addBatch ` row):
86+
87+ ``` php
88+ use Utopia\Usage\Tenant;
89+
90+ $tenant = new Tenant($usage, 'project_123');
91+ $tenant->getTotal('bandwidth'); // no tenant argument
92+ $tenant->addBatch([
93+ ['metric' => 'bandwidth', 'value' => 5000, 'tags' => []], // tenant stamped automatically
94+ ], Usage::TYPE_EVENT);
95+ ```
96+
6897## Metric Types
6998
7099### Events (Additive)
@@ -79,8 +108,13 @@ Event-specific columns (see `Metric::EVENT_COLUMNS`): `path`, `method`, `status`
79108` deviceModel ` .
80109
81110``` php
82- // Collect events — values accumulate in-memory buffer (summed per metric)
83- $usage->collect('bandwidth', 5000, Usage::TYPE_EVENT, [
111+ use Utopia\Usage\Accumulator;
112+
113+ // Buffer metrics in memory and flush them in batch
114+ $accumulator = new Accumulator($usage);
115+
116+ // Collect events — tenant first; values accumulate in-memory (summed per tenant+metric)
117+ $accumulator->collect('project_123', 'bandwidth', 5000, Usage::TYPE_EVENT, [
84118 'path' => '/v1/storage/files',
85119 'method' => 'POST',
86120 'status' => '201',
@@ -114,9 +148,9 @@ Gauge-specific columns (see `Metric::GAUGE_COLUMNS`): `teamId`,
114148` teamInternalId ` , ` resourceId ` , ` resourceInternalId ` .
115149
116150``` php
117- // Collect gauges — last value wins per metric in buffer
118- $usage ->collect('users', 1500, Usage::TYPE_GAUGE);
119- $usage ->collect('storage.size', 1048576, Usage::TYPE_GAUGE, [
151+ // Collect gauges — last value wins per tenant+ metric in buffer
152+ $accumulator ->collect('project_123', 'users', 1500, Usage::TYPE_GAUGE);
153+ $accumulator ->collect('project_123', 'storage.size', 1048576, Usage::TYPE_GAUGE, [
120154 'teamId' => 'team_x',
121155 'teamInternalId' => '7',
122156 'resourceId' => 'abc123',
@@ -126,28 +160,28 @@ $usage->collect('storage.size', 1048576, Usage::TYPE_GAUGE, [
126160
127161### Flushing
128162
163+ The accumulator exposes raw signals — ` count() ` (buffered entries) and
164+ ` elapsedSeconds() ` (seconds since last flush) — and leaves the flush policy to
165+ the caller.
166+
129167``` php
130- // Check if flush is recommended (threshold or interval reached)
131- if ($usage->shouldFlush() ) {
132- $usage ->flush(); // Writes events to events table, gauges to gauges table
168+ // Flush when the buffer grows large or enough time has passed
169+ if ($accumulator->count() >= 5000 || $accumulator->elapsedSeconds() >= 10 ) {
170+ $accumulator ->flush(); // Writes events to events table, gauges to gauges table
133171}
134-
135- // Configure thresholds
136- $usage->setFlushThreshold(5000); // Flush after 5000 collect() calls (default: 10,000)
137- $usage->setFlushInterval(10); // Flush after 10 seconds (default: 20)
138172```
139173
140174### Batch Writes
141175
142176``` php
143- // Write directly without buffering
177+ // Write directly without buffering — each row carries its tenant
144178$usage->addBatch([
145- ['metric' => 'requests', 'value' => 100, 'tags' => ['path' => '/v1/users', 'method' => 'GET']],
146- ['metric' => 'bandwidth', 'value' => 50000, 'tags' => ['country' => 'DE', 'region' => 'fra']],
179+ ['tenant' => 'project_123', ' metric' => 'requests', 'value' => 100, 'tags' => ['path' => '/v1/users', 'method' => 'GET']],
180+ ['tenant' => 'project_123', ' metric' => 'bandwidth', 'value' => 50000, 'tags' => ['country' => 'DE', 'region' => 'fra']],
147181], Usage::TYPE_EVENT);
148182
149183$usage->addBatch([
150- ['metric' => 'users', 'value' => 42, 'tags' => ['teamId' => 'team_x']],
184+ ['tenant' => 'project_123', ' metric' => 'users', 'value' => 42, 'tags' => ['teamId' => 'team_x']],
151185], Usage::TYPE_GAUGE);
152186```
153187
@@ -158,8 +192,8 @@ $usage->addBatch([
158192``` php
159193use Utopia\Query\Query;
160194
161- // Find events filtered by metric and time range
162- $metrics = $usage->find([
195+ // Find events filtered by metric and time range (tenant first)
196+ $metrics = $usage->find('project_123', [
163197 Query::equal('metric', ['bandwidth']),
164198 Query::equal('country', ['US']),
165199 Query::greaterThanEqual('time', '2026-01-01'),
@@ -168,12 +202,12 @@ $metrics = $usage->find([
168202], Usage::TYPE_EVENT);
169203
170204// Find gauges
171- $gauges = $usage->find([
205+ $gauges = $usage->find('project_123', [
172206 Query::equal('metric', ['users', 'storage.size']),
173207], Usage::TYPE_GAUGE);
174208
175209// Query both tables (type = null)
176- $all = $usage->find([
210+ $all = $usage->find('project_123', [
177211 Query::equal('metric', ['bandwidth']),
178212]);
179213```
@@ -182,12 +216,13 @@ $all = $usage->find([
182216
183217``` php
184218// Get total for a single metric (SUM for events, latest for gauges)
185- $total = $usage->getTotal('bandwidth', [
219+ $total = $usage->getTotal('project_123', ' bandwidth', [
186220 Query::greaterThanEqual('time', '2026-03-01'),
187221], Usage::TYPE_EVENT);
188222
189223// Batch totals — single query with GROUP BY
190224$totals = $usage->getTotalBatch(
225+ 'project_123',
191226 ['bandwidth', 'executions', 'requests'],
192227 [Query::greaterThanEqual('time', '2026-03-01')],
193228 Usage::TYPE_EVENT
@@ -200,6 +235,7 @@ $totals = $usage->getTotalBatch(
200235// Get time series with query-time aggregation
201236// Events: SUM per bucket, Gauges: argMax per bucket
202237$series = $usage->getTimeSeries(
238+ tenant: 'project_123',
203239 metrics: ['bandwidth', 'requests'],
204240 interval: '1d', // '1h' or '1d'
205241 startDate: '2026-03-01',
@@ -217,20 +253,21 @@ The daily materialized view pre-aggregates events by `metric + tenant + day` for
217253
218254``` php
219255// Sum a single metric from the daily table
220- $total = $usage->sumDaily([
256+ $total = $usage->sumDaily('project_123', [
221257 Query::equal('metric', ['bandwidth']),
222258 Query::between('time', '2026-03-01', '2026-04-01'),
223259]);
224260
225261// Sum multiple metrics in one query
226262$totals = $usage->sumDailyBatch(
263+ 'project_123',
227264 ['bandwidth', 'executions', 'storage.size'],
228265 [Query::between('time', '2026-03-01', '2026-04-01')]
229266);
230267// Returns: ['bandwidth' => 5000000, 'executions' => 12345, 'storage.size' => 0]
231268
232269// Find daily aggregated rows
233- $rows = $usage->findDaily([
270+ $rows = $usage->findDaily('project_123', [
234271 Query::equal('metric', ['bandwidth']),
235272 Query::orderDesc('time'),
236273 Query::limit(30),
@@ -241,12 +278,12 @@ $rows = $usage->findDaily([
241278
242279``` php
243280// Purge all event metrics older than 90 days
244- $usage->purge([
281+ $usage->purge('project_123', [
245282 Query::lessThan('time', '2026-01-01'),
246283], Usage::TYPE_EVENT);
247284
248285// Purge all gauge metrics
249- $usage->purge([], Usage::TYPE_GAUGE);
286+ $usage->purge('project_123', [], Usage::TYPE_GAUGE);
250287```
251288
252289## Architecture
@@ -321,22 +358,23 @@ $usage->purge([], Usage::TYPE_GAUGE);
321358Extend ` Utopia\Usage\Adapter ` and implement:
322359
323360- ` getName() ` , ` setup() ` , ` healthCheck() `
324- - ` addBatch(array $metrics, string $type, int $batchSize): bool `
325- - ` find(array $queries, ?string $type): array `
326- - ` count(array $queries, ?string $type): int `
327- - ` sum(array $queries, string $attribute, ?string $type): int `
328- - ` getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries, bool $zeroFill, ?string $type): array `
329- - ` getTotal(string $metric, array $queries, ?string $type): int `
330- - ` getTotalBatch(array $metrics, array $queries, ?string $type): array `
331- - ` findDaily(array $queries): array `
332- - ` sumDaily(array $queries, string $attribute): int `
333- - ` sumDailyBatch(array $metrics, array $queries): array `
334- - ` purge(array $queries, ?string $type): bool `
335- - ` setTenant(?string $tenant): self `
336-
337- Namespace, database, shared-tables mode, async inserts, query logging and the
338- dual-read sample rate are set once via the adapter constructor (see "Using
339- ClickHouse Adapter" above). Only the tenant changes over an instance's lifetime.
361+ - ` addBatch(array $metrics, string $type, int $batchSize): bool ` (each metric carries its own ` tenant ` )
362+ - ` find(string $tenant, array $queries, ?string $type): array `
363+ - ` count(string $tenant, array $queries, ?string $type, ?int $max): int `
364+ - ` sum(string $tenant, array $queries, string $attribute, string $type): int `
365+ - ` getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries, bool $zeroFill, ?string $type): array `
366+ - ` getTotal(string $tenant, string $metric, array $queries, ?string $type): int `
367+ - ` getTotalBatch(string $tenant, array $metrics, array $queries, ?string $type): array `
368+ - ` findDaily(string $tenant, array $queries): array `
369+ - ` sumDaily(string $tenant, array $queries, string $attribute): int `
370+ - ` sumDailyBatch(string $tenant, array $metrics, array $queries): array `
371+ - ` purge(string $tenant, array $queries, ?string $type): bool `
372+
373+ All configuration — namespace, database, shared-tables mode, async inserts,
374+ query logging — is set once via the adapter constructor (see "Using ClickHouse
375+ Adapter" above). Adapters hold no per-request state; the tenant is passed
376+ explicitly on every call, so one instance is safe to share across tenants and
377+ coroutines.
340378
341379## System Requirements
342380
0 commit comments