diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..e5100db
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,20 @@
+name: "CodeQL"
+
+on: [ pull_request ]
+jobs:
+ lint:
+ name: CodeQL
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 2
+
+ - run: git checkout HEAD^2
+
+ - name: Run CodeQL
+ run: |
+ docker run --rm -v $PWD:/app composer sh -c \
+ "composer install --profile --ignore-platform-reqs && composer check"
\ No newline at end of file
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
new file mode 100644
index 0000000..15853db
--- /dev/null
+++ b/.github/workflows/linter.yml
@@ -0,0 +1,20 @@
+name: "Linter"
+
+on: [ pull_request ]
+jobs:
+ lint:
+ name: Linter
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 2
+
+ - run: git checkout HEAD^2
+
+ - name: Run Linter
+ run: |
+ docker run --rm -v $PWD:/app composer sh -c \
+ "composer install --profile --ignore-platform-reqs && composer lint"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..f0ad936
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,23 @@
+name: "Tests"
+
+on: [ pull_request ]
+jobs:
+ lint:
+ name: Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 2
+
+ - run: git checkout HEAD^2
+
+ - name: Build
+ run: |
+ docker compose build
+ docker compose up -d --wait
+
+ - name: Run Tests
+ run: docker compose exec usage vendor/bin/phpunit --configuration phpunit.xml tests
\ No newline at end of file
diff --git a/.phpunit.result.cache b/.phpunit.result.cache
new file mode 100644
index 0000000..3bbed83
--- /dev/null
+++ b/.phpunit.result.cache
@@ -0,0 +1 @@
+{"version":1,"defects":[],"times":{"Utopia\\Tests\\Usage\\UsageQueryTest::testGroupByIntervalCreation":0.001,"Utopia\\Tests\\Usage\\UsageQueryTest::testGroupByIntervalAllValidIntervals":0,"Utopia\\Tests\\Usage\\UsageQueryTest::testGroupByIntervalInvalidInterval":0,"Utopia\\Tests\\Usage\\UsageQueryTest::testGroupByIntervalInvalidIntervalEmpty":0,"Utopia\\Tests\\Usage\\UsageQueryTest::testIsGroupByInterval":0,"Utopia\\Tests\\Usage\\UsageQueryTest::testExtractGroupByInterval":0,"Utopia\\Tests\\Usage\\UsageQueryTest::testExtractGroupByIntervalReturnsNullWhenMissing":0,"Utopia\\Tests\\Usage\\UsageQueryTest::testRemoveGroupByInterval":0,"Utopia\\Tests\\Usage\\UsageQueryTest::testValidIntervalsConstant":0,"Utopia\\Tests\\Usage\\UsageQueryTest::testUsageQueryExtendsQuery":0,"Utopia\\Tests\\Usage\\MetricTest::testGetEventSchemaReturnsAttributeDefinitions":0.001,"Utopia\\Tests\\Usage\\MetricTest::testGetGaugeSchemaReturnsAttributeDefinitions":0,"Utopia\\Tests\\Usage\\MetricTest::testGetSchemaReturnsEventSchema":0,"Utopia\\Tests\\Usage\\MetricTest::testGetEventIndexesReturnsIndexDefinitions":0,"Utopia\\Tests\\Usage\\MetricTest::testGetGaugeIndexesReturnsIndexDefinitions":0,"Utopia\\Tests\\Usage\\MetricTest::testGetIndexesReturnsEventIndexes":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateAcceptsValidEventData":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateAcceptsValidGaugeData":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateAcceptsMinimalData":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateRejectsMissingMetric":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateRejectsMissingValue":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateRejectsNonStringMetric":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateRejectsOversizedMetric":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateRejectsNonIntegerValue":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateAcceptsDateTimeForTime":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateAcceptsDatetimeStringForTime":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateRejectsInvalidDatetimeString":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateRejectsNonArrayTags":0,"Utopia\\Tests\\Usage\\MetricTest::testValidateAcceptsEmptyTags":0,"Utopia\\Tests\\Usage\\MetricTest::testConstructorInitializesWithData":0,"Utopia\\Tests\\Usage\\MetricTest::testGetIdReturnsMetricId":0,"Utopia\\Tests\\Usage\\MetricTest::testGetIdReturnsEmptyStringWhenNotSet":0,"Utopia\\Tests\\Usage\\MetricTest::testGetMetricReturnsMetricName":0,"Utopia\\Tests\\Usage\\MetricTest::testGetValueReturnsValue":0,"Utopia\\Tests\\Usage\\MetricTest::testGetValueReturnsDefaultWhenNotSet":0,"Utopia\\Tests\\Usage\\MetricTest::testGetTypeReturnsType":0,"Utopia\\Tests\\Usage\\MetricTest::testGetTypeReturnsDefaultType":0,"Utopia\\Tests\\Usage\\MetricTest::testEventGettersReturnNullWhenNotSet":0,"Utopia\\Tests\\Usage\\MetricTest::testEventGettersReturnCorrectValues":0,"Utopia\\Tests\\Usage\\MetricTest::testGetTimeReturnsTimestamp":0,"Utopia\\Tests\\Usage\\MetricTest::testGetTimeReturnsNullWhenNotSet":0,"Utopia\\Tests\\Usage\\MetricTest::testGetTagsReturnsTags":0,"Utopia\\Tests\\Usage\\MetricTest::testGetTagsReturnsEmptyArrayWhenNotSet":0,"Utopia\\Tests\\Usage\\MetricTest::testGetTenantReturnsTenantId":0,"Utopia\\Tests\\Usage\\MetricTest::testGetTenantReturnsNullWhenNotSet":0,"Utopia\\Tests\\Usage\\MetricTest::testGetTenantConvertsNumericToString":0,"Utopia\\Tests\\Usage\\MetricTest::testGetAttributesReturnsAllAttributes":0,"Utopia\\Tests\\Usage\\MetricTest::testGetAttributeReturnsValue":0,"Utopia\\Tests\\Usage\\MetricTest::testGetAttributeReturnsDefaultWhenNotSet":0,"Utopia\\Tests\\Usage\\MetricTest::testSetAttributeSetsAndReturnsSelf":0,"Utopia\\Tests\\Usage\\MetricTest::testSetAttributeSupportsChaining":0,"Utopia\\Tests\\Usage\\MetricTest::testHasAttributeReturnsTrueWhenExists":0,"Utopia\\Tests\\Usage\\MetricTest::testHasAttributeReturnsFalseWhenNotExists":0,"Utopia\\Tests\\Usage\\MetricTest::testRemoveAttributeRemovesAndReturnsSelf":0,"Utopia\\Tests\\Usage\\MetricTest::testIsEmptyReturnsFalseWhenIdSet":0,"Utopia\\Tests\\Usage\\MetricTest::testIsEmptyReturnsTrueWhenNoId":0,"Utopia\\Tests\\Usage\\MetricTest::testToArrayReturnsArray":0,"Utopia\\Tests\\Usage\\MetricTest::testEventColumnsConstant":0}}
\ No newline at end of file
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..5dba194
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,76 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to make participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, sex characteristics, gender identity, expression,
+level of experience, education, socio-economic status, nationality, personal
+appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+ advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at team@appwrite.io. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..9d40d28
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,101 @@
+# Contributing
+
+We would ❤️ for you to contribute to Utopia-php and help make it better! We want contributing to Utopia-php to be fun, enjoyable, and educational for anyone and everyone. All contributions are welcome, including issues, new docs as well as updates and tweaks, blog posts, workshops, and more.
+
+## How to Start?
+
+If you are worried or don't know where to start, check out our next section explaining what kind of help we could use and where can you get involved. You can reach out with questions to [Eldad Fux (@eldadfux)](https://twitter.com/eldadfux) or anyone from the [Appwrite team on Discord](https://discord.gg/GSeTUeA). You can also submit an issue, and a maintainer can guide you!
+
+## Code of Conduct
+
+Help us keep Utopia-php open and inclusive. Please read and follow our [Code of Conduct](https://github.com/appwrite/appwrite/blob/master/CODE_OF_CONDUCT.md).
+
+## Submit a Pull Request 🚀
+
+Branch naming convention is as following
+
+`TYPE-ISSUE_ID-DESCRIPTION`
+
+example:
+
+```
+doc-548-submit-a-pull-request-section-to-contribution-guide
+```
+
+When `TYPE` can be:
+
+- **feat** - is a new feature
+- **doc** - documentation only changes
+- **cicd** - changes related to CI/CD system
+- **fix** - a bug fix
+- **refactor** - code change that neither fixes a bug nor adds a feature
+
+**All PRs must include a commit message with the changes description!**
+
+For the initial start, fork the project and use git clone command to download the repository to your computer. A standard procedure for working on an issue would be to:
+
+1. `git pull`, before creating a new branch, pull the changes from upstream. Your master needs to be up to date.
+
+```
+$ git pull
+```
+
+2. Create new branch from `master` like: `doc-548-submit-a-pull-request-section-to-contribution-guide`
+
+```
+$ git checkout -b [name_of_your_new_branch]
+```
+
+3. Work - commit - repeat ( be sure to be in your branch )
+
+4. Push changes to GitHub
+
+```
+$ git push origin [name_of_your_new_branch]
+```
+
+5. Submit your changes for review
+ If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button.
+6. Start a Pull Request
+ Now submit the pull request and click on `Create pull request`.
+7. Get a code review approval/reject
+8. After approval, merge your PR
+9. GitHub will automatically delete the branch after the merge is done. (they can still be restored).
+
+## Introducing New Features
+
+We would 💖 you to contribute to Utopia-php, but we would also like to make sure Utopia-php is as great as possible and loyal to its vision and mission statement 🙏.
+
+For us to find the right balance, please open an issue explaining your ideas before introducing a new pull request.
+
+This will allow the Utopia-php community to have sufficient discussion about the new feature value and how it fits in the product roadmap and vision.
+
+This is also important for the Utopia-php lead developers to be able to give technical input and different emphasis regarding the feature design and architecture. Some bigger features might need to go through our [RFC process](https://github.com/appwrite/rfc).
+
+## Other Ways to Help
+
+Pull requests are great, but there are many other areas where you can help Utopia-php.
+
+### Blogging & Speaking
+
+Blogging, speaking about, or creating tutorials about one of Utopia-php's many features is great way to contribute and help our project grow.
+
+### Presenting at Meetups
+
+Presenting at meetups and conferences about your Utopia-php projects. Your unique challenges and successes in building things with Utopia-php can provide great speaking material. We'd love to review your talk abstract/CFP, so get in touch with us if you'd like some help!
+
+### Sending Feedbacks & Reporting Bugs
+
+Sending feedback is a great way for us to understand your different use cases of Utopia-php better. If you had any issues, bugs, or want to share about your experience, feel free to do so on our GitHub issues page or at our [Discord channel](https://discord.gg/GSeTUeA).
+
+### Submitting New Ideas
+
+If you think Utopia-php could use a new feature, please open an issue on our GitHub repository, stating as much information as you can think about your new idea and it's implications. We would also use this issue to gather more information, get more feedback from the community, and have a proper discussion about the new feature.
+
+### Improving Documentation
+
+Submitting documentation updates, enhancements, designs, or bug fixes. Spelling or grammar fixes will be very much appreciated.
+
+### Helping Someone
+
+Searching for Utopia-php, GitHub or StackOverflow and helping someone else who needs help. You can also help by teaching others how to contribute to Utopia-php's repo!
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2d6a28f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,26 @@
+FROM composer:2.0 as step0
+
+WORKDIR /src/
+
+COPY composer.lock /src/
+COPY composer.json /src/
+
+RUN composer install --ignore-platform-reqs --optimize-autoloader \
+ --no-plugins --no-scripts --prefer-dist
+
+FROM php:8.3.3-cli-alpine3.19 as final
+
+LABEL maintainer="team@appwrite.io"
+
+RUN docker-php-ext-install pdo_mysql
+
+WORKDIR /code
+
+COPY --from=step0 /src/vendor /code/vendor
+
+# Add Source Code
+COPY ./tests /code/tests
+COPY ./src /code/src
+COPY ./phpunit.xml /code/phpunit.xml
+
+CMD [ "tail", "-f", "/dev/null" ]
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..a2b469d
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2024 Appwrite Team
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..eb92d96
--- /dev/null
+++ b/README.md
@@ -0,0 +1,306 @@
+# Utopia Usage
+
+
+[](https://appwrite.io/discord)
+
+Utopia framework usage library is a simple and lite library for managing application usage statistics. This library is aiming to be as simple and easy to learn and use. This library is maintained by the [Appwrite team](https://appwrite.io).
+
+Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) project it is dependency free and can be used as standalone with any other PHP project or framework.
+
+## Features
+
+- **Two Table Architecture**: Separate events and gauges tables optimized for their access patterns
+- **Events Table**: Request-level metrics with dedicated columns (path, method, status, resource, country, userAgent)
+- **Gauges Table**: Simple resource snapshots (storage size, user count, etc.)
+- **Query-Time Aggregation**: No write-time period fan-out — aggregate by any interval at query time
+- **Daily Materialized View**: Pre-aggregated daily SummingMergeTree for fast billing queries
+- **Pluggable Adapters**: ClickHouse (production) and Database (development/testing)
+- **In-Memory Buffering**: Collect metrics and flush in batch for high-throughput scenarios
+- **Rich Queries**: Filter, sort, paginate using `Utopia\Query\Query` objects
+- **Multi-Tenant**: Shared tables with tenant isolation via string tenant IDs
+- **LowCardinality Columns**: Country uses `LowCardinality(String)` for efficient storage
+- **Bloom Filter Indexes**: Fast filtering on all event columns
+
+## Getting Started
+
+Install using composer:
+```bash
+composer require utopia-php/usage
+```
+
+### Using ClickHouse Adapter
+
+```php
+setNamespace('my_app');
+$adapter->setSharedTables(true);
+$adapter->setTenant('project_123');
+
+$usage = new Usage($adapter);
+$usage->setup(); // Creates events, gauges, and daily MV tables
+```
+
+### Using Database Adapter
+
+```php
+setup();
+```
+
+## Metric Types
+
+### Events (Additive)
+
+Events are request-level metrics like bandwidth, executions, API calls. They are summed when aggregated.
+
+Event-specific columns: `path`, `method`, `status`, `resource`, `resourceId`, `country`, `userAgent`
+
+```php
+// Collect events — values accumulate in-memory buffer (summed per metric)
+$usage->collect('bandwidth', 5000, Usage::TYPE_EVENT, [
+ 'path' => '/v1/storage/files',
+ 'method' => 'POST',
+ 'status' => '201',
+ 'resource' => 'bucket',
+ 'resourceId' => 'abc123',
+ 'country' => 'US',
+ 'userAgent' => 'AppwriteSDK/1.0',
+]);
+
+// Event columns are auto-extracted from tags into dedicated columns
+// Remaining tags stay in the JSON tags column
+```
+
+### Gauges (Point-in-Time)
+
+Gauges are resource snapshots like storage size, user count, file count. Last-write-wins semantics.
+
+```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, [
+ 'resource' => 'bucket',
+ 'resourceId' => 'abc123',
+]);
+```
+
+### Flushing
+
+```php
+// Check if flush is recommended (threshold or interval reached)
+if ($usage->shouldFlush()) {
+ $usage->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
+
+```php
+// Write directly without buffering
+$usage->addBatch([
+ ['metric' => 'requests', 'value' => 100, 'tags' => ['path' => '/v1/users']],
+ ['metric' => 'bandwidth', 'value' => 50000, 'tags' => ['country' => 'DE']],
+], Usage::TYPE_EVENT);
+
+$usage->addBatch([
+ ['metric' => 'users', 'value' => 42, 'tags' => []],
+], Usage::TYPE_GAUGE);
+```
+
+## Querying Metrics
+
+### Find with Query Objects
+
+```php
+use Utopia\Query\Query;
+
+// Find events filtered by metric and time range
+$metrics = $usage->find([
+ Query::equal('metric', ['bandwidth']),
+ Query::equal('country', ['US']),
+ Query::greaterThanEqual('time', '2026-01-01'),
+ Query::orderDesc('time'),
+ Query::limit(100),
+], Usage::TYPE_EVENT);
+
+// Find gauges
+$gauges = $usage->find([
+ Query::equal('metric', ['users', 'storage.size']),
+], Usage::TYPE_GAUGE);
+
+// Query both tables (type = null)
+$all = $usage->find([
+ Query::equal('metric', ['bandwidth']),
+]);
+```
+
+### Totals
+
+```php
+// Get total for a single metric (SUM for events, latest for gauges)
+$total = $usage->getTotal('bandwidth', [
+ Query::greaterThanEqual('time', '2026-03-01'),
+], Usage::TYPE_EVENT);
+
+// Batch totals — single query with GROUP BY
+$totals = $usage->getTotalBatch(
+ ['bandwidth', 'executions', 'requests'],
+ [Query::greaterThanEqual('time', '2026-03-01')],
+ Usage::TYPE_EVENT
+);
+```
+
+### Time Series
+
+```php
+// Get time series with query-time aggregation
+// Events: SUM per bucket, Gauges: argMax per bucket
+$series = $usage->getTimeSeries(
+ metrics: ['bandwidth', 'requests'],
+ interval: '1d', // '1h' or '1d'
+ startDate: '2026-03-01',
+ endDate: '2026-04-01',
+ zeroFill: true, // Fill gaps with zeros
+ type: Usage::TYPE_EVENT
+);
+
+// Returns: ['bandwidth' => ['total' => 5000000, 'data' => [['value' => 100, 'date' => '...'], ...]]]
+```
+
+### Billing Queries (Daily MV)
+
+The daily materialized view pre-aggregates events by `metric + tenant + day` for fast billing:
+
+```php
+// Sum a single metric from the daily table
+$total = $usage->sumDaily([
+ Query::equal('metric', ['bandwidth']),
+ Query::between('time', '2026-03-01', '2026-04-01'),
+]);
+
+// Sum multiple metrics in one query
+$totals = $usage->sumDailyBatch(
+ ['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([
+ Query::equal('metric', ['bandwidth']),
+ Query::orderDesc('time'),
+ Query::limit(30),
+]);
+```
+
+### Purge
+
+```php
+// Purge all event metrics older than 90 days
+$usage->purge([
+ Query::lessThan('time', '2026-01-01'),
+], Usage::TYPE_EVENT);
+
+// Purge all gauge metrics
+$usage->purge([], Usage::TYPE_GAUGE);
+```
+
+## Architecture
+
+### ClickHouse Tables
+
+| Table | Engine | Purpose |
+|-------|--------|---------|
+| `{ns}_usage_events` | MergeTree | Raw request events with full metadata |
+| `{ns}_usage_gauges` | MergeTree | Resource snapshot gauges |
+| `{ns}_usage_events_daily` | SummingMergeTree | Pre-aggregated daily event totals |
+| `{ns}_usage_events_daily_mv` | Materialized View | Auto-populates daily table on insert |
+
+### Events Table Schema
+
+| Column | Type | Description |
+|--------|------|-------------|
+| id | String | UUID |
+| metric | String | Metric name (e.g. bandwidth, requests) |
+| value | Int64 | Metric value |
+| time | DateTime64(3) | Event timestamp |
+| path | Nullable(String) | API endpoint path |
+| method | Nullable(String) | HTTP method |
+| status | Nullable(String) | HTTP status code |
+| resource | Nullable(String) | Resource type |
+| resourceId | Nullable(String) | Resource ID |
+| country | LowCardinality(Nullable(String)) | ISO country code |
+| userAgent | Nullable(String) | User agent string |
+| tags | Nullable(String) | JSON for extra metadata |
+| tenant | Nullable(String) | Tenant ID (shared tables) |
+
+### Gauges Table Schema
+
+| Column | Type | Description |
+|--------|------|-------------|
+| id | String | UUID |
+| metric | String | Metric name |
+| value | Int64 | Current value |
+| time | DateTime64(3) | Snapshot timestamp |
+| tags | Nullable(String) | JSON metadata |
+| tenant | Nullable(String) | Tenant ID (shared tables) |
+
+### Daily Table Schema
+
+| Column | Type | Description |
+|--------|------|-------------|
+| metric | String | Metric name |
+| value | Int64 | Aggregated daily sum |
+| time | DateTime64(3) | Day start timestamp |
+| tenant | Nullable(String) | Tenant ID (shared tables) |
+
+### Creating Custom Adapters
+
+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`
+- `setNamespace(string $namespace): self`
+- `setTenant(?string $tenant): self`
+- `setSharedTables(bool $sharedTables): self`
+
+## System Requirements
+
+Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible.
+
+## Copyright and license
+
+The MIT License (MIT) [http://www.opensource.org/licenses/mit-license.php](http://www.opensource.org/licenses/mit-license.php)
diff --git a/composer.json b/composer.json
index 827e005..d9f85c8 100644
--- a/composer.json
+++ b/composer.json
@@ -9,10 +9,33 @@
"email": "team@appwrite.io"
}
],
+ "scripts": {
+ "lint": "./vendor/bin/pint --test",
+ "format": "./vendor/bin/pint",
+ "check": "./vendor/bin/phpstan analyse --level max src tests"
+ },
"minimum-stability": "stable",
"require": {
- "utopia-php/fetch": "^0.4.2",
- "utopia-php/database": "^4.3"
+ "php": ">=8.0",
+ "utopia-php/fetch": "0.5.*",
+ "utopia-php/database": "5.*",
+ "utopia-php/query": "0.1.*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5",
+ "utopia-php/cache": "1.*",
+ "phpstan/phpstan": "1.*",
+ "laravel/pint": "1.*"
+ },
+ "autoload": {
+ "psr-4": {
+ "Utopia\\Usage\\": "src/Usage"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Utopia\\Tests\\": "tests"
+ }
},
"config": {
"allow-plugins": {
diff --git a/composer.lock b/composer.lock
index 4036504..05990a1 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "fae1878621d4585a46e2dc9e5ce78d5f",
+ "content-hash": "212d86b0fadf4671a550e439d21bea38",
"packages": [
{
"name": "brick/math",
- "version": "0.14.1",
+ "version": "0.14.8",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
- "reference": "f05858549e5f9d7bb45875a75583240a38a281d0"
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0",
- "reference": "f05858549e5f9d7bb45875a75583240a38a281d0",
+ "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629",
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629",
"shasum": ""
},
"require": {
@@ -56,7 +56,7 @@
],
"support": {
"issues": "https://github.com/brick/math/issues",
- "source": "https://github.com/brick/math/tree/0.14.1"
+ "source": "https://github.com/brick/math/tree/0.14.8"
},
"funding": [
{
@@ -64,7 +64,7 @@
"type": "github"
}
],
- "time": "2025-11-24T14:40:29+00:00"
+ "time": "2026-02-10T14:33:43+00:00"
},
{
"name": "composer/semver",
@@ -145,16 +145,16 @@
},
{
"name": "google/protobuf",
- "version": "v4.33.2",
+ "version": "v4.33.5",
"source": {
"type": "git",
"url": "https://github.com/protocolbuffers/protobuf-php.git",
- "reference": "fbd96b7bf1343f4b0d8fb358526c7ba4d72f1318"
+ "reference": "ebe8010a61b2ae0cff0d246fe1c4d44e9f7dfa6d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/fbd96b7bf1343f4b0d8fb358526c7ba4d72f1318",
- "reference": "fbd96b7bf1343f4b0d8fb358526c7ba4d72f1318",
+ "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/ebe8010a61b2ae0cff0d246fe1c4d44e9f7dfa6d",
+ "reference": "ebe8010a61b2ae0cff0d246fe1c4d44e9f7dfa6d",
"shasum": ""
},
"require": {
@@ -183,9 +183,9 @@
"proto"
],
"support": {
- "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.2"
+ "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.5"
},
- "time": "2025-12-05T22:12:22+00:00"
+ "time": "2026-01-29T20:49:00+00:00"
},
{
"name": "mongodb/mongodb",
@@ -410,16 +410,16 @@
},
{
"name": "open-telemetry/api",
- "version": "1.7.1",
+ "version": "1.8.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/api.git",
- "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4"
+ "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4",
- "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4",
+ "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/df5197c6fd0ddd8e9883b87de042d9341300e2ad",
+ "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad",
"shasum": ""
},
"require": {
@@ -429,7 +429,7 @@
"symfony/polyfill-php82": "^1.26"
},
"conflict": {
- "open-telemetry/sdk": "<=1.0.8"
+ "open-telemetry/sdk": "<=1.11"
},
"type": "library",
"extra": {
@@ -476,7 +476,7 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2025-10-19T10:49:48+00:00"
+ "time": "2026-01-21T04:14:03+00:00"
},
{
"name": "open-telemetry/context",
@@ -539,16 +539,16 @@
},
{
"name": "open-telemetry/exporter-otlp",
- "version": "1.3.3",
+ "version": "1.4.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/exporter-otlp.git",
- "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d"
+ "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/07b02bc71838463f6edcc78d3485c04b48fb263d",
- "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d",
+ "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/283a0d66522f2adc6d8d7debfd7686be91c282be",
+ "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be",
"shasum": ""
},
"require": {
@@ -599,7 +599,7 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2025-11-13T08:04:37+00:00"
+ "time": "2026-02-05T09:44:52+00:00"
},
{
"name": "open-telemetry/gen-otlp-protobuf",
@@ -666,16 +666,16 @@
},
{
"name": "open-telemetry/sdk",
- "version": "1.10.0",
+ "version": "1.13.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/sdk.git",
- "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99"
+ "reference": "c76f91203bf7ef98ab3f4e0a82ca21699af185e1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99",
- "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99",
+ "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/c76f91203bf7ef98ab3f4e0a82ca21699af185e1",
+ "reference": "c76f91203bf7ef98ab3f4e0a82ca21699af185e1",
"shasum": ""
},
"require": {
@@ -716,7 +716,7 @@
]
},
"branch-alias": {
- "dev-main": "1.9.x-dev"
+ "dev-main": "1.12.x-dev"
}
},
"autoload": {
@@ -759,20 +759,20 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2025-11-25T10:59:15+00:00"
+ "time": "2026-01-28T11:38:11+00:00"
},
{
"name": "open-telemetry/sem-conv",
- "version": "1.37.0",
+ "version": "1.38.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/sem-conv.git",
- "reference": "8da7ec497c881e39afa6657d72586e27efbd29a1"
+ "reference": "e613bc640a407def4991b8a936a9b27edd9a3240"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/8da7ec497c881e39afa6657d72586e27efbd29a1",
- "reference": "8da7ec497c881e39afa6657d72586e27efbd29a1",
+ "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/e613bc640a407def4991b8a936a9b27edd9a3240",
+ "reference": "e613bc640a407def4991b8a936a9b27edd9a3240",
"shasum": ""
},
"require": {
@@ -812,11 +812,11 @@
],
"support": {
"chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
- "docs": "https://opentelemetry.io/docs/php",
+ "docs": "https://opentelemetry.io/docs/languages/php",
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2025-09-03T12:08:10+00:00"
+ "time": "2026-01-21T04:14:03+00:00"
},
{
"name": "php-http/discovery",
@@ -1238,20 +1238,20 @@
},
{
"name": "ramsey/uuid",
- "version": "4.9.1",
+ "version": "4.9.2",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
- "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440"
+ "reference": "8429c78ca35a09f27565311b98101e2826affde0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440",
- "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440",
+ "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0",
+ "reference": "8429c78ca35a09f27565311b98101e2826affde0",
"shasum": ""
},
"require": {
- "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
+ "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
@@ -1310,9 +1310,9 @@
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
- "source": "https://github.com/ramsey/uuid/tree/4.9.1"
+ "source": "https://github.com/ramsey/uuid/tree/4.9.2"
},
- "time": "2025-09-04T20:59:21+00:00"
+ "time": "2025-12-14T04:43:48+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -1383,16 +1383,16 @@
},
{
"name": "symfony/http-client",
- "version": "v7.4.1",
+ "version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
- "reference": "26cc224ea7103dda90e9694d9e139a389092d007"
+ "reference": "1010624285470eb60e88ed10035102c75b4ea6af"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/26cc224ea7103dda90e9694d9e139a389092d007",
- "reference": "26cc224ea7103dda90e9694d9e139a389092d007",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af",
+ "reference": "1010624285470eb60e88ed10035102c75b4ea6af",
"shasum": ""
},
"require": {
@@ -1460,7 +1460,7 @@
"http"
],
"support": {
- "source": "https://github.com/symfony/http-client/tree/v7.4.1"
+ "source": "https://github.com/symfony/http-client/tree/v7.4.7"
},
"funding": [
{
@@ -1480,7 +1480,7 @@
"type": "tidelift"
}
],
- "time": "2025-12-04T21:12:57+00:00"
+ "time": "2026-03-05T11:16:58+00:00"
},
{
"name": "symfony/http-client-contracts",
@@ -2026,16 +2026,16 @@
},
{
"name": "utopia-php/cache",
- "version": "0.13.1",
+ "version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/cache.git",
- "reference": "97220cb3b3822b166ee016d1646e2ae2815dc540"
+ "reference": "7068870c086a6aea16173563a26b93ef3e408439"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/cache/zipball/97220cb3b3822b166ee016d1646e2ae2815dc540",
- "reference": "97220cb3b3822b166ee016d1646e2ae2815dc540",
+ "url": "https://api.github.com/repos/utopia-php/cache/zipball/7068870c086a6aea16173563a26b93ef3e408439",
+ "reference": "7068870c086a6aea16173563a26b93ef3e408439",
"shasum": ""
},
"require": {
@@ -2043,8 +2043,8 @@
"ext-memcached": "*",
"ext-redis": "*",
"php": ">=8.0",
- "utopia-php/pools": "0.8.*",
- "utopia-php/telemetry": "0.1.*"
+ "utopia-php/pools": "1.*",
+ "utopia-php/telemetry": "*"
},
"require-dev": {
"laravel/pint": "1.2.*",
@@ -2072,26 +2072,26 @@
],
"support": {
"issues": "https://github.com/utopia-php/cache/issues",
- "source": "https://github.com/utopia-php/cache/tree/0.13.1"
+ "source": "https://github.com/utopia-php/cache/tree/1.0.0"
},
- "time": "2025-05-09T14:43:52+00:00"
+ "time": "2026-01-28T10:55:44+00:00"
},
{
"name": "utopia-php/compression",
- "version": "0.1.3",
+ "version": "0.1.4",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/compression.git",
- "reference": "66f093557ba66d98245e562036182016c7dcfe8a"
+ "reference": "68045cb9d714c1259582d2dfd0e76bd34f83e713"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/compression/zipball/66f093557ba66d98245e562036182016c7dcfe8a",
- "reference": "66f093557ba66d98245e562036182016c7dcfe8a",
+ "url": "https://api.github.com/repos/utopia-php/compression/zipball/68045cb9d714c1259582d2dfd0e76bd34f83e713",
+ "reference": "68045cb9d714c1259582d2dfd0e76bd34f83e713",
"shasum": ""
},
"require": {
- "php": ">=8.0"
+ "php": ">=8.1"
},
"require-dev": {
"laravel/pint": "1.2.*",
@@ -2118,33 +2118,33 @@
],
"support": {
"issues": "https://github.com/utopia-php/compression/issues",
- "source": "https://github.com/utopia-php/compression/tree/0.1.3"
+ "source": "https://github.com/utopia-php/compression/tree/0.1.4"
},
- "time": "2025-01-15T15:15:51+00:00"
+ "time": "2026-02-17T05:53:40+00:00"
},
{
"name": "utopia-php/database",
- "version": "4.3.0",
+ "version": "5.3.6",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/database.git",
- "reference": "fe7a1326ad623609e65587fe8c01a630a7075fee"
+ "reference": "489e3cea9da80f067fda1acc3fa03bc6ca9f69de"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/database/zipball/fe7a1326ad623609e65587fe8c01a630a7075fee",
- "reference": "fe7a1326ad623609e65587fe8c01a630a7075fee",
+ "url": "https://api.github.com/repos/utopia-php/database/zipball/489e3cea9da80f067fda1acc3fa03bc6ca9f69de",
+ "reference": "489e3cea9da80f067fda1acc3fa03bc6ca9f69de",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-mongodb": "*",
"ext-pdo": "*",
- "php": ">=8.1",
- "utopia-php/cache": "0.13.*",
+ "php": ">=8.4",
+ "utopia-php/cache": "1.*",
"utopia-php/framework": "0.33.*",
- "utopia-php/mongo": "0.11.*",
- "utopia-php/pools": "0.8.*"
+ "utopia-php/mongo": "1.*",
+ "utopia-php/pools": "1.*"
},
"require-dev": {
"fakerphp/faker": "1.23.*",
@@ -2176,26 +2176,26 @@
],
"support": {
"issues": "https://github.com/utopia-php/database/issues",
- "source": "https://github.com/utopia-php/database/tree/4.3.0"
+ "source": "https://github.com/utopia-php/database/tree/5.3.6"
},
- "time": "2025-11-14T03:43:10+00:00"
+ "time": "2026-03-06T08:21:21+00:00"
},
{
"name": "utopia-php/fetch",
- "version": "0.4.2",
+ "version": "0.5.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/fetch.git",
- "reference": "83986d1be75a2fae4e684107fe70dd78a8e19b77"
+ "reference": "a96a010e1c273f3888765449687baf58cbc61fcd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/fetch/zipball/83986d1be75a2fae4e684107fe70dd78a8e19b77",
- "reference": "83986d1be75a2fae4e684107fe70dd78a8e19b77",
+ "url": "https://api.github.com/repos/utopia-php/fetch/zipball/a96a010e1c273f3888765449687baf58cbc61fcd",
+ "reference": "a96a010e1c273f3888765449687baf58cbc61fcd",
"shasum": ""
},
"require": {
- "php": ">=8.0"
+ "php": ">=8.1"
},
"require-dev": {
"laravel/pint": "^1.5.0",
@@ -2215,35 +2215,36 @@
"description": "A simple library that provides an interface for making HTTP Requests.",
"support": {
"issues": "https://github.com/utopia-php/fetch/issues",
- "source": "https://github.com/utopia-php/fetch/tree/0.4.2"
+ "source": "https://github.com/utopia-php/fetch/tree/0.5.1"
},
- "time": "2025-04-25T13:48:02+00:00"
+ "time": "2025-12-18T16:25:10+00:00"
},
{
"name": "utopia-php/framework",
- "version": "0.33.34",
+ "version": "0.33.41",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
- "reference": "76def92594c32504ec80eaacdb60ff8fad73c856"
+ "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/http/zipball/76def92594c32504ec80eaacdb60ff8fad73c856",
- "reference": "76def92594c32504ec80eaacdb60ff8fad73c856",
+ "url": "https://api.github.com/repos/utopia-php/http/zipball/0f3bf2377c867e547c929c3733b8224afee6ef06",
+ "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06",
"shasum": ""
},
"require": {
"php": ">=8.3",
"utopia-php/compression": "0.1.*",
- "utopia-php/telemetry": "0.1.*",
- "utopia-php/validators": "0.1.*"
+ "utopia-php/telemetry": "0.2.*",
+ "utopia-php/validators": "0.2.*"
},
"require-dev": {
"laravel/pint": "1.*",
"phpbench/phpbench": "1.*",
"phpstan/phpstan": "1.*",
- "phpunit/phpunit": "9.*"
+ "phpunit/phpunit": "9.*",
+ "swoole/ide-helper": "^6.0"
},
"type": "library",
"autoload": {
@@ -2263,22 +2264,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/http/issues",
- "source": "https://github.com/utopia-php/http/tree/0.33.34"
+ "source": "https://github.com/utopia-php/http/tree/0.33.41"
},
- "time": "2025-12-08T07:55:31+00:00"
+ "time": "2026-02-24T12:01:28+00:00"
},
{
"name": "utopia-php/mongo",
- "version": "0.11.0",
+ "version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/mongo.git",
- "reference": "34bc0cda8ea368cde68702a6fffe2c3ac625398e"
+ "reference": "45bedf36c2c946ec7a0a3e59b9f12f772de0b01d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/mongo/zipball/34bc0cda8ea368cde68702a6fffe2c3ac625398e",
- "reference": "34bc0cda8ea368cde68702a6fffe2c3ac625398e",
+ "url": "https://api.github.com/repos/utopia-php/mongo/zipball/45bedf36c2c946ec7a0a3e59b9f12f772de0b01d",
+ "reference": "45bedf36c2c946ec7a0a3e59b9f12f772de0b01d",
"shasum": ""
},
"require": {
@@ -2324,32 +2325,33 @@
],
"support": {
"issues": "https://github.com/utopia-php/mongo/issues",
- "source": "https://github.com/utopia-php/mongo/tree/0.11.0"
+ "source": "https://github.com/utopia-php/mongo/tree/1.0.0"
},
- "time": "2025-10-20T11:11:23+00:00"
+ "time": "2026-02-12T05:54:06+00:00"
},
{
"name": "utopia-php/pools",
- "version": "0.8.2",
+ "version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/pools.git",
- "reference": "05c67aba42eb68ac65489cc1e7fc5db83db2dd4d"
+ "reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/pools/zipball/05c67aba42eb68ac65489cc1e7fc5db83db2dd4d",
- "reference": "05c67aba42eb68ac65489cc1e7fc5db83db2dd4d",
+ "url": "https://api.github.com/repos/utopia-php/pools/zipball/74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
+ "reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
"shasum": ""
},
"require": {
- "php": ">=8.3",
- "utopia-php/telemetry": "0.1.*"
+ "php": ">=8.4",
+ "utopia-php/telemetry": "*"
},
"require-dev": {
"laravel/pint": "1.*",
"phpstan/phpstan": "1.*",
- "phpunit/phpunit": "11.*"
+ "phpunit/phpunit": "11.*",
+ "swoole/ide-helper": "6.*"
},
"type": "library",
"autoload": {
@@ -2376,38 +2378,89 @@
],
"support": {
"issues": "https://github.com/utopia-php/pools/issues",
- "source": "https://github.com/utopia-php/pools/tree/0.8.2"
+ "source": "https://github.com/utopia-php/pools/tree/1.0.3"
},
- "time": "2025-04-17T02:04:54+00:00"
+ "time": "2026-02-26T08:42:40+00:00"
},
{
- "name": "utopia-php/telemetry",
+ "name": "utopia-php/query",
"version": "0.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/query.git",
+ "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/query/zipball/964a10ed3185490505f4c0062f2eb7b89287fb27",
+ "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "laravel/pint": "*",
+ "phpstan/phpstan": "*",
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\Query\\": "src/Query"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A simple library providing a query abstraction for filtering, ordering, and pagination",
+ "keywords": [
+ "framework",
+ "php",
+ "query",
+ "upf",
+ "utopia"
+ ],
+ "support": {
+ "issues": "https://github.com/utopia-php/query/issues",
+ "source": "https://github.com/utopia-php/query/tree/0.1.1"
+ },
+ "time": "2026-03-03T09:05:14+00:00"
+ },
+ {
+ "name": "utopia-php/telemetry",
+ "version": "0.2.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/telemetry.git",
- "reference": "437f0021777f0e575dfb9e8a1a081b3aed75e33f"
+ "reference": "9997ebf59bb77920a7223ad73d834a76b09152c3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/437f0021777f0e575dfb9e8a1a081b3aed75e33f",
- "reference": "437f0021777f0e575dfb9e8a1a081b3aed75e33f",
+ "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/9997ebf59bb77920a7223ad73d834a76b09152c3",
+ "reference": "9997ebf59bb77920a7223ad73d834a76b09152c3",
"shasum": ""
},
"require": {
"ext-opentelemetry": "*",
"ext-protobuf": "*",
- "nyholm/psr7": "^1.8",
- "open-telemetry/exporter-otlp": "^1.1",
- "open-telemetry/sdk": "^1.1",
+ "nyholm/psr7": "1.*",
+ "open-telemetry/exporter-otlp": "1.*",
+ "open-telemetry/sdk": "1.*",
"php": ">=8.0",
- "symfony/http-client": "^7.1"
+ "symfony/http-client": "7.*"
},
"require-dev": {
- "laravel/pint": "^1.2",
- "phpbench/phpbench": "^1.2",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^9.5.25"
+ "laravel/pint": "1.*",
+ "phpbench/phpbench": "1.*",
+ "phpstan/phpstan": "2.*",
+ "phpunit/phpunit": "11.*",
+ "swoole/ide-helper": "6.*"
+ },
+ "suggest": {
+ "ext-sockets": "Required for the Swoole transport implementation",
+ "ext-swoole": "Required for the Swoole transport implementation"
},
"type": "library",
"autoload": {
@@ -2426,22 +2479,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/telemetry/issues",
- "source": "https://github.com/utopia-php/telemetry/tree/0.1.1"
+ "source": "https://github.com/utopia-php/telemetry/tree/0.2.0"
},
- "time": "2025-03-17T11:57:52+00:00"
+ "time": "2025-12-17T07:56:38+00:00"
},
{
"name": "utopia-php/validators",
- "version": "0.1.0",
+ "version": "0.2.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/validators.git",
- "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080"
+ "reference": "30b6030a5b100fc1dff34506e5053759594b2a20"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/validators/zipball/5c57d5b6cf964f8981807c1d3ea8df620c869080",
- "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080",
+ "url": "https://api.github.com/repos/utopia-php/validators/zipball/30b6030a5b100fc1dff34506e5053759594b2a20",
+ "reference": "30b6030a5b100fc1dff34506e5053759594b2a20",
"shasum": ""
},
"require": {
@@ -2449,7 +2502,7 @@
},
"require-dev": {
"laravel/pint": "1.*",
- "phpstan/phpstan": "1.*",
+ "phpstan/phpstan": "2.*",
"phpunit/phpunit": "11.*"
},
"type": "library",
@@ -2471,18 +2524,1937 @@
],
"support": {
"issues": "https://github.com/utopia-php/validators/issues",
- "source": "https://github.com/utopia-php/validators/tree/0.1.0"
+ "source": "https://github.com/utopia-php/validators/tree/0.2.0"
+ },
+ "time": "2026-01-13T09:16:51+00:00"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "doctrine/instantiator",
+ "version": "2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/instantiator.git",
+ "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7",
+ "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.4"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^14",
+ "ext-pdo": "*",
+ "ext-phar": "*",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/phpunit": "^10.5.58"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marco Pivetta",
+ "email": "ocramius@gmail.com",
+ "homepage": "https://ocramius.github.io/"
+ }
+ ],
+ "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+ "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+ "keywords": [
+ "constructor",
+ "instantiate"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/instantiator/issues",
+ "source": "https://github.com/doctrine/instantiator/tree/2.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-01-05T06:47:08+00:00"
+ },
+ {
+ "name": "laravel/pint",
+ "version": "v1.27.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/pint.git",
+ "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5",
+ "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "ext-tokenizer": "*",
+ "ext-xml": "*",
+ "php": "^8.2.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.93.1",
+ "illuminate/view": "^12.51.0",
+ "larastan/larastan": "^3.9.2",
+ "laravel-zero/framework": "^12.0.5",
+ "mockery/mockery": "^1.6.12",
+ "nunomaduro/termwind": "^2.3.3",
+ "pestphp/pest": "^3.8.5"
+ },
+ "bin": [
+ "builds/pint"
+ ],
+ "type": "project",
+ "autoload": {
+ "psr-4": {
+ "App\\": "app/",
+ "Database\\Seeders\\": "database/seeders/",
+ "Database\\Factories\\": "database/factories/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "An opinionated code formatter for PHP.",
+ "homepage": "https://laravel.com",
+ "keywords": [
+ "dev",
+ "format",
+ "formatter",
+ "lint",
+ "linter",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/pint/issues",
+ "source": "https://github.com/laravel/pint"
+ },
+ "time": "2026-02-10T20:00:20+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+ },
+ "time": "2025-12-06T11:56:16+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
},
- "time": "2025-11-18T11:05:46+00:00"
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpstan/phpstan",
+ "version": "1.12.33",
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1",
+ "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2|^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan-shim": "*"
+ },
+ "bin": [
+ "phpstan",
+ "phpstan.phar"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPStan - PHP Static Analysis Tool",
+ "keywords": [
+ "dev",
+ "static analysis"
+ ],
+ "support": {
+ "docs": "https://phpstan.org/user-guide/getting-started",
+ "forum": "https://github.com/phpstan/phpstan/discussions",
+ "issues": "https://github.com/phpstan/phpstan/issues",
+ "security": "https://github.com/phpstan/phpstan/security/policy",
+ "source": "https://github.com/phpstan/phpstan-src"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ondrejmirtes",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/phpstan",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-28T20:30:03+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "9.2.32",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5",
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^4.19.1 || ^5.1.0",
+ "php": ">=7.3",
+ "phpunit/php-file-iterator": "^3.0.6",
+ "phpunit/php-text-template": "^2.0.4",
+ "sebastian/code-unit-reverse-lookup": "^2.0.3",
+ "sebastian/complexity": "^2.0.3",
+ "sebastian/environment": "^5.1.5",
+ "sebastian/lines-of-code": "^1.0.4",
+ "sebastian/version": "^3.0.2",
+ "theseer/tokenizer": "^1.2.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.2.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-22T04:23:01+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "3.0.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2021-12-02T12:48:52+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "3.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:58:55+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T05:33:50+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "5.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:16:10+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "9.6.34",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "b36f02317466907a230d3aa1d34467041271ef4a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a",
+ "reference": "b36f02317466907a230d3aa1d34467041271ef4a",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/instantiator": "^1.5.0 || ^2",
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=7.3",
+ "phpunit/php-code-coverage": "^9.2.32",
+ "phpunit/php-file-iterator": "^3.0.6",
+ "phpunit/php-invoker": "^3.1.1",
+ "phpunit/php-text-template": "^2.0.4",
+ "phpunit/php-timer": "^5.0.3",
+ "sebastian/cli-parser": "^1.0.2",
+ "sebastian/code-unit": "^1.0.8",
+ "sebastian/comparator": "^4.0.10",
+ "sebastian/diff": "^4.0.6",
+ "sebastian/environment": "^5.1.5",
+ "sebastian/exporter": "^4.0.8",
+ "sebastian/global-state": "^5.0.8",
+ "sebastian/object-enumerator": "^4.0.4",
+ "sebastian/resource-operations": "^3.0.4",
+ "sebastian/type": "^3.2.1",
+ "sebastian/version": "^3.0.2"
+ },
+ "suggest": {
+ "ext-soap": "To be able to generate mocks based on WSDL files",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "9.6-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsors.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-01-27T05:45:00+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T06:27:43+00:00"
+ },
+ {
+ "name": "sebastian/code-unit",
+ "version": "1.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit.git",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/code-unit",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:08:54+00:00"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:30:19+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "4.0.10",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/diff": "^4.0",
+ "sebastian/exporter": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-01-24T09:22:56+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a",
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-22T06:19:30+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "4.0.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc",
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3",
+ "symfony/process": "^4.2 || ^5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T06:30:58+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "5.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "http://www.github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:03:51+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "4.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+ "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "ext-mbstring": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-09-24T06:03:27+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "5.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-uopz": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "http://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-10T07:10:35+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "1.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-22T06:20:34+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "4.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:12:34+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:14:26+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "4.0.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0",
+ "reference": "539c6691e0623af6dc6f9c20384c120f963465a0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-10T06:57:39+00:00"
+ },
+ {
+ "name": "sebastian/resource-operations",
+ "version": "3.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/resource-operations.git",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides a list of PHP built-in functions that operate on resources",
+ "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+ "support": {
+ "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-14T16:00:52+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "source": "https://github.com/sebastianbergmann/type/tree/3.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:13:03+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T06:39:44+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "1.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-11-17T20:03:58+00:00"
}
],
- "packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
- "platform": {},
+ "platform": {
+ "php": ">=8.0"
+ },
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..ef9e8e1
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,69 @@
+services:
+ mariadb:
+ image: mariadb:10.7
+ container_name: utopia-usage-mariadb
+ restart: unless-stopped
+ networks:
+ - usage
+ ports:
+ - "3307:3306"
+ volumes:
+ - mariadb:/var/lib/mysql:rw
+ environment:
+ - MYSQL_ROOT_PASSWORD=password
+ - MYSQL_DATABASE=utopiaTests
+ - MYSQL_USER=user
+ - MYSQL_PASSWORD=password
+ healthcheck:
+ test: ["CMD", "sh", "-c", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 30s
+
+ clickhouse:
+ image: clickhouse/clickhouse-server:25.11-alpine
+ environment:
+ - CLICKHOUSE_DB=default
+ - CLICKHOUSE_USER=default
+ - CLICKHOUSE_PASSWORD=clickhouse
+ - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
+ networks:
+ - usage
+ ports:
+ - "8124:8123"
+ - "9001:9000"
+ healthcheck:
+ test: ["CMD", "clickhouse-client", "--host=localhost", "--port=9000", "-q", "SELECT 1"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 15s
+
+ usage:
+ container_name: utopia-usage
+ build:
+ context: .
+ dockerfile: Dockerfile
+ networks:
+ - usage
+ volumes:
+ - ./tests:/code/tests
+ - ./src:/code/src
+ depends_on:
+ mariadb:
+ condition: service_healthy
+ clickhouse:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "php", "--version"]
+ interval: 5s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
+
+networks:
+ usage:
+
+volumes:
+ mariadb:
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..85a1d76
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
+
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..c781933
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,3 @@
+{
+ "preset": "psr12"
+}
\ No newline at end of file
diff --git a/src/Usage/Adapter.php b/src/Usage/Adapter.php
new file mode 100644
index 0000000..3ad312e
--- /dev/null
+++ b/src/Usage/Adapter.php
@@ -0,0 +1,168 @@
+ Health check result with 'healthy' bool and additional adapter-specific information
+ */
+ abstract public function healthCheck(): array;
+
+ /**
+ * Setup database structure
+ */
+ abstract public function setup(): void;
+
+ /**
+ * Add metrics in batch (raw append).
+ *
+ * Routes rows to the correct table based on the $type parameter.
+ * For events, path/method/status/resource/resourceId are extracted from tags
+ * into dedicated columns; remaining tags stay in the tags JSON.
+ *
+ * @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
+ */
+ abstract public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool;
+
+ /**
+ * Get time series data for metrics with query-time aggregation.
+ *
+ * Groups data by the specified interval (1h or 1d) and applies
+ * SUM for event metrics and argMax for gauge metrics.
+ *
+ * @param array $metrics List of metric names
+ * @param string $interval Aggregation interval: '1h' or '1d'
+ * @param string $startDate Start datetime string
+ * @param string $endDate End datetime string
+ * @param array<\Utopia\Query\Query> $queries Additional query filters
+ * @param bool $zeroFill Whether to fill gaps with zero values
+ * @param string|null $type Metric type: 'event', 'gauge', or null (query both)
+ * @return array}>
+ */
+ abstract public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array;
+
+ /**
+ * Get total value for a single metric.
+ *
+ * Returns sum for event metrics, latest value for gauge metrics.
+ * When $type is null, queries both tables.
+ *
+ * @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;
+
+ /**
+ * Get totals for multiple metrics in a single query.
+ *
+ * Returns sum for event metrics, latest value for gauge metrics.
+ *
+ * @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;
+
+ /**
+ * Purge usage metrics matching the given queries.
+ * When no queries are provided, all metrics are deleted.
+ *
+ * @param array<\Utopia\Query\Query> $queries
+ * @param string|null $type Metric type: 'event', 'gauge', or null (purge both)
+ */
+ abstract public function purge(array $queries = [], ?string $type = null): bool;
+
+ /**
+ * Find metrics using Query objects.
+ *
+ * @param array<\Utopia\Query\Query> $queries
+ * @param string|null $type Metric type: 'event', 'gauge', or null (query both)
+ * @return array
+ */
+ abstract public function find(array $queries = [], ?string $type = null): array;
+
+ /**
+ * Count metrics using Query objects.
+ *
+ * @param array<\Utopia\Query\Query> $queries
+ * @param string|null $type Metric type: 'event', 'gauge', or null (count both)
+ * @return int
+ */
+ abstract public function count(array $queries = [], ?string $type = null): int;
+
+ /**
+ * Sum metric values using Query objects.
+ *
+ * @param array<\Utopia\Query\Query> $queries
+ * @param string $attribute Attribute to sum (default: 'value')
+ * @param string|null $type Metric type: 'event', 'gauge', or null (sum both)
+ * @return int
+ */
+ abstract public function sum(array $queries = [], string $attribute = 'value', ?string $type = null): int;
+
+ /**
+ * Sum event metrics from the pre-aggregated daily table.
+ *
+ * Queries the SummingMergeTree daily materialized view for fast billing/analytics.
+ * Only works for event metrics (gauges are not pre-aggregated).
+ *
+ * @param array<\Utopia\Query\Query> $queries Filters (metric, time range, resource, etc.)
+ * @return array
+ */
+ abstract public function findDaily(array $queries = []): array;
+
+ /**
+ * Sum event metric values from the pre-aggregated daily table.
+ *
+ * @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;
+
+ /**
+ * Sum multiple event metrics from the pre-aggregated daily table in one query.
+ *
+ * @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;
+
+ /**
+ * Set the namespace prefix for table names.
+ *
+ * @param string $namespace
+ * @return self
+ */
+ abstract public function setNamespace(string $namespace): self;
+
+ /**
+ * Set the tenant ID for multi-tenant support.
+ *
+ * @param string|null $tenant
+ * @return self
+ */
+ abstract public function setTenant(?string $tenant): self;
+
+ /**
+ * Enable or disable shared tables mode (multi-tenant with tenant column).
+ *
+ * @param bool $sharedTables
+ * @return self
+ */
+ abstract public function setSharedTables(bool $sharedTables): self;
+}
diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php
new file mode 100644
index 0000000..1647f6f
--- /dev/null
+++ b/src/Usage/Adapter/ClickHouse.php
@@ -0,0 +1,2723 @@
+ Maps interval strings to ClickHouse time functions */
+ private const INTERVAL_FUNCTIONS = [
+ '1h' => 'toStartOfHour',
+ '1d' => 'toStartOfDay',
+ ];
+
+ private string $host;
+
+ private int $port;
+
+ private string $database = self::DEFAULT_DATABASE;
+
+ private string $table = self::DEFAULT_TABLE;
+
+ private string $username;
+
+ private string $password;
+
+ /** @var bool Whether to use HTTPS for ClickHouse HTTP interface */
+ private bool $secure = false;
+
+ private Client $client;
+
+ protected ?string $tenant = null;
+
+ protected bool $sharedTables = false;
+
+ protected string $namespace = '';
+
+ /** @var bool Whether to log queries for debugging */
+ private bool $enableQueryLogging = false;
+
+ /** @var array, duration: float, timestamp: float, success: bool, error?: string}> Query execution log */
+ private array $queryLog = [];
+
+ /** @var bool Whether to enable gzip compression for HTTP requests/responses */
+ private bool $enableCompression = false;
+
+ /** @var bool Whether to enable HTTP keep-alive for connection pooling */
+ private bool $enableKeepAlive = true;
+
+ /** @var int Number of requests made using this adapter instance */
+ private int $requestCount = 0;
+
+ /** @var int Maximum number of retry attempts for failed requests (0 = no retries) */
+ private int $maxRetries = 3;
+
+ /** @var int Initial retry delay in milliseconds (doubles with each retry) */
+ private int $retryDelay = 100;
+
+ /** @var string|null Current operation context for better error messages */
+ private ?string $operationContext = null;
+
+ /** @var bool Whether to enable ClickHouse async inserts (server-side batching) */
+ private bool $asyncInserts = false;
+
+ /** @var bool Whether to wait for async insert confirmation before returning */
+ private bool $asyncInsertWait = true;
+
+ /**
+ * @param string $host ClickHouse host
+ * @param string $username ClickHouse username (default: 'default')
+ * @param string $password ClickHouse password (default: '')
+ * @param int $port ClickHouse HTTP port (default: 8123)
+ * @param bool $secure Whether to use HTTPS (default: false)
+ */
+ public function __construct(
+ string $host,
+ string $username = 'default',
+ string $password = '',
+ int $port = self::DEFAULT_PORT,
+ bool $secure = false
+ ) {
+ $this->validateHost($host);
+ $this->validatePort($port);
+
+ $this->host = $host;
+ $this->port = $port;
+ $this->username = $username;
+ $this->password = $password;
+ $this->secure = $secure;
+
+ // Initialize the HTTP client for connection reuse
+ $this->client = new Client();
+ $this->client->addHeader('X-ClickHouse-User', $this->username);
+ $this->client->addHeader('X-ClickHouse-Key', $this->password);
+ $this->client->setTimeout(30_000); // 30 seconds
+ }
+
+ /**
+ * Set the HTTP request timeout in milliseconds.
+ *
+ * @param int $milliseconds Timeout in milliseconds (min: 1000ms, max: 600000ms)
+ * @return self
+ * @throws Exception If timeout is out of valid range
+ */
+ public function setTimeout(int $milliseconds): self
+ {
+ if ($milliseconds < 1000) {
+ throw new Exception('Timeout must be at least 1000 milliseconds (1 second)');
+ }
+ if ($milliseconds > 600000) {
+ throw new Exception('Timeout cannot exceed 600000 milliseconds (10 minutes)');
+ }
+ $this->client->setTimeout($milliseconds);
+ return $this;
+ }
+
+ /**
+ * Enable or disable query logging for debugging.
+ *
+ * @param bool $enable Whether to enable query logging
+ * @return self
+ */
+ public function enableQueryLogging(bool $enable = true): self
+ {
+ $this->enableQueryLogging = $enable;
+ return $this;
+ }
+
+ /**
+ * Enable or disable gzip compression for HTTP requests/responses.
+ *
+ * @param bool $enable Whether to enable compression
+ * @return self
+ */
+ public function setCompression(bool $enable): self
+ {
+ $this->enableCompression = $enable;
+ return $this;
+ }
+
+ /**
+ * Enable or disable HTTP keep-alive for connection pooling.
+ *
+ * @param bool $enable Whether to enable keep-alive (default: true)
+ * @return self
+ */
+ public function setKeepAlive(bool $enable): self
+ {
+ $this->enableKeepAlive = $enable;
+ return $this;
+ }
+
+ /**
+ * Set maximum number of retry attempts for failed requests.
+ *
+ * @param int $maxRetries Maximum retry attempts (0-10, 0 = no retries)
+ * @return self
+ * @throws Exception If maxRetries is out of valid range
+ */
+ public function setMaxRetries(int $maxRetries): self
+ {
+ if ($maxRetries < 0 || $maxRetries > 10) {
+ throw new Exception('Max retries must be between 0 and 10');
+ }
+ $this->maxRetries = $maxRetries;
+ return $this;
+ }
+
+ /**
+ * Set initial retry delay in milliseconds.
+ * Delay doubles with each retry attempt (exponential backoff).
+ *
+ * @param int $milliseconds Initial delay in milliseconds (10-5000ms)
+ * @return self
+ * @throws Exception If delay is out of valid range
+ */
+ public function setRetryDelay(int $milliseconds): self
+ {
+ if ($milliseconds < 10 || $milliseconds > 5000) {
+ throw new Exception('Retry delay must be between 10 and 5000 milliseconds');
+ }
+ $this->retryDelay = $milliseconds;
+ return $this;
+ }
+
+ /**
+ * Enable or disable ClickHouse async inserts (server-side batching).
+ *
+ * @param bool $enable Whether to enable async inserts
+ * @param bool $waitForConfirmation Whether to wait for server-side flush before returning
+ * @return self
+ */
+ public function setAsyncInserts(bool $enable, bool $waitForConfirmation = true): self
+ {
+ $this->asyncInserts = $enable;
+ $this->asyncInsertWait = $waitForConfirmation;
+ return $this;
+ }
+
+ /**
+ * Get connection statistics for monitoring.
+ *
+ * @return array{request_count: int, keep_alive_enabled: bool, compression_enabled: bool, query_logging_enabled: bool, max_retries: int, retry_delay: int, async_inserts: bool, async_insert_wait: bool}
+ */
+ public function getConnectionStats(): array
+ {
+ return [
+ 'request_count' => $this->requestCount,
+ 'keep_alive_enabled' => $this->enableKeepAlive,
+ 'compression_enabled' => $this->enableCompression,
+ 'query_logging_enabled' => $this->enableQueryLogging,
+ 'max_retries' => $this->maxRetries,
+ 'retry_delay' => $this->retryDelay,
+ 'async_inserts' => $this->asyncInserts,
+ 'async_insert_wait' => $this->asyncInsertWait,
+ ];
+ }
+
+ /**
+ * Get the query execution log.
+ *
+ * @return array, duration: float, timestamp: float, success: bool, error?: string}>
+ */
+ public function getQueryLog(): array
+ {
+ return $this->queryLog;
+ }
+
+ /**
+ * Clear the query execution log.
+ *
+ * @return self
+ */
+ public function clearQueryLog(): self
+ {
+ $this->queryLog = [];
+ return $this;
+ }
+
+ /**
+ * Get adapter name.
+ */
+ public function getName(): string
+ {
+ return 'ClickHouse';
+ }
+
+ /**
+ * Check ClickHouse connection health and get server information.
+ *
+ * @return array{healthy: bool, host: string, port: int, database: string, secure: bool, version?: string, uptime?: int, error?: string, response_time?: float}
+ */
+ public function healthCheck(): array
+ {
+ $this->setOperationContext('healthCheck()');
+
+ $startTime = microtime(true);
+ $result = [
+ 'healthy' => false,
+ 'host' => $this->host,
+ 'port' => $this->port,
+ 'database' => $this->database,
+ 'secure' => $this->secure,
+ ];
+
+ try {
+ // Simple connectivity test
+ $response = $this->query('SELECT 1 as ping FORMAT JSON');
+ $json = json_decode($response, true);
+
+ if (!is_array($json) || !isset($json['data'][0]['ping'])) {
+ $result['error'] = 'Invalid response format';
+ return $result;
+ }
+
+ // Get server version and uptime
+ try {
+ $versionResponse = $this->query('SELECT version() as version, uptime() as uptime FORMAT JSON');
+ $versionJson = json_decode($versionResponse, true);
+
+ if (is_array($versionJson) && isset($versionJson['data'][0])) {
+ $result['version'] = (string) $versionJson['data'][0]['version'];
+ $result['uptime'] = (int) $versionJson['data'][0]['uptime'];
+ }
+ } catch (Exception $e) {
+ // Version info is optional, don't fail health check
+ }
+
+ $result['healthy'] = true;
+ $result['response_time'] = round(microtime(true) - $startTime, 3);
+
+ return $result;
+ } catch (Exception $e) {
+ $result['error'] = $e->getMessage();
+ $result['response_time'] = round(microtime(true) - $startTime, 3);
+ return $result;
+ }
+ }
+
+ /**
+ * Validate host parameter.
+ *
+ * @param string $host
+ * @throws Exception
+ */
+ private function validateHost(string $host): void
+ {
+ $validator = new Hostname();
+ if (!$validator->isValid($host)) {
+ throw new Exception('ClickHouse host is not a valid hostname or IP address');
+ }
+ }
+
+ /**
+ * Validate port parameter.
+ *
+ * @param int $port
+ * @throws Exception
+ */
+ private function validatePort(int $port): void
+ {
+ if ($port < 1 || $port > 65535) {
+ throw new Exception('ClickHouse port must be between 1 and 65535');
+ }
+ }
+
+ /**
+ * Validate identifier (database, table, namespace).
+ *
+ * @param string $identifier
+ * @param string $type Name of the identifier type for error messages
+ * @throws Exception
+ */
+ private function validateIdentifier(string $identifier, string $type = 'Identifier'): void
+ {
+ if (empty($identifier)) {
+ throw new Exception("{$type} cannot be empty");
+ }
+
+ if (strlen($identifier) > 255) {
+ throw new Exception("{$type} cannot exceed 255 characters");
+ }
+
+ if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $identifier)) {
+ throw new Exception("{$type} must start with a letter or underscore and contain only alphanumeric characters and underscores");
+ }
+
+ $keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TABLE', 'DATABASE'];
+ if (in_array(strtoupper($identifier), $keywords, true)) {
+ throw new Exception("{$type} cannot be a reserved SQL keyword");
+ }
+ }
+
+ /**
+ * Escape an identifier for safe use in SQL.
+ *
+ * @param string $identifier
+ * @return string
+ */
+ private function escapeIdentifier(string $identifier): string
+ {
+ return '`' . str_replace('`', '``', $identifier) . '`';
+ }
+
+ /**
+ * Set the namespace for multi-project support.
+ *
+ * @param string $namespace
+ * @return self
+ * @throws Exception
+ */
+ public function setNamespace(string $namespace): self
+ {
+ if (!empty($namespace)) {
+ $this->validateIdentifier($namespace, 'Namespace');
+ }
+ $this->namespace = $namespace;
+ return $this;
+ }
+
+ /**
+ * Set the database name for subsequent operations.
+ *
+ * @param string $database
+ * @return self
+ * @throws Exception
+ */
+ public function setDatabase(string $database): self
+ {
+ $this->validateIdentifier($database, 'Database');
+ $this->database = $database;
+ return $this;
+ }
+
+ /**
+ * Enable or disable HTTPS for ClickHouse HTTP interface.
+ */
+ public function setSecure(bool $secure): self
+ {
+ $this->secure = $secure;
+ return $this;
+ }
+
+ /**
+ * Get the namespace.
+ *
+ * @return string
+ */
+ public function getNamespace(): string
+ {
+ return $this->namespace;
+ }
+
+ /**
+ * Set the tenant ID for multi-tenant support.
+ *
+ * @param string|null $tenant
+ * @return self
+ */
+ public function setTenant(?string $tenant): self
+ {
+ $this->tenant = $tenant;
+ return $this;
+ }
+
+ /**
+ * Get the tenant ID.
+ *
+ * @return string|null
+ */
+ public function getTenant(): ?string
+ {
+ return $this->tenant;
+ }
+
+ /**
+ * Set whether tables are shared across tenants.
+ *
+ * @param bool $sharedTables
+ * @return self
+ */
+ public function setSharedTables(bool $sharedTables): self
+ {
+ $this->sharedTables = $sharedTables;
+ return $this;
+ }
+
+ /**
+ * Get whether tables are shared across tenants.
+ *
+ * @return bool
+ */
+ public function isSharedTables(): bool
+ {
+ return $this->sharedTables;
+ }
+
+ /**
+ * Get the base table name with namespace prefix.
+ *
+ * @return string
+ */
+ private function getTableName(): string
+ {
+ $tableName = $this->table;
+
+ if (!empty($this->namespace)) {
+ $tableName = $this->namespace . '_' . $tableName;
+ }
+
+ return $tableName;
+ }
+
+ /**
+ * Get the events table name.
+ *
+ * @return string
+ */
+ private function getEventsTableName(): string
+ {
+ return $this->getTableName() . '_events';
+ }
+
+ /**
+ * Get the gauges table name.
+ *
+ * @return string
+ */
+ private function getGaugesTableName(): string
+ {
+ return $this->getTableName() . '_gauges';
+ }
+
+ /**
+ * Get the events daily table name.
+ *
+ * @return string
+ */
+ private function getEventsDailyTableName(): string
+ {
+ return $this->getTableName() . '_events_daily';
+ }
+
+ /**
+ * Get the appropriate table name for a given type.
+ *
+ * @param string $type 'event' or 'gauge'
+ * @return string
+ */
+ private function getTableForType(string $type): string
+ {
+ return $type === Usage::TYPE_GAUGE ? $this->getGaugesTableName() : $this->getEventsTableName();
+ }
+
+ /**
+ * Build a fully qualified table reference with database and escaping.
+ *
+ * @param string $tableName The table name (with namespace already applied)
+ * @return string Fully qualified table reference
+ */
+ private function buildTableReference(string $tableName): string
+ {
+ $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
+ return $escapedTable;
+ }
+
+ /**
+ * Log a query execution for debugging purposes.
+ *
+ * @param string $sql SQL query executed
+ * @param array $params Query parameters
+ * @param float $duration Execution duration in seconds
+ * @param bool $success Whether the query succeeded
+ * @param string|null $error Error message if query failed
+ * @param int $retryAttempt Current retry attempt number
+ */
+ private function logQuery(string $sql, array $params, float $duration, bool $success, ?string $error = null, int $retryAttempt = 0): void
+ {
+ if (!$this->enableQueryLogging) {
+ return;
+ }
+
+ $logEntry = [
+ 'sql' => $sql,
+ 'params' => $params,
+ 'duration' => $duration,
+ 'timestamp' => microtime(true),
+ 'success' => $success,
+ ];
+
+ if ($retryAttempt > 0) {
+ $logEntry['retry_attempt'] = $retryAttempt;
+ }
+
+ if ($error !== null) {
+ $logEntry['error'] = $error;
+ }
+
+ $this->queryLog[] = $logEntry;
+ }
+
+ /**
+ * Determine if an error is retryable.
+ *
+ * @param int|null $httpCode HTTP status code if available
+ * @param string $errorMessage Error message
+ * @return bool True if the error is retryable
+ */
+ private function isRetryableError(?int $httpCode, string $errorMessage): bool
+ {
+ if ($httpCode !== null) {
+ if (in_array($httpCode, [408, 429, 500, 502, 503, 504], true)) {
+ return true;
+ }
+ if ($httpCode >= 400 && $httpCode < 500) {
+ return false;
+ }
+ }
+
+ $retryablePatterns = [
+ 'connection', 'timeout', 'timed out', 'refused', 'reset',
+ 'broken pipe', 'network', 'temporary', 'unavailable',
+ ];
+
+ $lowerMessage = strtolower($errorMessage);
+ foreach ($retryablePatterns as $pattern) {
+ if (strpos($lowerMessage, $pattern) !== false) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Set the current operation context for better error messages.
+ *
+ * @param string|null $context
+ * @return void
+ */
+ private function setOperationContext(?string $context): void
+ {
+ $this->operationContext = $context;
+ }
+
+ /**
+ * Execute an operation with automatic retry logic and exponential backoff.
+ *
+ * @template T
+ * @param callable(int): T $operation
+ * @param callable(Exception, int|null): bool $shouldRetry
+ * @param callable(Exception, int): Exception $buildException
+ * @return T
+ * @throws Exception
+ */
+ private function executeWithRetry(callable $operation, callable $shouldRetry, callable $buildException): mixed
+ {
+ $attempt = 0;
+ $lastException = null;
+
+ while ($attempt <= $this->maxRetries) {
+ try {
+ return $operation($attempt);
+ } catch (Exception $e) {
+ $lastException = $e;
+
+ if ($attempt < $this->maxRetries && $shouldRetry($e, $attempt)) {
+ $attempt++;
+ $delay = $this->retryDelay * (2 ** ($attempt - 1));
+ usleep($delay * 1000);
+ continue;
+ }
+
+ throw $buildException($e, $attempt);
+ }
+ }
+
+ throw $buildException(
+ $lastException ?? new Exception('Unknown error occurred'),
+ $this->maxRetries
+ );
+ }
+
+ /**
+ * Build a contextual error message.
+ *
+ * @param string $baseMessage
+ * @param string|null $table
+ * @param string|null $sql
+ * @return string
+ */
+ private function buildErrorMessage(string $baseMessage, ?string $table = null, ?string $sql = null): string
+ {
+ $parts = [];
+
+ if ($this->operationContext !== null) {
+ $parts[] = "Operation: {$this->operationContext}";
+ }
+
+ if ($table !== null) {
+ $parts[] = "Table: {$table}";
+ }
+
+ if ($sql !== null) {
+ $truncatedSql = strlen($sql) > 200 ? substr($sql, 0, 200) . '...' : $sql;
+ $truncatedSql = preg_replace('/\s+/', ' ', $truncatedSql);
+ $parts[] = "Query: {$truncatedSql}";
+ }
+
+ $context = !empty($parts) ? ' [' . implode(', ', $parts) . ']' : '';
+ return $baseMessage . $context;
+ }
+
+ /**
+ * Execute a ClickHouse query via HTTP interface.
+ *
+ * @param string $sql
+ * @param array $params
+ * @return string
+ * @throws Exception
+ */
+ private function query(string $sql, array $params = []): string
+ {
+ return $this->executeWithRetry(
+ function (int $attempt) use ($sql, $params): string {
+ $startTime = microtime(true);
+ $scheme = $this->secure ? 'https' : 'http';
+ $url = "{$scheme}://{$this->host}:{$this->port}/";
+
+ $this->client->addHeader('X-ClickHouse-Database', $this->database);
+
+ if ($this->enableKeepAlive) {
+ $this->client->addHeader('Connection', 'keep-alive');
+ } else {
+ $this->client->addHeader('Connection', 'close');
+ }
+
+ if ($this->enableCompression) {
+ $this->client->addHeader('Accept-Encoding', 'gzip');
+ }
+
+ if ($attempt === 0) {
+ $this->requestCount++;
+ }
+
+ $body = ['query' => $sql];
+ foreach ($params as $key => $value) {
+ $body['param_' . $key] = $this->formatParamValue($value);
+ }
+
+ $response = $this->client->fetch(
+ url: $url,
+ method: Client::METHOD_POST,
+ body: $body
+ );
+ $httpCode = $response->getStatusCode();
+
+ if ($httpCode !== 200) {
+ $bodyStr = $response->getBody();
+ $bodyStr = is_string($bodyStr) ? $bodyStr : '';
+ $duration = microtime(true) - $startTime;
+ $baseError = "ClickHouse query failed with HTTP {$httpCode}: {$bodyStr}";
+ $errorMsg = $this->buildErrorMessage($baseError, null, $sql);
+ $this->logQuery($sql, $params, $duration, false, $errorMsg, $attempt);
+
+ throw new Exception($errorMsg . '|HTTP_CODE:' . $httpCode);
+ }
+
+ $body = $response->getBody();
+ $result = is_string($body) ? $body : '';
+ $duration = microtime(true) - $startTime;
+ $this->logQuery($sql, $params, $duration, true, null, $attempt);
+ return $result;
+ },
+ function (Exception $e, ?int $httpCode): bool {
+ $exceptionHttpCode = null;
+ if (preg_match('/\|HTTP_CODE:(\d+)$/', $e->getMessage(), $matches)) {
+ $exceptionHttpCode = (int) $matches[1];
+ }
+ return $this->isRetryableError($exceptionHttpCode, $e->getMessage());
+ },
+ function (Exception $e, int $attempt) use ($sql): Exception {
+ $cleanMessage = preg_replace('/\|HTTP_CODE:\d+$/', '', $e->getMessage());
+ $cleanMessage = is_string($cleanMessage) ? $cleanMessage : $e->getMessage();
+
+ if (strpos($cleanMessage, '[Operation:') !== false) {
+ return new Exception($cleanMessage, 0, $e);
+ }
+
+ $baseError = "ClickHouse query execution failed after " . ($attempt + 1) . " attempt(s): {$cleanMessage}";
+ $errorMsg = $this->buildErrorMessage($baseError, null, $sql);
+ return new Exception($errorMsg, 0, $e);
+ }
+ );
+ }
+
+ /**
+ * Execute a ClickHouse INSERT using JSONEachRow format.
+ *
+ * @param string $table Table name
+ * @param array $data Array of JSON strings (one per row)
+ * @throws Exception
+ */
+ private function insert(string $table, array $data): void
+ {
+ if (empty($data)) {
+ return;
+ }
+
+ $this->executeWithRetry(
+ function (int $attempt) use ($table, $data): void {
+ $startTime = microtime(true);
+ $scheme = $this->secure ? 'https' : 'http';
+ $escapedTable = $this->escapeIdentifier($table);
+
+ $queryParams = ['query' => "INSERT INTO {$escapedTable} FORMAT JSONEachRow"];
+ if ($this->asyncInserts) {
+ $queryParams['async_insert'] = '1';
+ $queryParams['wait_for_async_insert'] = $this->asyncInsertWait ? '1' : '0';
+ }
+ $url = "{$scheme}://{$this->host}:{$this->port}/?" . http_build_query($queryParams);
+
+ $this->client->addHeader('X-ClickHouse-Database', $this->database);
+ $this->client->addHeader('Content-Type', 'application/x-ndjson');
+
+ if ($this->enableKeepAlive) {
+ $this->client->addHeader('Connection', 'keep-alive');
+ } else {
+ $this->client->addHeader('Connection', 'close');
+ }
+
+ if ($this->enableCompression) {
+ $this->client->addHeader('Accept-Encoding', 'gzip');
+ }
+
+ if ($attempt === 0) {
+ $this->requestCount++;
+ }
+
+ $body = implode("\n", $data);
+
+ $sql = "INSERT INTO {$escapedTable} FORMAT JSONEachRow";
+ $params = ['rows' => count($data), 'bytes' => strlen($body)];
+
+ try {
+ $response = $this->client->fetch(
+ url: $url,
+ method: Client::METHOD_POST,
+ body: $body
+ );
+
+ $httpCode = $response->getStatusCode();
+
+ if ($httpCode !== 200) {
+ $bodyStr = $response->getBody();
+ $bodyStr = is_string($bodyStr) ? $bodyStr : '';
+ $duration = microtime(true) - $startTime;
+ $rowCount = count($data);
+ $baseError = "ClickHouse insert failed with HTTP {$httpCode}: {$bodyStr}";
+ $errorMsg = $this->buildErrorMessage($baseError, $table, "INSERT INTO {$table} ({$rowCount} rows)");
+ $this->logQuery($sql, $params, $duration, false, $errorMsg, $attempt);
+
+ throw new Exception($errorMsg . '|HTTP_CODE:' . $httpCode);
+ }
+
+ $duration = microtime(true) - $startTime;
+ $this->logQuery($sql, $params, $duration, true, null, $attempt);
+ } finally {
+ $this->client->removeHeader('Content-Type');
+ }
+ },
+ function (Exception $e, ?int $httpCode): bool {
+ $exceptionHttpCode = null;
+ if (preg_match('/\|HTTP_CODE:(\d+)$/', $e->getMessage(), $matches)) {
+ $exceptionHttpCode = (int) $matches[1];
+ }
+ return $this->isRetryableError($exceptionHttpCode, $e->getMessage());
+ },
+ function (Exception $e, int $attempt) use ($table, $data): Exception {
+ $cleanMessage = preg_replace('/\|HTTP_CODE:\d+$/', '', $e->getMessage());
+ $cleanMessage = is_string($cleanMessage) ? $cleanMessage : $e->getMessage();
+
+ if (strpos($cleanMessage, '[Operation:') !== false) {
+ return new Exception($cleanMessage, 0, $e);
+ }
+
+ $rowCount = count($data);
+ $baseError = "ClickHouse insert execution failed after " . ($attempt + 1) . " attempt(s): {$cleanMessage}";
+ $errorMsg = $this->buildErrorMessage($baseError, $table, "INSERT INTO {$table} ({$rowCount} rows)");
+ return new Exception($errorMsg, 0, $e);
+ }
+ );
+ }
+
+ /**
+ * Format a parameter value for safe transmission to ClickHouse.
+ *
+ * @param mixed $value
+ * @return string
+ */
+ private function formatParamValue(mixed $value): string
+ {
+ if (is_int($value) || is_float($value)) {
+ return (string) $value;
+ }
+
+ if ($value === null) {
+ return '';
+ }
+
+ if (is_bool($value)) {
+ return $value ? '1' : '0';
+ }
+
+ if (is_array($value)) {
+ $encoded = json_encode($value);
+ return is_string($encoded) ? $encoded : '';
+ }
+
+ if (is_string($value)) {
+ return $value;
+ }
+
+ if (is_object($value) && method_exists($value, '__toString')) {
+ return (string) $value;
+ }
+
+ return '';
+ }
+
+ /**
+ * Setup ClickHouse table structure.
+ *
+ * Creates:
+ * 1. Events table (MergeTree) with event-specific columns
+ * 2. Events daily table (SummingMergeTree) for pre-aggregation
+ * 3. Events daily materialized view
+ * 4. Gauges table (MergeTree) with simple schema
+ *
+ * @throws Exception
+ */
+ public function setup(): void
+ {
+ $this->setOperationContext('setup()');
+
+ // Create database if not exists
+ $escapedDatabase = $this->escapeIdentifier($this->database);
+ $createDbSql = "CREATE DATABASE IF NOT EXISTS {$escapedDatabase}";
+ $this->query($createDbSql);
+
+ // --- Events table ---
+ $this->createTable(
+ $this->getEventsTableName(),
+ 'event',
+ $this->getEventIndexes()
+ );
+
+ // --- Events daily table (SummingMergeTree) ---
+ $this->createDailyTable();
+
+ // --- Events daily materialized view ---
+ $this->createDailyMaterializedView();
+
+ // --- Gauges table ---
+ $this->createTable(
+ $this->getGaugesTableName(),
+ 'gauge',
+ $this->getGaugeIndexes()
+ );
+ }
+
+ /**
+ * Create a MergeTree table for the given type.
+ *
+ * @param string $tableName
+ * @param string $type 'event' or 'gauge'
+ * @param array> $indexes
+ * @throws Exception
+ */
+ private function createTable(string $tableName, string $type, array $indexes): void
+ {
+ $columns = ['id String'];
+
+ foreach ($this->getAttributes($type) as $attribute) {
+ /** @var string $id */
+ $id = $attribute['$id'];
+
+ if ($id === 'time') {
+ $columns[] = 'time DateTime64(3)';
+ } else {
+ $columns[] = $this->getColumnDefinition($id, $type);
+ }
+ }
+
+ // Add tenant column only if tables are shared across tenants
+ if ($this->sharedTables) {
+ $columns[] = 'tenant Nullable(String)';
+ }
+
+ // Build indexes
+ $indexDefs = [];
+ foreach ($indexes as $index) {
+ /** @var string $indexName */
+ $indexName = $index['$id'];
+ /** @var array $attributes */
+ $attributes = $index['attributes'];
+ $escapedIndexName = $this->escapeIdentifier($indexName);
+ $escapedAttributes = array_map(fn ($attr) => $this->escapeIdentifier($attr), $attributes);
+ $attributeList = implode(', ', $escapedAttributes);
+ $indexDefs[] = "INDEX {$escapedIndexName} ({$attributeList}) TYPE bloom_filter GRANULARITY 1";
+ }
+
+ $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
+
+ $columnDefs = implode(",\n ", $columns);
+ $indexDefsStr = !empty($indexDefs) ? ",\n " . implode(",\n ", $indexDefs) : '';
+
+ $orderByExpr = $this->sharedTables ? '(tenant, id)' : '(id)';
+
+ $createTableSql = "
+ CREATE TABLE IF NOT EXISTS {$escapedDatabaseAndTable} (
+ {$columnDefs}{$indexDefsStr}
+ )
+ ENGINE = MergeTree()
+ ORDER BY {$orderByExpr}
+ PARTITION BY toYYYYMM(time)
+ SETTINGS index_granularity = 8192, allow_nullable_key = 1
+ ";
+
+ $this->query($createTableSql);
+ }
+
+ /**
+ * Create the events daily SummingMergeTree table.
+ *
+ * Minimal schema: metric, value, time, tenant.
+ * Resource-level breakdown uses the raw events table.
+ *
+ * @throws Exception
+ */
+ private function createDailyTable(): void
+ {
+ $dailyTableName = $this->getEventsDailyTableName();
+ $escapedDailyTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyTableName);
+
+ $columns = [
+ 'metric String',
+ 'value Int64',
+ 'time DateTime64(3)',
+ ];
+
+ if ($this->sharedTables) {
+ $columns[] = 'tenant Nullable(String)';
+ }
+
+ $indexes = [
+ 'INDEX index_metric (metric) TYPE bloom_filter GRANULARITY 1',
+ 'INDEX index_time (time) TYPE bloom_filter GRANULARITY 1',
+ ];
+
+ $columnDefs = implode(",\n ", $columns);
+ $indexDefsStr = ",\n " . implode(",\n ", $indexes);
+
+ $dailyOrderBy = $this->sharedTables ? '(tenant, metric, time)' : '(metric, time)';
+
+ $createDailyTableSql = "
+ CREATE TABLE IF NOT EXISTS {$escapedDailyTable} (
+ {$columnDefs}{$indexDefsStr}
+ )
+ ENGINE = SummingMergeTree()
+ ORDER BY {$dailyOrderBy}
+ PARTITION BY toYYYYMM(time)
+ SETTINGS index_granularity = 8192, allow_nullable_key = 1
+ ";
+
+ $this->query($createDailyTableSql);
+ }
+
+ /**
+ * Create the materialized view for daily event aggregation.
+ *
+ * @throws Exception
+ */
+ private function createDailyMaterializedView(): void
+ {
+ $eventsTable = $this->getEventsTableName();
+ $dailyTableName = $this->getEventsDailyTableName();
+ $dailyMvName = $this->getTableName() . '_events_daily_mv';
+
+ $escapedEventsTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($eventsTable);
+ $escapedDailyTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyTableName);
+ $escapedDailyMv = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyMvName);
+
+ if ($this->sharedTables) {
+ $innerSelect = "metric, tenant, sum(value) as value, toStartOfDay(time) as d";
+ $innerGroupBy = "metric, tenant, d";
+ $outerSelect = "metric, value, d as time, tenant";
+ } else {
+ $innerSelect = "metric, sum(value) as value, toStartOfDay(time) as d";
+ $innerGroupBy = "metric, d";
+ $outerSelect = "metric, value, d as time";
+ }
+
+ $createDailyMvSql = "
+ CREATE MATERIALIZED VIEW IF NOT EXISTS {$escapedDailyMv}
+ TO {$escapedDailyTable}
+ AS SELECT {$outerSelect}
+ FROM (
+ SELECT {$innerSelect}
+ FROM {$escapedEventsTable}
+ GROUP BY {$innerGroupBy}
+ )
+ ";
+
+ $this->query($createDailyMvSql);
+ }
+
+ /**
+ * Validate that an attribute name exists in the schema for a given type.
+ *
+ * @param string $attributeName
+ * @param string $type 'event' or 'gauge'
+ * @return bool
+ * @throws Exception
+ */
+ private function validateAttributeName(string $attributeName, string $type = 'event'): bool
+ {
+ if ($attributeName === 'id') {
+ return true;
+ }
+
+ if ($attributeName === 'tenant' && $this->sharedTables) {
+ return true;
+ }
+
+ foreach ($this->getAttributes($type) as $attribute) {
+ if ($attribute['$id'] === $attributeName) {
+ return true;
+ }
+ }
+
+ // Also check the other type's attributes for cross-table queries
+ $otherType = $type === 'event' ? 'gauge' : 'event';
+ foreach ($this->getAttributes($otherType) as $attribute) {
+ if ($attribute['$id'] === $attributeName) {
+ return true;
+ }
+ }
+
+ throw new Exception("Invalid attribute name: {$attributeName}");
+ }
+
+ /**
+ * Format datetime for ClickHouse compatibility.
+ *
+ * @param \DateTime|string|null $dateTime
+ * @return string
+ * @throws Exception
+ */
+ private function formatDateTime($dateTime): string
+ {
+ if ($dateTime === null) {
+ return (new \DateTime())->format('Y-m-d H:i:s.v');
+ }
+
+ if ($dateTime instanceof \DateTime) {
+ return $dateTime->format('Y-m-d H:i:s.v');
+ }
+
+ if (is_string($dateTime)) {
+ try {
+ $dt = new \DateTime($dateTime);
+ return $dt->format('Y-m-d H:i:s.v');
+ } catch (\Exception $e) {
+ throw new Exception("Invalid datetime string: {$dateTime}");
+ }
+ }
+
+ /** @phpstan-ignore-next-line */
+ throw new Exception("Invalid datetime value type: " . gettype($dateTime));
+ }
+
+ /**
+ * Get ClickHouse type for an attribute.
+ *
+ * @param string $id Attribute identifier
+ * @param string $type 'event' or 'gauge'
+ * @return string ClickHouse type
+ * @throws Exception
+ */
+ private function getColumnType(string $id, string $type = 'event'): string
+ {
+ $attribute = $this->getAttribute($id, $type);
+ if (!$attribute) {
+ throw new Exception("Attribute {$id} not found in {$type} schema");
+ }
+
+ // Country uses LowCardinality for efficient storage of low-cardinality values
+ if ($id === 'country') {
+ return 'LowCardinality(Nullable(String))';
+ }
+
+ $attributeType = is_string($attribute['type'] ?? null) ? $attribute['type'] : 'string';
+ $baseType = match ($attributeType) {
+ 'integer' => 'Int64',
+ 'float' => 'Float64',
+ 'boolean' => 'UInt8',
+ 'datetime' => 'DateTime64(3)',
+ default => 'String',
+ };
+
+ return !$attribute['required'] ? 'Nullable(' . $baseType . ')' : $baseType;
+ }
+
+ protected function getColumnDefinition(string $id, string $type = 'event'): string
+ {
+ $chType = $this->getColumnType($id, $type);
+ $escapedId = $this->escapeIdentifier($id);
+ return "{$escapedId} {$chType}";
+ }
+
+ /**
+ * Validate metric data for batch operations.
+ *
+ * @param string $metric Metric name
+ * @param int $value Metric value
+ * @param string $type Metric type ('event' or 'gauge')
+ * @param array $tags Tags
+ * @param int|null $metricIndex Index for batch error messages
+ * @throws Exception
+ */
+ private function validateMetricData(string $metric, int $value, string $type, array $tags, ?int $metricIndex = null): void
+ {
+ $prefix = $metricIndex !== null ? "Metric #{$metricIndex}: " : '';
+
+ if (empty($metric)) {
+ throw new Exception($prefix . 'Metric cannot be empty');
+ }
+
+ if (strlen($metric) > 255) {
+ throw new Exception($prefix . 'Metric exceeds maximum size of 255 characters');
+ }
+
+ if ($value < 0) {
+ throw new Exception($prefix . 'Value cannot be negative');
+ }
+
+ if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) {
+ throw new \InvalidArgumentException($prefix . "Invalid type '{$type}'. Allowed: " . Usage::TYPE_EVENT . ', ' . Usage::TYPE_GAUGE);
+ }
+
+ if (!is_array($tags)) {
+ throw new Exception($prefix . 'Tags must be an array');
+ }
+ }
+
+ /**
+ * Validate all metrics in a batch before processing.
+ *
+ * @param array> $metrics
+ * @param string $type The target table type
+ * @throws Exception
+ */
+ private function validateMetricsBatch(array $metrics, string $type): void
+ {
+ foreach ($metrics as $index => $metricData) {
+ if (!isset($metricData['metric'])) {
+ throw new Exception("Metric #{$index}: 'metric' is required");
+ }
+ if (!isset($metricData['value'])) {
+ throw new Exception("Metric #{$index}: 'value' is required");
+ }
+
+ $metric = $metricData['metric'];
+ $value = $metricData['value'];
+
+ if (!is_string($metric)) {
+ throw new Exception("Metric #{$index}: 'metric' must be a string, got " . gettype($metric));
+ }
+ if (!is_int($value)) {
+ throw new Exception("Metric #{$index}: 'value' must be an integer, got " . gettype($value));
+ }
+
+ /** @var array */
+ $tags = $metricData['tags'] ?? [];
+ $this->validateMetricData($metric, $value, $type, $tags, $index);
+
+ 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));
+ }
+ }
+ }
+ }
+
+ /**
+ * Add metrics in batch (raw append to appropriate table).
+ *
+ * For events: extracts path/method/status/resource/resourceId from tags into
+ * dedicated columns; remaining tags stay in the tags JSON column.
+ * For gauges: simple metric/value/time/tags insert.
+ *
+ * @param array> $metrics
+ * @param string $type Metric type: 'event' or 'gauge'
+ * @param int $batchSize Maximum number of metrics per INSERT statement
+ * @throws Exception
+ */
+ public function addBatch(array $metrics, string $type = Usage::TYPE_EVENT, int $batchSize = self::INSERT_BATCH_SIZE): bool
+ {
+ if (empty($metrics)) {
+ return true;
+ }
+
+ $this->setOperationContext('addBatch()');
+
+ // Validate all metrics before processing
+ $this->validateMetricsBatch($metrics, $type);
+
+ $batchSize = \min(self::INSERT_BATCH_SIZE, \max(1, $batchSize));
+
+ $tableName = $this->getTableForType($type);
+
+ foreach (\array_chunk($metrics, $batchSize) as $metricsBatch) {
+ $rows = [];
+
+ foreach ($metricsBatch as $metricData) {
+ /** @var string $metric */
+ $metric = $metricData['metric'];
+ /** @var int $value */
+ $value = $metricData['value'];
+ /** @var array $tags */
+ $tags = $metricData['tags'] ?? [];
+
+ $tenant = $this->sharedTables ? $this->resolveTenantFromMetric($metricData) : null;
+
+ if ($type === Usage::TYPE_EVENT) {
+ // Extract event-specific columns from tags into dedicated columns
+ $eventColumns = [];
+ foreach (Metric::EVENT_COLUMNS as $col) {
+ if (isset($tags[$col])) {
+ $tagValue = $tags[$col];
+ $eventColumns[$col] = is_string($tagValue) ? $tagValue : (is_scalar($tagValue) ? (string) $tagValue : null);
+ unset($tags[$col]);
+ } else {
+ $eventColumns[$col] = null;
+ }
+ }
+
+ ksort($tags);
+
+ $row = array_merge([
+ 'id' => $this->generateId(),
+ 'metric' => $metric,
+ 'value' => $value,
+ 'time' => $this->formatDateTime(null),
+ ], $eventColumns, [
+ 'tags' => $tags,
+ ]);
+ } else {
+ // Gauge: simple schema
+ ksort($tags);
+
+ $row = [
+ 'id' => $this->generateId(),
+ 'metric' => $metric,
+ 'value' => $value,
+ 'time' => $this->formatDateTime(null),
+ 'tags' => $tags,
+ ];
+ }
+
+ if ($this->sharedTables) {
+ $row['tenant'] = $tenant;
+ }
+
+ $encoded = json_encode($row);
+ if ($encoded === false) {
+ throw new Exception("Failed to JSON encode metric row: " . json_last_error_msg());
+ }
+ $rows[] = $encoded;
+ }
+
+ $this->insert($tableName, $rows);
+ }
+
+ return true;
+ }
+
+ /**
+ * Resolve tenant for a single metric entry.
+ *
+ * @param array $metricData
+ */
+ private function resolveTenantFromMetric(array $metricData): ?string
+ {
+ $tenant = array_key_exists('$tenant', $metricData) ? $metricData['$tenant'] : $this->tenant;
+
+ if ($tenant === null) {
+ return null;
+ }
+
+ if (is_string($tenant)) {
+ return $tenant;
+ }
+
+ if (is_int($tenant) || is_float($tenant)) {
+ return (string) $tenant;
+ }
+
+ return null;
+ }
+
+ /**
+ * Find metrics using Query objects.
+ * When $type is null, queries both tables with UNION ALL.
+ *
+ * @param array $queries
+ * @param string|null $type 'event', 'gauge', or null (both)
+ * @return array
+ * @throws Exception
+ */
+ public function find(array $queries = [], ?string $type = null): array
+ {
+ $this->setOperationContext('find()');
+
+ if ($type !== null) {
+ return $this->findFromTable($queries, $type);
+ }
+
+ // Query both tables with UNION ALL
+ $events = $this->findFromTable($queries, Usage::TYPE_EVENT);
+ $gauges = $this->findFromTable($queries, Usage::TYPE_GAUGE);
+
+ return array_merge($events, $gauges);
+ }
+
+ /**
+ * Find metrics from a specific table.
+ *
+ * When a `groupByInterval` query is present, switches to aggregated mode:
+ * - Events: SELECT metric, SUM(value) as value, toStartOfInterval(time, INTERVAL ...) as time
+ * - Gauges: SELECT metric, argMax(value, time) as value, toStartOfInterval(time, INTERVAL ...) as time
+ * Results are grouped by metric and time bucket, ordered by time ASC.
+ *
+ * @param array $queries
+ * @param string $type 'event' or 'gauge'
+ * @return array
+ * @throws Exception
+ */
+ private function findFromTable(array $queries, string $type): array
+ {
+ $tableName = $this->getTableForType($type);
+ $fromTable = $this->buildTableReference($tableName);
+
+ $parsed = $this->parseQueries($queries, $type);
+
+ // Check if groupByInterval is requested
+ if (isset($parsed['groupByInterval'])) {
+ return $this->findAggregatedFromTable($parsed, $fromTable, $type);
+ }
+
+ $selectColumns = $this->getSelectColumns($type);
+
+ $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']);
+ $whereClause = $whereData['clause'];
+ $parsed['params'] = $whereData['params'];
+
+ $orderClause = '';
+ if (!empty($parsed['orderBy'])) {
+ $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']);
+ }
+
+ $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : '';
+ $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : '';
+
+ $sql = "
+ SELECT {$selectColumns}
+ FROM {$fromTable}{$whereClause}{$orderClause}{$limitClause}{$offsetClause}
+ FORMAT JSON
+ ";
+
+ $result = $this->query($sql, $parsed['params']);
+
+ return $this->parseResults($result, $type);
+ }
+
+ /**
+ * Find aggregated metrics from a table using time-bucketed grouping.
+ *
+ * Produces SQL like:
+ * SELECT metric, SUM(value) as value,
+ * toStartOfInterval(time, INTERVAL 1 HOUR) as time
+ * FROM table WHERE ... GROUP BY metric, time ORDER BY time ASC
+ *
+ * @param array $parsed Parsed query data from parseQueries()
+ * @param string $fromTable Fully qualified table reference
+ * @param string $type 'event' or 'gauge'
+ * @return array
+ * @throws Exception
+ */
+ private function findAggregatedFromTable(array $parsed, string $fromTable, string $type): array
+ {
+ /** @var string $interval */
+ $interval = $parsed['groupByInterval'];
+ $intervalSql = UsageQuery::VALID_INTERVALS[$interval];
+
+ // Choose aggregation function based on metric type
+ $valueExpr = $type === Usage::TYPE_GAUGE
+ ? 'argMax(value, time) as value'
+ : 'SUM(value) as value';
+
+ // Use 'bucket' alias to avoid collision with the raw 'time' column,
+ // then alias back to 'time' in outer context for consistent Metric parsing.
+ $timeBucketExpr = "toStartOfInterval(time, {$intervalSql})";
+
+ $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']);
+ $whereClause = $whereData['clause'];
+ $params = $whereData['params'];
+
+ // Use custom ORDER BY if specified, otherwise default to bucket ASC
+ $orderClause = ' ORDER BY bucket ASC';
+ if (!empty($parsed['orderBy'])) {
+ $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']);
+ }
+
+ $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : '';
+ $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : '';
+
+ $sql = "
+ SELECT metric, {$valueExpr}, {$timeBucketExpr} as bucket
+ FROM {$fromTable}{$whereClause}
+ GROUP BY metric, bucket{$orderClause}{$limitClause}{$offsetClause}
+ FORMAT JSON
+ ";
+
+ $result = $this->query($sql, $params);
+
+ return $this->parseAggregatedResults($result, $type);
+ }
+
+ /**
+ * Parse ClickHouse JSON results from an aggregated (groupByInterval) query into Metric array.
+ *
+ * Maps the 'bucket' column back to 'time' for consistent Metric objects.
+ *
+ * @param string $result Raw JSON response from ClickHouse
+ * @param string $type 'event' or 'gauge'
+ * @return array
+ */
+ private function parseAggregatedResults(string $result, string $type = 'event'): array
+ {
+ if (empty(trim($result))) {
+ return [];
+ }
+
+ $json = json_decode($result, true);
+
+ if (!is_array($json) || !isset($json['data']) || !is_array($json['data'])) {
+ return [];
+ }
+
+ $rows = $json['data'];
+ $metrics = [];
+
+ foreach ($rows as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+
+ $document = [];
+
+ foreach ($row as $key => $value) {
+ if ($key === 'bucket') {
+ // Map 'bucket' back to 'time' for consistent Metric objects
+ $parsedTime = (string) $value;
+ if (strpos($parsedTime, 'T') === false) {
+ $parsedTime = str_replace(' ', 'T', $parsedTime) . '+00:00';
+ }
+ $document['time'] = $parsedTime;
+ } elseif ($key === 'value') {
+ $document[$key] = $value !== null ? (int) $value : null;
+ } else {
+ $document[$key] = $value;
+ }
+ }
+
+ // Set the type based on which table we queried
+ $document['type'] = $type;
+
+ $metrics[] = new Metric($document);
+ }
+
+ return $metrics;
+ }
+
+ /**
+ * Count metrics using Query objects.
+ *
+ * @param array $queries
+ * @param string|null $type 'event', 'gauge', or null (both)
+ * @return int
+ * @throws Exception
+ */
+ public function count(array $queries = [], ?string $type = null): int
+ {
+ $this->setOperationContext('count()');
+
+ if ($type !== null) {
+ return $this->countFromTable($queries, $type);
+ }
+
+ // Count from both tables
+ return $this->countFromTable($queries, Usage::TYPE_EVENT)
+ + $this->countFromTable($queries, Usage::TYPE_GAUGE);
+ }
+
+ /**
+ * Count metrics from a specific table.
+ *
+ * @param array $queries
+ * @param string $type
+ * @return int
+ * @throws Exception
+ */
+ private function countFromTable(array $queries, string $type): int
+ {
+ $tableName = $this->getTableForType($type);
+ $fromTable = $this->buildTableReference($tableName);
+
+ $parsed = $this->parseQueries($queries, $type);
+
+ $params = $parsed['params'];
+ unset($params['limit'], $params['offset']);
+
+ $whereData = $this->buildWhereClause($parsed['filters'], $params);
+ $whereClause = $whereData['clause'];
+ $params = $whereData['params'];
+
+ $sql = "
+ SELECT COUNT(*) as total FROM {$fromTable}{$whereClause}
+ FORMAT JSON
+ ";
+
+ $result = $this->query($sql, $params);
+ $json = json_decode($result, true);
+
+ if (!is_array($json) || !isset($json['data'][0]['total'])) {
+ return 0;
+ }
+
+ return (int) $json['data'][0]['total'];
+ }
+
+ /**
+ * Sum metric values using Query objects.
+ *
+ * @param array $queries
+ * @param string $attribute Attribute to sum (default: 'value')
+ * @param string|null $type 'event', 'gauge', or null (both)
+ * @return int
+ * @throws Exception
+ */
+ public function sum(array $queries = [], string $attribute = 'value', ?string $type = null): int
+ {
+ $this->setOperationContext('sum()');
+
+ if ($type !== null) {
+ return $this->sumFromTable($queries, $attribute, $type);
+ }
+
+ // Sum from both tables
+ return $this->sumFromTable($queries, $attribute, Usage::TYPE_EVENT)
+ + $this->sumFromTable($queries, $attribute, Usage::TYPE_GAUGE);
+ }
+
+ /**
+ * Sum metric values from a specific table.
+ *
+ * @param array $queries
+ * @param string $attribute
+ * @param string $type
+ * @return int
+ * @throws Exception
+ */
+ private function sumFromTable(array $queries, string $attribute, string $type): int
+ {
+ $tableName = $this->getTableForType($type);
+ $fromTable = $this->buildTableReference($tableName);
+
+ $this->validateAttributeName($attribute, $type);
+ $escapedAttribute = $this->escapeIdentifier($attribute);
+
+ $parsed = $this->parseQueries($queries, $type);
+
+ $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']);
+ $whereClause = $whereData['clause'];
+ $params = $whereData['params'];
+
+ $sql = "
+ SELECT sum({$escapedAttribute}) as total FROM {$fromTable}{$whereClause}
+ FORMAT JSON
+ ";
+
+ $result = $this->query($sql, $params);
+
+ $json = json_decode($result, true);
+
+ if (!is_array($json) || !isset($json['data'][0]['total'])) {
+ return 0;
+ }
+
+ return (int) $json['data'][0]['total'];
+ }
+
+ /**
+ * Find event metrics from the pre-aggregated daily table.
+ *
+ * @param array $queries
+ * @return array
+ * @throws Exception
+ */
+ public function findDaily(array $queries = []): array
+ {
+ $this->setOperationContext('findDaily()');
+
+ $fromTable = $this->buildTableReference($this->getEventsDailyTableName());
+
+ // Daily table has limited columns — only allow metric, value, time, resource, resourceId, tenant
+ $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT);
+ $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']);
+
+ $dailyColumns = ['metric', 'value', 'time'];
+ if ($this->sharedTables) {
+ $dailyColumns[] = 'tenant';
+ }
+ $selectColumns = implode(', ', array_map(fn ($c) => $this->escapeIdentifier($c), $dailyColumns));
+
+ $orderClause = !empty($parsed['orderBy']) ? ' ORDER BY ' . implode(', ', $parsed['orderBy']) : '';
+ $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : '';
+ $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : '';
+
+ $sql = "SELECT {$selectColumns} FROM {$fromTable}{$whereData['clause']}{$orderClause}{$limitClause}{$offsetClause} FORMAT JSON";
+
+ return $this->parseResults($this->query($sql, $whereData['params']), Usage::TYPE_EVENT);
+ }
+
+ /**
+ * Sum event metric values from the pre-aggregated daily table.
+ *
+ * @param array $queries
+ * @param string $attribute Attribute to sum (default: 'value')
+ * @return int
+ * @throws Exception
+ */
+ public function sumDaily(array $queries = [], string $attribute = 'value'): int
+ {
+ $this->setOperationContext('sumDaily()');
+
+ $fromTable = $this->buildTableReference($this->getEventsDailyTableName());
+ $this->validateAttributeName($attribute, Usage::TYPE_EVENT);
+ $escapedAttribute = $this->escapeIdentifier($attribute);
+
+ $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT);
+ $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']);
+
+ $sql = "SELECT sum({$escapedAttribute}) as total FROM {$fromTable}{$whereData['clause']} FORMAT JSON";
+
+ $result = $this->query($sql, $whereData['params']);
+ $json = json_decode($result, true);
+
+ return (is_array($json) && isset($json['data'][0]['total'])) ? (int) $json['data'][0]['total'] : 0;
+ }
+
+ /**
+ * Sum multiple event metrics from the pre-aggregated daily table in one query.
+ *
+ * @param array $metrics
+ * @param array $queries
+ * @return array
+ * @throws Exception
+ */
+ public function sumDailyBatch(array $metrics, array $queries = []): array
+ {
+ if (empty($metrics)) {
+ return [];
+ }
+
+ $this->setOperationContext('sumDailyBatch()');
+
+ $totals = \array_fill_keys($metrics, 0);
+
+ $fromTable = $this->buildTableReference($this->getEventsDailyTableName());
+
+ // Build metric IN params
+ $metricParams = [];
+ $metricPlaceholders = [];
+ foreach ($metrics as $i => $metric) {
+ $paramName = 'metric_' . $i;
+ $metricParams[$paramName] = $metric;
+ $metricPlaceholders[] = "{{$paramName}:String}";
+ }
+ $metricInClause = implode(', ', $metricPlaceholders);
+
+ $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT);
+ $params = array_merge($metricParams, $parsed['params']);
+
+ $whereData = $this->buildWhereClause($parsed['filters'], $params);
+ $whereClause = $whereData['clause'];
+ $params = $whereData['params'];
+
+ $metricFilter = $this->escapeIdentifier('metric') . " IN ({$metricInClause})";
+ $whereClause = !empty($whereClause)
+ ? $whereClause . ' AND ' . $metricFilter
+ : ' WHERE ' . $metricFilter;
+
+ $sql = "
+ SELECT metric, SUM(value) as total
+ FROM {$fromTable}{$whereClause}
+ GROUP BY metric
+ FORMAT JSON
+ ";
+
+ $result = $this->query($sql, $params);
+ $json = json_decode($result, true);
+
+ if (is_array($json) && isset($json['data']) && is_array($json['data'])) {
+ foreach ($json['data'] as $row) {
+ $metricName = $row['metric'] ?? '';
+ if (isset($totals[$metricName])) {
+ $totals[$metricName] = (int) ($row['total'] ?? 0);
+ }
+ }
+ }
+
+ return $totals;
+ }
+
+ /**
+ * Get time series data for metrics with query-time aggregation.
+ *
+ * Uses SUM for event metrics and argMax for gauge metrics.
+ * When $type is null, queries both tables and merges results.
+ *
+ * @param array $metrics
+ * @param string $interval '1h' or '1d'
+ * @param string $startDate
+ * @param string $endDate
+ * @param array $queries
+ * @param bool $zeroFill
+ * @param string|null $type 'event', 'gauge', or null (both)
+ * @return array}>
+ * @throws Exception
+ */
+ public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array
+ {
+ if (empty($metrics)) {
+ return [];
+ }
+
+ if (!isset(self::INTERVAL_FUNCTIONS[$interval])) {
+ throw new \InvalidArgumentException("Invalid interval '{$interval}'. Allowed: " . implode(', ', array_keys(self::INTERVAL_FUNCTIONS)));
+ }
+
+ $this->setOperationContext('getTimeSeries()');
+
+ // Initialize result structure
+ $output = [];
+ foreach ($metrics as $metric) {
+ $output[$metric] = ['total' => 0, 'data' => []];
+ }
+
+ $typesToQuery = [];
+ if ($type === Usage::TYPE_EVENT || $type === null) {
+ $typesToQuery[] = Usage::TYPE_EVENT;
+ }
+ if ($type === Usage::TYPE_GAUGE || $type === null) {
+ $typesToQuery[] = Usage::TYPE_GAUGE;
+ }
+
+ foreach ($typesToQuery as $queryType) {
+ $typeResult = $this->getTimeSeriesFromTable($metrics, $interval, $startDate, $endDate, $queries, $queryType);
+
+ // Merge results
+ foreach ($typeResult as $metricName => $metricData) {
+ if (!isset($output[$metricName])) {
+ continue;
+ }
+
+ $output[$metricName]['total'] += $metricData['total'];
+ $output[$metricName]['data'] = array_merge(
+ $output[$metricName]['data'],
+ $metricData['data']
+ );
+ }
+ }
+
+ // Zero-fill gaps if requested
+ if ($zeroFill) {
+ foreach ($output as $metricName => &$metricData) {
+ $metricData['data'] = $this->zeroFillTimeSeries(
+ $metricData['data'],
+ $interval,
+ $startDate,
+ $endDate
+ );
+ }
+ unset($metricData);
+ }
+
+ return $output;
+ }
+
+ /**
+ * Get time series data from a specific table.
+ *
+ * @param array $metrics
+ * @param string $interval
+ * @param string $startDate
+ * @param string $endDate
+ * @param array $queries
+ * @param string $type
+ * @return array}>
+ * @throws Exception
+ */
+ private function getTimeSeriesFromTable(array $metrics, string $interval, string $startDate, string $endDate, array $queries, string $type): array
+ {
+ $timeFunction = self::INTERVAL_FUNCTIONS[$interval];
+ $tableName = $this->getTableForType($type);
+ $fromTable = $this->buildTableReference($tableName);
+
+ // Build metric IN params
+ $metricParams = [];
+ $metricPlaceholders = [];
+ foreach ($metrics as $i => $metric) {
+ $paramName = 'metric_' . $i;
+ $metricParams[$paramName] = $metric;
+ $metricPlaceholders[] = "{{$paramName}:String}";
+ }
+
+ $metricInClause = implode(', ', $metricPlaceholders);
+
+ // Build additional WHERE conditions from queries
+ $parsed = $this->parseQueries($queries, $type);
+ $additionalFilters = $parsed['filters'];
+ $params = array_merge($metricParams, $parsed['params']);
+
+ $params['start_date'] = $this->formatDateTime($startDate);
+ $params['end_date'] = $this->formatDateTime($endDate);
+
+ // Build tenant filter
+ $tenantFilter = '';
+ if ($this->sharedTables && $this->tenant !== null) {
+ $tenantFilter = ' AND tenant = {tenant:Nullable(String)}';
+ $params['tenant'] = $this->tenant;
+ }
+
+ $additionalWhere = '';
+ if (!empty($additionalFilters)) {
+ $additionalWhere = ' AND ' . implode(' AND ', $additionalFilters);
+ }
+
+ // Use appropriate aggregation based on type
+ if ($type === Usage::TYPE_EVENT) {
+ $valueExpr = 'SUM(value) as agg_value';
+ } else {
+ $valueExpr = 'argMax(value, time) as agg_value';
+ }
+
+ $sql = "
+ SELECT
+ metric,
+ {$timeFunction}(time) as bucket,
+ {$valueExpr}
+ FROM {$fromTable}
+ WHERE metric IN ({$metricInClause})
+ AND time BETWEEN {start_date:DateTime64(3)} AND {end_date:DateTime64(3)}
+ {$tenantFilter}{$additionalWhere}
+ GROUP BY metric, bucket
+ ORDER BY bucket ASC
+ FORMAT JSON
+ ";
+
+ $result = $this->query($sql, $params);
+ $json = json_decode($result, true);
+
+ // Initialize result structure
+ $output = [];
+ foreach ($metrics as $metric) {
+ $output[$metric] = ['total' => 0, 'data' => []];
+ }
+
+ if (is_array($json) && isset($json['data']) && is_array($json['data'])) {
+ foreach ($json['data'] as $row) {
+ $metricName = $row['metric'] ?? '';
+ $bucketTime = $row['bucket'] ?? '';
+ $value = (int) ($row['agg_value'] ?? 0);
+
+ if (!isset($output[$metricName])) {
+ continue;
+ }
+
+ // Format bucket time
+ $formattedDate = $bucketTime;
+ if (is_string($bucketTime) && strpos($bucketTime, 'T') === false) {
+ $formattedDate = str_replace(' ', 'T', $bucketTime) . '+00:00';
+ }
+
+ $output[$metricName]['total'] += $value;
+ $output[$metricName]['data'][] = [
+ 'value' => $value,
+ 'date' => $formattedDate,
+ ];
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Fill gaps in time series data with zero-value entries.
+ *
+ * @param array $data Existing data points
+ * @param string $interval '1h' or '1d'
+ * @param string $startDate Start datetime
+ * @param string $endDate End datetime
+ * @return array
+ */
+ private function zeroFillTimeSeries(array $data, string $interval, string $startDate, string $endDate): array
+ {
+ $format = $interval === '1h' ? 'Y-m-d\TH:00:00+00:00' : 'Y-m-d\T00:00:00+00:00';
+ $step = $interval === '1h' ? '+1 hour' : '+1 day';
+
+ // Build lookup of existing data points by formatted date
+ $existing = [];
+ foreach ($data as $point) {
+ $dt = new \DateTime($point['date']);
+ $key = $dt->format($format);
+ // If multiple points in the same bucket, sum them
+ $existing[$key] = ($existing[$key] ?? 0) + $point['value'];
+ }
+
+ // Generate all time buckets in range
+ $start = new \DateTime($startDate);
+ $end = new \DateTime($endDate);
+
+ $result = [];
+ $current = clone $start;
+
+ while ($current <= $end) {
+ $key = $current->format($format);
+ $result[] = [
+ 'value' => $existing[$key] ?? 0,
+ 'date' => $key,
+ ];
+ $current->modify($step);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get total value for a single metric.
+ *
+ * Returns sum for event metrics, latest value for gauge metrics.
+ * When $type is null, queries both tables.
+ *
+ * @param string $metric
+ * @param array $queries
+ * @param string|null $type 'event', 'gauge', or null (both)
+ * @return int
+ * @throws Exception
+ */
+ public function getTotal(string $metric, array $queries = [], ?string $type = null): int
+ {
+ $this->setOperationContext('getTotal()');
+
+ if ($type === Usage::TYPE_EVENT) {
+ return $this->getTotalFromEvents($metric, $queries);
+ }
+
+ if ($type === Usage::TYPE_GAUGE) {
+ return $this->getTotalFromGauges($metric, $queries);
+ }
+
+ // Query both tables — event uses SUM, gauge uses argMax
+ $eventTotal = $this->getTotalFromEvents($metric, $queries);
+ $gaugeTotal = $this->getTotalFromGauges($metric, $queries);
+
+ // If we got data from both, prioritize event (they don't overlap in practice)
+ // If only one has data, return that
+ if ($eventTotal > 0 && $gaugeTotal > 0) {
+ // A metric shouldn't be in both tables; return whichever is nonzero
+ // In practice, callers specify type for ambiguous cases
+ return $eventTotal + $gaugeTotal;
+ }
+
+ return $eventTotal > 0 ? $eventTotal : $gaugeTotal;
+ }
+
+ /**
+ * Get total from events table (SUM).
+ *
+ * @param string $metric
+ * @param array $queries
+ * @return int
+ * @throws Exception
+ */
+ private function getTotalFromEvents(string $metric, array $queries): int
+ {
+ $tableName = $this->getEventsTableName();
+ $fromTable = $this->buildTableReference($tableName);
+
+ $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT);
+ $params = $parsed['params'];
+ $params['metric_name'] = $metric;
+
+ $whereData = $this->buildWhereClause($parsed['filters'], $params);
+ $whereClause = $whereData['clause'];
+ $params = $whereData['params'];
+
+ // Add metric filter
+ $metricFilter = $this->escapeIdentifier('metric') . ' = {metric_name:String}';
+ if (!empty($whereClause)) {
+ $whereClause .= ' AND ' . $metricFilter;
+ } else {
+ $whereClause = ' WHERE ' . $metricFilter;
+ }
+
+ $sql = "
+ SELECT SUM(value) as total
+ FROM {$fromTable}{$whereClause}
+ FORMAT JSON
+ ";
+
+ $result = $this->query($sql, $params);
+ $json = json_decode($result, true);
+
+ if (!is_array($json) || !isset($json['data'][0]['total'])) {
+ return 0;
+ }
+
+ return (int) $json['data'][0]['total'];
+ }
+
+ /**
+ * Get total from gauges table (argMax).
+ *
+ * @param string $metric
+ * @param array $queries
+ * @return int
+ * @throws Exception
+ */
+ private function getTotalFromGauges(string $metric, array $queries): int
+ {
+ $tableName = $this->getGaugesTableName();
+ $fromTable = $this->buildTableReference($tableName);
+
+ $parsed = $this->parseQueries($queries, Usage::TYPE_GAUGE);
+ $params = $parsed['params'];
+ $params['metric_name'] = $metric;
+
+ $whereData = $this->buildWhereClause($parsed['filters'], $params);
+ $whereClause = $whereData['clause'];
+ $params = $whereData['params'];
+
+ // Add metric filter
+ $metricFilter = $this->escapeIdentifier('metric') . ' = {metric_name:String}';
+ if (!empty($whereClause)) {
+ $whereClause .= ' AND ' . $metricFilter;
+ } else {
+ $whereClause = ' WHERE ' . $metricFilter;
+ }
+
+ $sql = "
+ SELECT argMax(value, time) as total
+ FROM {$fromTable}{$whereClause}
+ FORMAT JSON
+ ";
+
+ $result = $this->query($sql, $params);
+ $json = json_decode($result, true);
+
+ if (!is_array($json) || !isset($json['data'][0]['total'])) {
+ return 0;
+ }
+
+ return (int) $json['data'][0]['total'];
+ }
+
+ /**
+ * Get totals for multiple metrics in a single query.
+ *
+ * @param array $metrics
+ * @param array $queries
+ * @param string|null $type 'event', 'gauge', or null (both)
+ * @return array
+ * @throws Exception
+ */
+ public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array
+ {
+ if (empty($metrics)) {
+ return [];
+ }
+
+ $this->setOperationContext('getTotalBatch()');
+
+ // Initialize all metrics to 0
+ $totals = \array_fill_keys($metrics, 0);
+
+ $typesToQuery = [];
+ if ($type === Usage::TYPE_EVENT || $type === null) {
+ $typesToQuery[] = Usage::TYPE_EVENT;
+ }
+ if ($type === Usage::TYPE_GAUGE || $type === null) {
+ $typesToQuery[] = Usage::TYPE_GAUGE;
+ }
+
+ foreach ($typesToQuery as $queryType) {
+ $tableName = $this->getTableForType($queryType);
+ $fromTable = $this->buildTableReference($tableName);
+
+ // Build metric IN params
+ $metricParams = [];
+ $metricPlaceholders = [];
+ foreach ($metrics as $i => $metric) {
+ $paramName = 'metric_' . $i;
+ $metricParams[$paramName] = $metric;
+ $metricPlaceholders[] = "{{$paramName}:String}";
+ }
+ $metricInClause = implode(', ', $metricPlaceholders);
+
+ $parsed = $this->parseQueries($queries, $queryType);
+ $params = array_merge($metricParams, $parsed['params']);
+
+ $whereData = $this->buildWhereClause($parsed['filters'], $params);
+ $whereClause = $whereData['clause'];
+ $params = $whereData['params'];
+
+ $escapedMetric = $this->escapeIdentifier('metric');
+ $metricFilter = "{$escapedMetric} IN ({$metricInClause})";
+ if (!empty($whereClause)) {
+ $whereClause .= ' AND ' . $metricFilter;
+ } else {
+ $whereClause = ' WHERE ' . $metricFilter;
+ }
+
+ // Use appropriate aggregation
+ if ($queryType === Usage::TYPE_EVENT) {
+ $valueExpr = 'SUM(value) as agg_val';
+ } else {
+ $valueExpr = 'argMax(value, time) as agg_val';
+ }
+
+ $sql = "
+ SELECT
+ metric,
+ {$valueExpr}
+ FROM {$fromTable}{$whereClause}
+ GROUP BY metric
+ FORMAT JSON
+ ";
+
+ $result = $this->query($sql, $params);
+ $json = json_decode($result, true);
+
+ if (is_array($json) && isset($json['data']) && is_array($json['data'])) {
+ foreach ($json['data'] as $row) {
+ $metricName = $row['metric'] ?? '';
+
+ if (!isset($totals[$metricName])) {
+ continue;
+ }
+
+ $totals[$metricName] += (int) ($row['agg_val'] ?? 0);
+ }
+ }
+ }
+
+ return $totals;
+ }
+
+ /**
+ * Build WHERE clause from filters with optional tenant filtering.
+ *
+ * @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
+ {
+ $conditions = $filters;
+ $whereParams = $params;
+
+ if ($includeTenant) {
+ $tenantFilter = $this->getTenantFilter();
+ if ($tenantFilter) {
+ $conditions[] = $tenantFilter;
+ $whereParams['tenant'] = $this->tenant;
+ }
+ }
+
+ $clause = !empty($conditions) ? ' WHERE ' . implode(' AND ', $conditions) : '';
+
+ return [
+ 'clause' => $clause,
+ 'params' => $whereParams
+ ];
+ }
+
+ /**
+ * Get the ClickHouse parameter type string for a given attribute.
+ *
+ * Returns 'Int64' for the value column, 'String' for everything else.
+ *
+ * @param string $attribute
+ * @return string
+ */
+ private function getParamType(string $attribute): string
+ {
+ return $attribute === 'value' ? 'Int64' : 'String';
+ }
+
+ /**
+ * Parse Query objects into SQL clauses.
+ *
+ * @param array $queries
+ * @param string $type 'event' or 'gauge' — used for attribute validation
+ * @return array{filters: array, params: array, orderBy?: array, limit?: int, offset?: int, groupByInterval?: string}
+ * @throws Exception
+ */
+ private function parseQueries(array $queries, string $type = 'event'): array
+ {
+ $filters = [];
+ $params = [];
+ $orderBy = [];
+ $limit = null;
+ $offset = null;
+ $groupByInterval = null;
+ $paramCounter = 0;
+
+ foreach ($queries as $query) {
+ $method = $query->getMethod();
+ $attribute = $query->getAttribute();
+ $values = $query->getValues();
+
+ switch ($method) {
+ case Query::TYPE_EQUAL:
+ $this->validateAttributeName($attribute, $type);
+ $escapedAttr = $this->escapeIdentifier($attribute);
+ $chType = $this->getParamType($attribute);
+
+ if (count($values) > 1) {
+ /** @var array $arrayValues */
+ $arrayValues = $values;
+ $inParams = [];
+ foreach ($arrayValues as $value) {
+ $paramName = 'param_' . $paramCounter++;
+ if ($attribute === 'time') {
+ $inParams[] = "{{$paramName}:DateTime64(3)}";
+ /** @var \DateTime|string|null $timeValue */
+ $timeValue = $value;
+ $params[$paramName] = $this->formatDateTime($timeValue);
+ } else {
+ $inParams[] = "{{$paramName}:{$chType}}";
+ /** @var bool|float|int|string $scalarValue */
+ $scalarValue = $value;
+ $params[$paramName] = $this->formatParamValue($scalarValue);
+ }
+ }
+
+ /** @var int $inParamCount */
+ $inParamCount = count($inParams);
+ if ($inParamCount === 1) {
+ $filters[] = "{$escapedAttr} = " . $inParams[0];
+ } else {
+ $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")";
+ }
+ } else {
+ $paramName = 'param_' . $paramCounter++;
+ if ($attribute === 'time') {
+ /** @var array<\DateTime|string|null> $values */
+ $formattedValue = $this->formatDateTime($values[0]);
+ $filters[] = "{$escapedAttr} = {{$paramName}:DateTime64(3)}";
+ } else {
+ /** @var bool|float|int|string $formattedValue */
+ $formattedValue = $this->formatParamValue($values[0]);
+ $filters[] = "{$escapedAttr} = {{$paramName}:{$chType}}";
+ }
+ $params[$paramName] = $formattedValue;
+ }
+ break;
+
+ case Query::TYPE_LESSER:
+ $this->validateAttributeName($attribute, $type);
+ $escapedAttr = $this->escapeIdentifier($attribute);
+ $chType = $this->getParamType($attribute);
+ $paramName = 'param_' . $paramCounter++;
+ $value = is_array($values) && !empty($values) ? $values[0] : $values;
+ if ($attribute === 'time') {
+ $filters[] = "{$escapedAttr} < {{$paramName}:DateTime64(3)}";
+ $params[$paramName] = $this->formatDateTime($value);
+ } else {
+ $filters[] = "{$escapedAttr} < {{$paramName}:{$chType}}";
+ $params[$paramName] = $this->formatParamValue($value);
+ }
+ break;
+
+ case Query::TYPE_GREATER:
+ $this->validateAttributeName($attribute, $type);
+ $escapedAttr = $this->escapeIdentifier($attribute);
+ $chType = $this->getParamType($attribute);
+ $paramName = 'param_' . $paramCounter++;
+ $value = is_array($values) && !empty($values) ? $values[0] : $values;
+ if ($attribute === 'time') {
+ $filters[] = "{$escapedAttr} > {{$paramName}:DateTime64(3)}";
+ $params[$paramName] = $this->formatDateTime($value);
+ } else {
+ $filters[] = "{$escapedAttr} > {{$paramName}:{$chType}}";
+ $params[$paramName] = $this->formatParamValue($value);
+ }
+ break;
+
+ case Query::TYPE_BETWEEN:
+ $this->validateAttributeName($attribute, $type);
+ $escapedAttr = $this->escapeIdentifier($attribute);
+ $chType = $this->getParamType($attribute);
+ $paramName1 = 'param_' . $paramCounter++;
+ $paramName2 = 'param_' . $paramCounter++;
+ $value1 = is_array($values) && isset($values[0]) ? $values[0] : $values;
+ $value2 = is_array($values) && isset($values[1]) ? $values[1] : $values;
+ if ($attribute === 'time') {
+ $paramType = 'DateTime64(3)';
+ $filters[] = "{$escapedAttr} BETWEEN {{$paramName1}:{$paramType}} AND {{$paramName2}:{$paramType}}";
+ $params[$paramName1] = $this->formatDateTime($value1);
+ $params[$paramName2] = $this->formatDateTime($value2);
+ } else {
+ $filters[] = "{$escapedAttr} BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}";
+ $params[$paramName1] = $this->formatParamValue($value1);
+ $params[$paramName2] = $this->formatParamValue($value2);
+ }
+ break;
+
+ case Query::TYPE_ORDER_DESC:
+ $this->validateAttributeName($attribute, $type);
+ $escapedAttr = $this->escapeIdentifier($attribute);
+ $orderBy[] = "{$escapedAttr} DESC";
+ break;
+
+ case Query::TYPE_ORDER_ASC:
+ $this->validateAttributeName($attribute, $type);
+ $escapedAttr = $this->escapeIdentifier($attribute);
+ $orderBy[] = "{$escapedAttr} ASC";
+ break;
+
+ case Query::TYPE_CONTAINS:
+ $this->validateAttributeName($attribute, $type);
+ $escapedAttr = $this->escapeIdentifier($attribute);
+ $chType = $this->getParamType($attribute);
+ $inParams = [];
+ foreach ($values as $value) {
+ $paramName = 'param_' . $paramCounter++;
+ if ($attribute === 'time') {
+ $inParams[] = "{{$paramName}:DateTime64(3)}";
+ /** @var \DateTime|string|null $singleValue */
+ $singleValue = $value;
+ $params[$paramName] = $this->formatDateTime($singleValue);
+ } else {
+ $inParams[] = "{{$paramName}:{$chType}}";
+ /** @var bool|float|int|string $singleValue */
+ $singleValue = $value;
+ $params[$paramName] = $this->formatParamValue($singleValue);
+ }
+ }
+ if (!empty($inParams)) {
+ $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")";
+ }
+ break;
+
+ case Query::TYPE_LESSER_EQUAL:
+ $this->validateAttributeName($attribute, $type);
+ $escapedAttr = $this->escapeIdentifier($attribute);
+ $chType = $this->getParamType($attribute);
+ $paramName = 'param_' . $paramCounter++;
+ $singleValue = null;
+ if ($attribute === 'time') {
+ if (is_array($values)) {
+ /** @var \DateTime|string|null $singleValue */
+ $singleValue = $values[0] ?? null;
+ }
+ $filters[] = "{$escapedAttr} <= {{$paramName}:DateTime64(3)}";
+ $params[$paramName] = $this->formatDateTime($singleValue);
+ } else {
+ if (is_array($values)) {
+ /** @var bool|float|int|string $singleValue */
+ $singleValue = $values[0] ?? null;
+ }
+ $filters[] = "{$escapedAttr} <= {{$paramName}:{$chType}}";
+ $params[$paramName] = $this->formatParamValue($singleValue);
+ }
+ break;
+
+ case Query::TYPE_GREATER_EQUAL:
+ $this->validateAttributeName($attribute, $type);
+ $escapedAttr = $this->escapeIdentifier($attribute);
+ $chType = $this->getParamType($attribute);
+ $paramName = 'param_' . $paramCounter++;
+ $singleValue = null;
+ if ($attribute === 'time') {
+ if (is_array($values)) {
+ /** @var \DateTime|string|null $singleValue */
+ $singleValue = $values[0] ?? null;
+ }
+ $filters[] = "{$escapedAttr} >= {{$paramName}:DateTime64(3)}";
+ $params[$paramName] = $this->formatDateTime($singleValue);
+ } else {
+ if (is_array($values)) {
+ /** @var bool|float|int|string $singleValue */
+ $singleValue = $values[0] ?? null;
+ }
+ $filters[] = "{$escapedAttr} >= {{$paramName}:{$chType}}";
+ $params[$paramName] = $this->formatParamValue($singleValue);
+ }
+ break;
+
+ case Query::TYPE_LIMIT:
+ $limitVal = is_array($values) && !empty($values) ? $values[0] : $values;
+ if (!\is_int($limitVal)) {
+ throw new \Exception('Invalid limit value. Expected int');
+ }
+ $limit = $limitVal;
+ $params['limit'] = $limit;
+ break;
+
+ case Query::TYPE_OFFSET:
+ $offsetVal = is_array($values) && !empty($values) ? $values[0] : $values;
+ if (!\is_int($offsetVal)) {
+ throw new \Exception('Invalid offset value. Expected int');
+ }
+ $offset = $offsetVal;
+ $params['offset'] = $offset;
+ break;
+
+ case UsageQuery::TYPE_GROUP_BY_INTERVAL:
+ $this->validateAttributeName($attribute, $type);
+ $interval = $values[0] ?? '1h';
+ if (!is_string($interval) || !isset(UsageQuery::VALID_INTERVALS[$interval])) {
+ throw new \Exception(
+ "Invalid groupByInterval interval '{$interval}'. Allowed: "
+ . implode(', ', array_keys(UsageQuery::VALID_INTERVALS))
+ );
+ }
+ $groupByInterval = $interval;
+ break;
+ }
+ }
+
+ $result = [
+ 'filters' => $filters,
+ 'params' => $params,
+ ];
+
+ if (!empty($orderBy)) {
+ $result['orderBy'] = $orderBy;
+ }
+
+ if ($limit !== null) {
+ $result['limit'] = $limit;
+ }
+
+ if ($offset !== null) {
+ $result['offset'] = $offset;
+ }
+
+ if ($groupByInterval !== null) {
+ $result['groupByInterval'] = $groupByInterval;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Parse ClickHouse JSON results into Metric array.
+ *
+ * @param string $result
+ * @param string $type 'event' or 'gauge' — used to set the type attribute on parsed metrics
+ * @return array
+ */
+ private function parseResults(string $result, string $type = 'event'): array
+ {
+ if (empty(trim($result))) {
+ return [];
+ }
+
+ $json = json_decode($result, true);
+
+ if (!is_array($json) || !isset($json['data']) || !is_array($json['data'])) {
+ return [];
+ }
+
+ $rows = $json['data'];
+ $metrics = [];
+
+ foreach ($rows as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+ $document = [];
+
+ foreach ($row as $key => $value) {
+ if ($key === 'tenant') {
+ $document[$key] = $value !== null ? (string) $value : null;
+ } elseif ($key === 'value') {
+ $document[$key] = $value !== null ? (int) $value : null;
+ } elseif ($key === 'time') {
+ $parsedTime = (string)$value;
+ if (strpos($parsedTime, 'T') === false) {
+ $parsedTime = str_replace(' ', 'T', $parsedTime) . '+00:00';
+ }
+ $document[$key] = $parsedTime;
+ } elseif ($key === 'tags') {
+ if (is_string($value)) {
+ $document[$key] = json_decode($value, true) ?? [];
+ } else {
+ $document[$key] = $value;
+ }
+ } else {
+ $document[$key] = $value;
+ }
+ }
+
+ if (isset($document['id'])) {
+ $document['$id'] = $document['id'];
+ unset($document['id']);
+ }
+
+ // Set the type based on which table we queried
+ $document['type'] = $type;
+
+ $metrics[] = new Metric($document);
+ }
+
+ return $metrics;
+ }
+
+ /**
+ * Get the SELECT column list for queries.
+ *
+ * @param string $type 'event' or 'gauge'
+ * @return string
+ */
+ private function getSelectColumns(string $type = 'event'): string
+ {
+ $columns = [];
+
+ $columns[] = $this->escapeIdentifier('id');
+
+ foreach ($this->getAttributes($type) as $attribute) {
+ $id = $attribute['$id'];
+ if (is_string($id)) {
+ $columns[] = $this->escapeIdentifier($id);
+ }
+ }
+
+ if ($this->sharedTables) {
+ $columns[] = $this->escapeIdentifier('tenant');
+ }
+
+ 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).
+ *
+ * @param array $queries
+ * @param string|null $type 'event', 'gauge', or null (purge both)
+ * @throws Exception
+ */
+ public function purge(array $queries = [], ?string $type = null): bool
+ {
+ $this->setOperationContext('purge()');
+
+ $typesToPurge = [];
+ if ($type === Usage::TYPE_EVENT || $type === null) {
+ $typesToPurge[] = Usage::TYPE_EVENT;
+ }
+ if ($type === Usage::TYPE_GAUGE || $type === null) {
+ $typesToPurge[] = Usage::TYPE_GAUGE;
+ }
+
+ foreach ($typesToPurge as $purgeType) {
+ $tableName = $this->getTableForType($purgeType);
+ $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
+
+ $parsed = $this->parseQueries($queries, $purgeType);
+ $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']);
+ $whereClause = $whereData['clause'];
+ $params = $whereData['params'];
+
+ if (empty($whereClause)) {
+ $whereClause = ' WHERE 1=1';
+ }
+
+ $sql = "DELETE FROM {$escapedTable}{$whereClause}";
+ $this->query($sql, $params);
+ }
+
+ return true;
+ }
+}
diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php
new file mode 100644
index 0000000..41638ed
--- /dev/null
+++ b/src/Usage/Adapter/Database.php
@@ -0,0 +1,509 @@
+db = $db;
+ }
+
+ public function getName(): string
+ {
+ return 'Database';
+ }
+
+ /**
+ * Check database connection health and collection existence.
+ *
+ * @return array{healthy: bool, database?: string, collection?: string, error?: string}
+ */
+ public function healthCheck(): array
+ {
+ try {
+ $databaseName = $this->db->getDatabase();
+ if (!$this->db->exists($databaseName)) {
+ return [
+ 'healthy' => false,
+ 'error' => "Database '{$databaseName}' does not exist"
+ ];
+ }
+
+ $collectionName = $this->collection ?? 'usage';
+ if ($this->db->getCollection($collectionName)->isEmpty()) {
+ return [
+ 'healthy' => false,
+ 'database' => $databaseName,
+ 'collection' => $collectionName,
+ 'error' => "Collection '{$collectionName}' is missing or empty in database '{$databaseName}'"
+ ];
+ }
+
+ return [
+ 'healthy' => true,
+ 'database' => $databaseName,
+ 'collection' => $collectionName
+ ];
+ } catch (\Exception $e) {
+ return [
+ 'healthy' => false,
+ 'error' => $e->getMessage()
+ ];
+ }
+ }
+
+ public function setup(): void
+ {
+ $this->collection = 'usage';
+ if (! $this->db->exists($this->db->getDatabase())) {
+ throw new \Exception('You need to create the database before running Usage setup');
+ }
+
+ // Use event attributes which is a superset (includes path/method/status/resource/resourceId)
+ $attributes = $this->getAttributeDocuments('event');
+ $indexDocs = $this->getIndexDocuments('event');
+
+ try {
+ $this->db->createCollection(
+ $this->collection,
+ $attributes,
+ $indexDocs
+ );
+ } catch (DuplicateException) {
+ // Collection already exists
+ }
+ }
+
+ /**
+ * Get column definition for Database adapter (not used, but required by SQL parent)
+ */
+ protected function getColumnDefinition(string $id, string $type = 'event'): string
+ {
+ return '';
+ }
+
+ /**
+ * Add metrics in batch (raw append).
+ *
+ * Database adapter uses a single collection for both types. The $type parameter
+ * is stored as a field in each document for query-time differentiation.
+ *
+ * @param array}> $metrics
+ * @param string $type Metric type: 'event' or 'gauge'
+ * @param int $batchSize
+ * @return bool
+ * @throws \Exception
+ */
+ public function addBatch(array $metrics, string $type = Usage::TYPE_EVENT, int $batchSize = 1000): bool
+ {
+ $this->db->getAuthorization()->skip(function () use ($metrics, $type, $batchSize) {
+ $documents = [];
+ foreach ($metrics as $metric) {
+ if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) {
+ throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: event, gauge");
+ }
+
+ $tags = $metric['tags'] ?? [];
+ ksort($tags);
+
+ $docData = [
+ '$id' => $this->generateId(),
+ '$permissions' => [],
+ 'metric' => $metric['metric'],
+ 'value' => $metric['value'],
+ 'time' => (new \DateTime())->format('Y-m-d H:i:s.v'),
+ 'tags' => $tags,
+ ];
+
+ // For events, extract event-specific columns from tags
+ if ($type === Usage::TYPE_EVENT) {
+ foreach (Metric::EVENT_COLUMNS as $col) {
+ $docData[$col] = $tags[$col] ?? null;
+ }
+ }
+
+ $documents[] = new Document($docData);
+ }
+
+ foreach (array_chunk($documents, max(1, $batchSize)) as $chunk) {
+ foreach ($chunk as $doc) {
+ $this->db->createDocument($this->collection, $doc);
+ }
+ }
+ });
+
+ return true;
+ }
+
+ /**
+ * Get time series data for metrics.
+ *
+ * Stub implementation for Database adapter.
+ *
+ * @param array $metrics
+ * @param string $interval
+ * @param string $startDate
+ * @param string $endDate
+ * @param array $queries
+ * @param bool $zeroFill
+ * @param string|null $type
+ * @return array}>
+ */
+ public function getTimeSeries(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 = [];
+ foreach ($metrics as $metric) {
+ $output[$metric] = ['total' => 0, 'data' => []];
+ }
+ return $output;
+ }
+
+ /**
+ * Get total value for a single metric.
+ *
+ * Returns SUM for event metrics, latest value for gauge metrics.
+ *
+ * @param string $metric
+ * @param array $queries
+ * @param string|null $type
+ * @return int
+ */
+ public function getTotal(string $metric, array $queries = [], ?string $type = null): int
+ {
+ $allQueries = array_merge($queries, [
+ Query::equal('metric', [$metric]),
+ ]);
+
+ // If type is specified, query only that type
+ $queryType = $type;
+ /** @var array $results */
+ $results = $this->find($allQueries, $queryType);
+
+ if (empty($results)) {
+ return 0;
+ }
+
+ if ($type === Usage::TYPE_GAUGE) {
+ // For gauge, return the last (most recently inserted) value
+ $lastResult = end($results);
+ return $lastResult->getValue(0) ?? 0;
+ }
+
+ if ($type === Usage::TYPE_EVENT) {
+ // For events, SUM all values
+ $sum = 0;
+ foreach ($results as $result) {
+ $sum += (int) ($result->getValue(0) ?? 0);
+ }
+ return $sum;
+ }
+
+ // Type is null — try to detect from results
+ $firstType = $results[0]->getType();
+
+ if ($firstType === 'gauge') {
+ $lastResult = end($results);
+ return $lastResult->getValue(0) ?? 0;
+ }
+
+ // Default to SUM for events
+ $sum = 0;
+ foreach ($results as $result) {
+ $sum += (int) ($result->getValue(0) ?? 0);
+ }
+
+ return $sum;
+ }
+
+ /**
+ * Get totals for multiple metrics.
+ *
+ * @param array $metrics
+ * @param array $queries
+ * @param string|null $type
+ * @return array
+ */
+ public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array
+ {
+ if (empty($metrics)) {
+ return [];
+ }
+
+ $totals = \array_fill_keys($metrics, 0);
+
+ foreach ($metrics as $metric) {
+ $totals[$metric] = $this->getTotal($metric, $queries, $type);
+ }
+
+ return $totals;
+ }
+
+ /**
+ * Sum metric values.
+ *
+ * @param array $queries
+ * @param string $attribute
+ * @param string|null $type
+ * @return int
+ */
+ public function sum(array $queries = [], string $attribute = 'value', ?string $type = null): int
+ {
+ /** @var array $results */
+ $results = $this->find($queries, $type);
+
+ $sum = 0;
+ foreach ($results as $result) {
+ $sum += (int) ($result->getValue(0) ?? 0);
+ }
+
+ return $sum;
+ }
+
+ /**
+ * Find from daily table — Database adapter falls back to regular find for events.
+ *
+ * @param array $queries
+ * @return array
+ */
+ public function findDaily(array $queries = []): array
+ {
+ return $this->find($queries, Usage::TYPE_EVENT);
+ }
+
+ /**
+ * Sum multiple metrics from daily table — falls back to individual sumDaily calls.
+ *
+ * @param array<\Utopia\Query\Query> $queries
+ * @return array
+ */
+ public function sumDailyBatch(array $metrics, array $queries = []): array
+ {
+ $totals = \array_fill_keys($metrics, 0);
+ foreach ($metrics as $metric) {
+ $metricQueries = array_merge($queries, [Query::equal('metric', [$metric])]);
+ $totals[$metric] = $this->sumDaily($metricQueries);
+ }
+ return $totals;
+ }
+
+ /**
+ * Sum from daily table — Database adapter falls back to regular sum for events.
+ *
+ * @param array $queries
+ * @param string $attribute
+ * @return int
+ */
+ public function sumDaily(array $queries = [], string $attribute = 'value'): int
+ {
+ return $this->sum($queries, $attribute, Usage::TYPE_EVENT);
+ }
+
+ /**
+ * Convert Utopia\Query\Query to Utopia\Database\Query for use with the Database class.
+ *
+ * @param array $queries
+ * @return array
+ */
+ private function convertQueriesToDatabase(array $queries): array
+ {
+ $dbQueries = [];
+ foreach ($queries as $query) {
+ $method = $query->getMethod();
+ $attribute = $query->getAttribute();
+ $values = $query->getValues();
+
+ switch ($method) {
+ case Query::TYPE_EQUAL:
+ /** @var array|bool|float|int|string> $values */
+ $dbQueries[] = DatabaseQuery::equal($attribute, $values);
+ break;
+ case Query::TYPE_GREATER:
+ if (!empty($values)) {
+ /** @var bool|float|int|string $value */
+ $value = $values[0];
+ $dbQueries[] = DatabaseQuery::greaterThan($attribute, $value);
+ }
+ break;
+ case Query::TYPE_LESSER:
+ if (!empty($values)) {
+ /** @var bool|float|int|string $value */
+ $value = $values[0];
+ $dbQueries[] = DatabaseQuery::lessThan($attribute, $value);
+ }
+ break;
+ case Query::TYPE_BETWEEN:
+ if (count($values) >= 2) {
+ /** @var bool|float|int|string $start */
+ $start = $values[0];
+ /** @var bool|float|int|string $end */
+ $end = $values[1];
+ $dbQueries[] = DatabaseQuery::between($attribute, $start, $end);
+ }
+ break;
+ case Query::TYPE_CONTAINS:
+ /** @var array|bool|float|int|string> $values */
+ $dbQueries[] = DatabaseQuery::contains($attribute, $values);
+ break;
+ case Query::TYPE_LESSER_EQUAL:
+ if (!empty($values)) {
+ /** @var bool|float|int|string $value */
+ $value = $values[0];
+ $dbQueries[] = DatabaseQuery::lessThanEqual($attribute, $value);
+ }
+ break;
+ case Query::TYPE_GREATER_EQUAL:
+ if (!empty($values)) {
+ /** @var bool|float|int|string $value */
+ $value = $values[0];
+ $dbQueries[] = DatabaseQuery::greaterThanEqual($attribute, $value);
+ }
+ break;
+ case Query::TYPE_ORDER_DESC:
+ $dbQueries[] = DatabaseQuery::orderDesc($attribute);
+ break;
+ case Query::TYPE_ORDER_ASC:
+ $dbQueries[] = DatabaseQuery::orderAsc($attribute);
+ break;
+ case Query::TYPE_LIMIT:
+ if (!empty($values)) {
+ /** @var int|string $val */
+ $val = $values[0] ?? 0;
+ $dbQueries[] = DatabaseQuery::limit((int) $val);
+ }
+ break;
+ case Query::TYPE_OFFSET:
+ if (!empty($values)) {
+ /** @var int|string $val */
+ $val = $values[0] ?? 0;
+ $dbQueries[] = DatabaseQuery::offset((int) $val);
+ }
+ break;
+
+ case UsageQuery::TYPE_GROUP_BY_INTERVAL:
+ // groupByInterval is not supported by the Database adapter.
+ // Silently skip — callers get raw (non-aggregated) results.
+ break;
+ }
+ }
+
+ return $dbQueries;
+ }
+
+ /**
+ * @param array $queries
+ * @param string|null $type
+ */
+ public function purge(array $queries = [], ?string $type = null): bool
+ {
+ $this->db->getAuthorization()->skip(function () use ($queries) {
+ $dbQueries = $this->convertQueriesToDatabase($queries);
+ $dbQueries[] = DatabaseQuery::limit(100);
+
+ do {
+ $documents = $this->db->find(
+ collection: $this->collection,
+ queries: $dbQueries,
+ );
+
+ foreach ($documents as $document) {
+ $this->db->deleteDocument($this->collection, $document->getId());
+ }
+ } while (! empty($documents));
+ });
+
+ return true;
+ }
+
+ /**
+ * Find metrics using Query objects.
+ *
+ * @param array $queries
+ * @param string|null $type
+ * @return array
+ */
+ public function find(array $queries = [], ?string $type = null): array
+ {
+ /** @var array $result */
+ $result = $this->db->getAuthorization()->skip(function () use ($queries) {
+ $dbQueries = $this->convertQueriesToDatabase($queries);
+ return $this->db->find(
+ collection: $this->collection,
+ queries: $dbQueries,
+ );
+ });
+
+ return \array_map(fn ($doc) => new Metric($doc->getArrayCopy()), $result);
+ }
+
+ /**
+ * Count metrics using Query objects.
+ *
+ * @param array $queries
+ * @param string|null $type
+ * @return int
+ */
+ public function count(array $queries = [], ?string $type = null): int
+ {
+ /** @var int $count */
+ $count = $this->db->getAuthorization()->skip(function () use ($queries) {
+ $dbQueries = $this->convertQueriesToDatabase($queries);
+ return $this->db->count(
+ collection: $this->collection,
+ queries: $dbQueries
+ );
+ });
+
+ return $count;
+ }
+
+ /**
+ * Set the namespace prefix for table names.
+ *
+ * @param string $namespace
+ * @return self
+ */
+ public function setNamespace(string $namespace): self
+ {
+ $this->db->setNamespace($namespace);
+ return $this;
+ }
+
+ /**
+ * Set the tenant ID for multi-tenant support.
+ *
+ * @param string|null $tenant
+ * @return self
+ */
+ public function setTenant(?string $tenant): self
+ {
+ $this->db->setTenant($tenant !== null ? (int) $tenant : null);
+ return $this;
+ }
+
+ /**
+ * Enable or disable shared tables mode.
+ *
+ * @param bool $sharedTables
+ * @return self
+ */
+ public function setSharedTables(bool $sharedTables): self
+ {
+ $this->db->setSharedTables($sharedTables);
+ return $this;
+ }
+}
diff --git a/src/Usage/Adapter/SQL.php b/src/Usage/Adapter/SQL.php
new file mode 100644
index 0000000..f7f2d6b
--- /dev/null
+++ b/src/Usage/Adapter/SQL.php
@@ -0,0 +1,168 @@
+>
+ */
+ public function getEventAttributes(): array
+ {
+ return Metric::getEventSchema();
+ }
+
+ /**
+ * Get attribute definitions for gauge metrics.
+ *
+ * @return array>
+ */
+ public function getGaugeAttributes(): array
+ {
+ return Metric::getGaugeSchema();
+ }
+
+ /**
+ * Get attribute definitions for a specific type.
+ *
+ * @param string $type 'event' or 'gauge'
+ * @return array>
+ */
+ public function getAttributes(string $type = 'event'): array
+ {
+ return $type === 'gauge' ? $this->getGaugeAttributes() : $this->getEventAttributes();
+ }
+
+ /**
+ * Get attribute documents for a specific type.
+ *
+ * @param string $type 'event' or 'gauge'
+ * @return array
+ */
+ public function getAttributeDocuments(string $type = 'event'): array
+ {
+ return array_map(static fn (array $attribute) => new Document($attribute), $this->getAttributes($type));
+ }
+
+ /**
+ * Get index definitions for event metrics.
+ *
+ * @return array>
+ */
+ public function getEventIndexes(): array
+ {
+ return Metric::getEventIndexes();
+ }
+
+ /**
+ * Get index definitions for gauge metrics.
+ *
+ * @return array>
+ */
+ public function getGaugeIndexes(): array
+ {
+ return Metric::getGaugeIndexes();
+ }
+
+ /**
+ * Get index definitions for a specific type.
+ *
+ * @param string $type 'event' or 'gauge'
+ * @return array>
+ */
+ public function getIndexes(string $type = 'event'): array
+ {
+ return $type === 'gauge' ? $this->getGaugeIndexes() : $this->getEventIndexes();
+ }
+
+ /**
+ * Get index documents for a specific type.
+ *
+ * @param string $type 'event' or 'gauge'
+ * @return array
+ */
+ public function getIndexDocuments(string $type = 'event'): array
+ {
+ return array_map(static fn (array $index) => new Document($index), $this->getIndexes($type));
+ }
+
+ /**
+ * Get a single attribute by ID from a specific schema.
+ *
+ * @param string $id
+ * @param string $type 'event' or 'gauge'
+ * @return array|null
+ */
+ protected function getAttribute(string $id, string $type = 'event')
+ {
+ foreach ($this->getAttributes($type) as $attribute) {
+ if ($attribute['$id'] === $id) {
+ return $attribute;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get SQL column definition for a given attribute ID.
+ * This method is database-specific and must be implemented by each concrete adapter.
+ *
+ * @param string $id Attribute identifier
+ * @param string $type 'event' or 'gauge'
+ * @return string Database-specific column definition
+ */
+ abstract protected function getColumnDefinition(string $id, string $type = 'event'): string;
+
+ /**
+ * Get all SQL column definitions for a specific type.
+ * Uses the concrete adapter's implementation of getColumnDefinition.
+ *
+ * @param string $type 'event' or 'gauge'
+ * @return array
+ */
+ protected function getAllColumnDefinitions(string $type = 'event'): array
+ {
+ $definitions = [];
+ foreach ($this->getAttributes($type) as $attribute) {
+ /** @var string $id */
+ $id = $attribute['$id'];
+ $definitions[] = $this->getColumnDefinition($id, $type);
+ }
+
+ return $definitions;
+ }
+
+ /**
+ * Generate a UUID for row identification.
+ * Since we're appending raw rows (no dedup), IDs are random.
+ */
+ protected function generateId(): string
+ {
+ return bin2hex(random_bytes(16));
+ }
+}
diff --git a/src/Usage/Metric.php b/src/Usage/Metric.php
new file mode 100644
index 0000000..6252c58
--- /dev/null
+++ b/src/Usage/Metric.php
@@ -0,0 +1,764 @@
+ 'unique-id',
+ * 'metric' => 'bandwidth',
+ * 'value' => 1024,
+ * 'time' => '2025-12-09 10:00:00',
+ * 'path' => '/v1/storage/files',
+ * 'method' => 'POST',
+ * 'status' => '201',
+ * 'resource' => 'bucket',
+ * 'resourceId' => 'abc123',
+ * 'tags' => ['region' => 'us-east', 'country' => 'US']
+ * ]);
+ *
+ * echo $metric->getMetric(); // 'bandwidth'
+ * echo $metric->getValue(); // 1024
+ * echo $metric->getPath(); // '/v1/storage/files'
+ * ```
+ *
+ * @extends ArrayObject
+ */
+class Metric extends ArrayObject
+{
+ /**
+ * Event-specific column names that are extracted from tags into dedicated columns.
+ */
+ public const EVENT_COLUMNS = ['path', 'method', 'status', 'resource', 'resourceId', 'country', 'userAgent'];
+
+ /**
+ * Construct a new metric object.
+ *
+ * Initializes the metric with the provided data array.
+ * The array can contain any attributes, but common ones include:
+ * - $id: Unique identifier for the metric
+ * - metric: Name/type of the metric being tracked
+ * - value: Numeric value of the metric
+ * - time: Timestamp when the metric was recorded
+ * - path: API endpoint path (events only)
+ * - method: HTTP method (events only)
+ * - status: HTTP status code (events only)
+ * - resource: Resource type (events only)
+ * - resourceId: Resource ID (events only)
+ * - tags: Additional metadata as key-value pairs
+ * - tenant: Tenant ID for multi-tenant environments
+ *
+ * @param array $input Metric data
+ */
+ public function __construct(array $input = [])
+ {
+ parent::__construct($input);
+ }
+
+ /**
+ * Get metric ID.
+ *
+ * Returns the unique identifier for this metric entry.
+ * This is typically a UUID or auto-generated ID from the storage backend.
+ *
+ * @return string The metric ID, or empty string if not set
+ */
+ public function getId(): string
+ {
+ $id = $this->getAttribute('$id', '');
+ return is_string($id) ? $id : '';
+ }
+
+ /**
+ * Get metric name.
+ *
+ * Returns the name or type of metric being tracked.
+ * Examples: 'bandwidth', 'requests', 'storage', 'executions'
+ *
+ * @return string The metric name, or empty string if not set
+ */
+ public function getMetric(): string
+ {
+ $metric = $this->getAttribute('metric', '');
+ return is_string($metric) ? $metric : '';
+ }
+
+ /**
+ * Get metric value.
+ *
+ * Returns the numeric value associated with this metric.
+ * For example, number of requests, bytes transferred, or execution count.
+ *
+ * @param int|null $default Default value to return if not set
+ * @return int|null The metric value, or the default if not set or invalid
+ */
+ public function getValue(?int $default = null): ?int
+ {
+ $value = $this->getAttribute('value', $default ?? 0);
+ return is_int($value) ? $value : $default;
+ }
+
+ /**
+ * Get metric type.
+ *
+ * Returns the type of this metric based on which table it was stored in.
+ * Values:
+ * - 'event': Additive metrics (bandwidth, requests, etc.) aggregated with SUM
+ * - 'gauge': Point-in-time metrics (storage, user count, etc.) aggregated with argMax
+ *
+ * Note: The type is no longer stored in the table schema (since table choice implies type),
+ * but this method is kept for backward compatibility. It reads from the 'type' attribute
+ * which callers may still set.
+ *
+ * @return string The type identifier, defaults to 'event'
+ */
+ public function getType(): string
+ {
+ $type = $this->getAttribute('type', 'event');
+ return is_string($type) ? $type : 'event';
+ }
+
+ /**
+ * Get timestamp.
+ *
+ * Returns the timestamp when this metric was recorded or the
+ * aggregation period start time. Format depends on the storage backend,
+ * typically ISO 8601 or database datetime format.
+ *
+ * @return string|null The timestamp string, or null if not set
+ */
+ public function getTime(): ?string
+ {
+ $time = $this->getAttribute('time', null);
+ return is_string($time) ? $time : null;
+ }
+
+ /**
+ * Get API endpoint path (event metrics only).
+ *
+ * @return string|null The path, or null if not set
+ */
+ public function getPath(): ?string
+ {
+ $path = $this->getAttribute('path', null);
+ return is_string($path) ? $path : null;
+ }
+
+ /**
+ * Get HTTP method (event metrics only).
+ *
+ * @return string|null The HTTP method (GET, POST, etc.), or null if not set
+ */
+ public function getMethod(): ?string
+ {
+ $method = $this->getAttribute('method', null);
+ return is_string($method) ? $method : null;
+ }
+
+ /**
+ * Get HTTP status code (event metrics only).
+ *
+ * @return string|null The status code as string, or null if not set
+ */
+ public function getStatus(): ?string
+ {
+ $status = $this->getAttribute('status', null);
+ return is_string($status) ? $status : null;
+ }
+
+ /**
+ * Get resource type (event metrics only).
+ *
+ * @return string|null The resource type, or null if not set
+ */
+ public function getResource(): ?string
+ {
+ $resource = $this->getAttribute('resource', null);
+ return is_string($resource) ? $resource : null;
+ }
+
+ /**
+ * Get resource ID (event metrics only).
+ *
+ * @return string|null The resource ID, or null if not set
+ */
+ public function getResourceId(): ?string
+ {
+ $resourceId = $this->getAttribute('resourceId', null);
+ return is_string($resourceId) ? $resourceId : null;
+ }
+
+ /**
+ * Get country code (event metrics only).
+ *
+ * @return string|null ISO 3166-1 alpha-2 country code, or null if not set
+ */
+ public function getCountry(): ?string
+ {
+ $country = $this->getAttribute('country', null);
+ return is_string($country) ? $country : null;
+ }
+
+ /**
+ * Get user agent (event metrics only).
+ *
+ * @return string|null The user agent string, or null if not set
+ */
+ public function getUserAgent(): ?string
+ {
+ $userAgent = $this->getAttribute('userAgent', null);
+ return is_string($userAgent) ? $userAgent : null;
+ }
+
+ /**
+ * Get tags.
+ *
+ * Returns additional metadata associated with this metric as key-value pairs.
+ * Tags are useful for filtering, grouping, and contextualizing metrics.
+ *
+ * Common tag examples:
+ * - region: Geographic region (us-east, eu-west)
+ * - userAgent: Client user agent
+ * - country: Country code
+ *
+ * Note: For event metrics, path/method/status/resource/resourceId are stored
+ * as dedicated columns, not in tags. Remaining metadata (region, userAgent, etc.)
+ * stays in the tags JSON.
+ *
+ * @return array Associative array of tags
+ */
+ public function getTags(): array
+ {
+ $tags = $this->getAttribute('tags', []);
+ return is_array($tags) ? $tags : [];
+ }
+
+ /**
+ * Get tenant ID.
+ *
+ * Returns the tenant identifier when using shared tables in multi-tenant
+ * architectures. This allows data isolation at the application level while
+ * sharing the same database tables.
+ *
+ * @return string|null The tenant ID, or null if not set or not using multi-tenancy
+ */
+ public function getTenant(): ?string
+ {
+ $tenant = $this->getAttribute('tenant');
+
+ if ($tenant === null) {
+ return null;
+ }
+
+ return is_string($tenant) ? $tenant : (is_numeric($tenant) ? (string) $tenant : null);
+ }
+
+ /**
+ * Get all attributes.
+ *
+ * Returns all metric data as an associative array.
+ * This includes both standard fields (id, metric, value, etc.) and
+ * any custom attributes that were set on the metric.
+ *
+ * @return array All metric attributes
+ */
+ public function getAttributes(): array
+ {
+ $attributes = [];
+
+ foreach ($this as $key => $value) {
+ $attributes[$key] = $value;
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Get a specific attribute.
+ *
+ * Retrieves the value of a named attribute. If the attribute doesn't exist,
+ * returns the provided default value.
+ *
+ * This is a generic accessor - prefer using the type-safe getters
+ * (getId(), getMetric(), etc.) for standard attributes.
+ *
+ * @param string $name The attribute name to retrieve
+ * @param mixed $default Default value if attribute is not set
+ * @return mixed The attribute value or default
+ */
+ public function getAttribute(string $name, mixed $default = null): mixed
+ {
+ if (isset($this[$name])) {
+ return $this[$name];
+ }
+
+ return $default;
+ }
+
+ /**
+ * Set a specific attribute.
+ *
+ * Sets or updates the value of a named attribute.
+ * Returns the metric instance for method chaining.
+ *
+ * Example:
+ * ```php
+ * $metric->setAttribute('custom', 'value')
+ * ->setAttribute('another', 123);
+ * ```
+ *
+ * @param string $key The attribute name
+ * @param mixed $value The attribute value
+ * @return static This metric instance for chaining
+ */
+ public function setAttribute(string $key, mixed $value): static
+ {
+ $this[$key] = $value;
+
+ return $this;
+ }
+
+ /**
+ * Check if an attribute exists.
+ *
+ * Determines whether a named attribute is set on this metric,
+ * regardless of its value (including null).
+ *
+ * @param string $name The attribute name to check
+ * @return bool True if the attribute exists, false otherwise
+ */
+ public function hasAttribute(string $name): bool
+ {
+ return isset($this[$name]);
+ }
+
+ /**
+ * Remove an attribute.
+ *
+ * Removes a named attribute from the metric.
+ * Returns the metric instance for method chaining.
+ *
+ * @param string $name The attribute name to remove
+ * @return static This metric instance for chaining
+ */
+ public function removeAttribute(string $name): static
+ {
+ unset($this[$name]);
+
+ /** @var static */
+ return $this;
+ }
+
+ /**
+ * Check if the metric is empty.
+ *
+ * A metric is considered empty if it has no ID set.
+ * This is useful for checking if a query returned valid results.
+ *
+ * @return bool True if the metric has no ID, false otherwise
+ */
+ public function isEmpty(): bool
+ {
+ return empty($this->getId());
+ }
+
+ /**
+ * Convert to array.
+ *
+ * Returns a plain PHP array representation of the metric.
+ * This is useful for serialization, JSON encoding, or passing
+ * to functions that expect arrays.
+ *
+ * @return array Array representation of the metric
+ */
+ public function toArray(): array
+ {
+ return $this->getArrayCopy();
+ }
+
+ /**
+ * Get event table schema definition.
+ *
+ * Returns the attribute schema for the events table which stores
+ * raw request events with metadata columns for path, method, status,
+ * resource, and resourceId.
+ *
+ * @return array>
+ */
+ public static function getEventSchema(): array
+ {
+ return [
+ [
+ '$id' => 'metric',
+ 'type' => 'string',
+ 'size' => 255,
+ 'required' => true,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'value',
+ 'type' => 'integer',
+ 'size' => 0,
+ 'required' => true,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'time',
+ 'type' => 'datetime',
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => false,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ [
+ '$id' => 'path',
+ 'type' => 'string',
+ 'size' => 255,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'method',
+ 'type' => 'string',
+ 'size' => 16,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'status',
+ 'type' => 'string',
+ 'size' => 16,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'resource',
+ 'type' => 'string',
+ 'size' => 255,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'resourceId',
+ 'type' => 'string',
+ 'size' => 255,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'country',
+ 'type' => 'string',
+ 'size' => 2,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'userAgent',
+ 'type' => 'string',
+ 'size' => 255,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'tags',
+ 'type' => 'string',
+ 'size' => 16777216,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => ['json'],
+ ],
+ ];
+ }
+
+ /**
+ * Get gauge table schema definition.
+ *
+ * Returns the attribute schema for the gauges table which stores
+ * simple resource snapshots (metric, value, time, tags).
+ *
+ * @return array>
+ */
+ public static function getGaugeSchema(): array
+ {
+ return [
+ [
+ '$id' => 'metric',
+ 'type' => 'string',
+ 'size' => 255,
+ 'required' => true,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'value',
+ 'type' => 'integer',
+ 'size' => 0,
+ 'required' => true,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => 'time',
+ 'type' => 'datetime',
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => false,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ [
+ '$id' => 'tags',
+ 'type' => 'string',
+ 'size' => 16777216,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => ['json'],
+ ],
+ ];
+ }
+
+ /**
+ * Get combined schema (backward compat).
+ *
+ * Returns the event schema which is a superset. This preserves
+ * backward compatibility with code that calls Metric::getSchema().
+ *
+ * @return array>
+ */
+ public static function getSchema(): array
+ {
+ return self::getEventSchema();
+ }
+
+ /**
+ * Get event table indexes.
+ *
+ * @return array>
+ */
+ public static function getEventIndexes(): array
+ {
+ return [
+ [
+ '$id' => 'index-metric',
+ 'type' => 'key',
+ 'attributes' => ['metric'],
+ ],
+ [
+ '$id' => 'index-time',
+ 'type' => 'key',
+ 'attributes' => ['time'],
+ ],
+ [
+ '$id' => 'index-path',
+ 'type' => 'key',
+ 'attributes' => ['path'],
+ ],
+ [
+ '$id' => 'index-method',
+ 'type' => 'key',
+ 'attributes' => ['method'],
+ ],
+ [
+ '$id' => 'index-status',
+ 'type' => 'key',
+ 'attributes' => ['status'],
+ ],
+ [
+ '$id' => 'index-resource',
+ 'type' => 'key',
+ 'attributes' => ['resource'],
+ ],
+ [
+ '$id' => 'index-resourceId',
+ 'type' => 'key',
+ 'attributes' => ['resourceId'],
+ ],
+ [
+ '$id' => 'index-country',
+ 'type' => 'key',
+ 'attributes' => ['country'],
+ ],
+ [
+ '$id' => 'index-userAgent',
+ 'type' => 'key',
+ 'attributes' => ['userAgent'],
+ ],
+ ];
+ }
+
+ /**
+ * Get gauge table indexes.
+ *
+ * @return array>
+ */
+ public static function getGaugeIndexes(): array
+ {
+ return [
+ [
+ '$id' => 'index-metric',
+ 'type' => 'key',
+ 'attributes' => ['metric'],
+ ],
+ [
+ '$id' => 'index-time',
+ 'type' => 'key',
+ 'attributes' => ['time'],
+ ],
+ ];
+ }
+
+ /**
+ * Get combined indexes (backward compat).
+ *
+ * Returns the event indexes. This preserves backward compatibility
+ * with code that calls Metric::getIndexes().
+ *
+ * @return array>
+ */
+ public static function getIndexes(): array
+ {
+ return self::getEventIndexes();
+ }
+
+ /**
+ * Validate metric data against schema.
+ *
+ * Validates that metric data conforms to the schema definition.
+ * Checks for:
+ * - Required attributes are present
+ * - Data types match schema types
+ * - String sizes don't exceed limits
+ * - Values are in valid ranges
+ *
+ * @param array $data The metric data to validate
+ * @param string $type The metric type ('event' or 'gauge') to validate against
+ * @throws \Exception If validation fails
+ */
+ public static function validate(array $data, string $type = 'event'): void
+ {
+ $schema = $type === 'gauge' ? self::getGaugeSchema() : self::getEventSchema();
+
+ foreach ($schema as $attribute) {
+ /** @var string $attrId */
+ $attrId = $attribute['$id'];
+ $required = $attribute['required'] ?? false;
+ $attrType = $attribute['type'] ?? 'string';
+ /** @var int $size */
+ $size = $attribute['size'] ?? 0;
+
+ // Check if required attribute is present
+ if ($required && !isset($data[$attrId])) {
+ throw new \Exception("Required attribute '{$attrId}' is missing");
+ }
+
+ // Skip validation if not present and not required
+ if (!isset($data[$attrId])) {
+ continue;
+ }
+
+ $value = $data[$attrId];
+
+ // Special handling for tags: accept array (will be JSON-encoded)
+ if ($attrId === 'tags') {
+ if (!is_array($value)) {
+ throw new \Exception("Attribute '{$attrId}' must be an array, got " . gettype($value));
+ }
+ continue;
+ }
+
+ // Validate based on attribute type
+ match ($attrType) {
+ 'string' => self::validateStringAttribute($attrId, $value, $size),
+ 'integer' => self::validateIntegerAttribute($attrId, $value),
+ 'datetime' => self::validateDatetimeAttribute($attrId, $value),
+ default => null,
+ };
+ }
+ }
+
+ /**
+ * Validate string attribute value.
+ *
+ * @throws \Exception
+ */
+ private static function validateStringAttribute(string $attrId, mixed $value, int $size): void
+ {
+ if (!is_string($value)) {
+ throw new \Exception("Attribute '{$attrId}' must be a string, got " . gettype($value));
+ }
+
+ if ($size > 0 && strlen($value) > $size) {
+ throw new \Exception("Attribute '{$attrId}' exceeds maximum size of {$size} characters");
+ }
+ }
+
+ /**
+ * Validate integer attribute value.
+ *
+ * @throws \Exception
+ */
+ private static function validateIntegerAttribute(string $attrId, mixed $value): void
+ {
+ if (!is_int($value)) {
+ throw new \Exception("Attribute '{$attrId}' must be an integer, got " . gettype($value));
+ }
+ }
+
+ /**
+ * Validate datetime attribute value.
+ *
+ * @throws \Exception
+ */
+ private static function validateDatetimeAttribute(string $attrId, mixed $value): void
+ {
+ if ($value instanceof \DateTime) {
+ return; // Valid DateTime object
+ }
+
+ if (!is_string($value)) {
+ throw new \Exception("Attribute '{$attrId}' must be a DateTime object or string, got " . gettype($value));
+ }
+
+ try {
+ new \DateTime($value);
+ } catch (\Exception $e) {
+ throw new \Exception("Attribute '{$attrId}' is not a valid datetime string: {$e->getMessage()}");
+ }
+ }
+}
diff --git a/src/Usage/Usage.php b/src/Usage/Usage.php
new file mode 100644
index 0000000..41fb829
--- /dev/null
+++ b/src/Usage/Usage.php
@@ -0,0 +1,470 @@
+}>
+ */
+ 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.
+ *
+ * @param Adapter $adapter The adapter to use for storing usage metrics
+ */
+ public function __construct(Adapter $adapter)
+ {
+ $this->adapter = $adapter;
+ $this->lastFlushTime = microtime(true);
+ }
+
+ /**
+ * Get the current adapter.
+ */
+ public function getAdapter(): Adapter
+ {
+ return $this->adapter;
+ }
+
+ /**
+ * Check adapter health and connection status.
+ *
+ * @return array Health check result with 'healthy' bool and additional adapter-specific information
+ */
+ public function healthCheck(): array
+ {
+ return $this->adapter->healthCheck();
+ }
+
+ /**
+ * Setup the usage metrics storage.
+ *
+ * @throws \Exception
+ */
+ public function setup(): void
+ {
+ $this->adapter->setup();
+ }
+
+ /**
+ * Add metrics in batch (raw append).
+ *
+ * @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 = self::TYPE_EVENT, int $batchSize = 1000): bool
+ {
+ return $this->adapter->addBatch($metrics, $type, $batchSize);
+ }
+
+ /**
+ * Get time series data for metrics.
+ *
+ * @param array $metrics List of metric names
+ * @param string $interval '1h' or '1d'
+ * @param string $startDate Start datetime
+ * @param string $endDate End datetime
+ * @param array<\Utopia\Query\Query> $queries Additional filters
+ * @param bool $zeroFill Whether to fill gaps with zero values
+ * @param string|null $type Metric type: 'event', 'gauge', or null (query both)
+ * @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->adapter->getTimeSeries($metrics, $interval, $startDate, $endDate, $queries, $zeroFill, $type);
+ }
+
+ /**
+ * Get total value for a single metric.
+ *
+ * @param string $metric Metric name
+ * @param array<\Utopia\Query\Query> $queries Additional filters
+ * @param string|null $type Metric type: 'event', 'gauge', or null (query both)
+ * @return int
+ * @throws \Exception
+ */
+ public function getTotal(string $metric, array $queries = [], ?string $type = null): int
+ {
+ return $this->adapter->getTotal($metric, $queries, $type);
+ }
+
+ /**
+ * Get totals for multiple metrics in a single query.
+ *
+ * @param array $metrics List of metric names
+ * @param array<\Utopia\Query\Query> $queries Additional filters
+ * @param string|null $type Metric type: 'event', 'gauge', or null (query both)
+ * @return array
+ * @throws \Exception
+ */
+ public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array
+ {
+ return $this->adapter->getTotalBatch($metrics, $queries, $type);
+ }
+
+ /**
+ * Purge usage metrics matching the given queries.
+ * When no queries are provided, all metrics are deleted.
+ *
+ * @param array<\Utopia\Query\Query> $queries
+ * @param string|null $type Metric type: 'event', 'gauge', or null (purge both)
+ * @throws \Exception
+ */
+ public function purge(array $queries = [], ?string $type = null): bool
+ {
+ return $this->adapter->purge($queries, $type);
+ }
+
+ /**
+ * Find metrics using Query objects.
+ *
+ * @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
+ {
+ return $this->adapter->find($queries, $type);
+ }
+
+ /**
+ * Count metrics using Query objects.
+ *
+ * @param array<\Utopia\Query\Query> $queries
+ * @param string|null $type Metric type: 'event', 'gauge', or null (count both)
+ * @return int
+ * @throws \Exception
+ */
+ public function count(array $queries = [], ?string $type = null): int
+ {
+ return $this->adapter->count($queries, $type);
+ }
+
+ /**
+ * Sum metric values using Query objects.
+ *
+ * @param array<\Utopia\Query\Query> $queries
+ * @param string $attribute Attribute to sum (default: 'value')
+ * @param string|null $type Metric type: 'event', 'gauge', or null (sum both)
+ * @return int
+ * @throws \Exception
+ */
+ public function sum(array $queries = [], string $attribute = 'value', ?string $type = null): int
+ {
+ return $this->adapter->sum($queries, $attribute, $type);
+ }
+
+ /**
+ * Find event metrics from the pre-aggregated daily table.
+ *
+ * Queries the SummingMergeTree daily MV for fast billing/analytics.
+ *
+ * @param array<\Utopia\Query\Query> $queries
+ * @return array
+ * @throws \Exception
+ */
+ public function findDaily(array $queries = []): array
+ {
+ return $this->adapter->findDaily($queries);
+ }
+
+ /**
+ * Sum event metric values from the pre-aggregated daily table.
+ *
+ * Use this for billing queries — reads pre-aggregated daily rows
+ * instead of scanning billions of raw events.
+ *
+ * @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
+ {
+ return $this->adapter->sumDaily($queries, $attribute);
+ }
+
+ /**
+ * Sum multiple event metrics from the pre-aggregated daily table in one query.
+ *
+ * @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
+ {
+ return $this->adapter->sumDailyBatch($metrics, $queries);
+ }
+
+ /**
+ * Set the namespace prefix for table names.
+ *
+ * @param string $namespace
+ * @return $this
+ * @throws \Exception
+ */
+ public function setNamespace(string $namespace): self
+ {
+ $this->adapter->setNamespace($namespace);
+ return $this;
+ }
+
+ /**
+ * Set the tenant ID for multi-tenant support.
+ *
+ * @param string|null $tenant
+ * @return $this
+ */
+ public function setTenant(?string $tenant): self
+ {
+ $this->adapter->setTenant($tenant);
+ return $this;
+ }
+
+ /**
+ * Enable or disable shared tables mode (multi-tenant with tenant column).
+ *
+ * @param bool $sharedTables
+ * @return $this
+ */
+ public function setSharedTables(bool $sharedTables): self
+ {
+ $this->adapter->setSharedTables($sharedTables);
+ 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);
+ }
+
+ $key = $metric . ':' . $type;
+
+ if ($type === 'event') {
+ // Additive: sum values for the same metric
+ 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().
+ *
+ * @return bool True if flush 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
+ $events = [];
+ $gauges = [];
+
+ foreach ($this->buffer as $entry) {
+ if ($entry['type'] === self::TYPE_EVENT) {
+ $events[] = $entry;
+ } else {
+ $gauges[] = $entry;
+ }
+ }
+
+ $result = true;
+
+ // Flush events to events table
+ if (!empty($events)) {
+ $result = $this->adapter->addBatch($events, self::TYPE_EVENT);
+ }
+
+ // Flush gauges to gauges table
+ if (!empty($gauges)) {
+ $result = $this->adapter->addBatch($gauges, self::TYPE_GAUGE) && $result;
+ }
+
+ $this->buffer = [];
+ $this->bufferCount = 0;
+ $this->lastFlushTime = microtime(true);
+
+ return $result;
+ }
+
+ /**
+ * 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/src/Usage/UsageQuery.php b/src/Usage/UsageQuery.php
new file mode 100644
index 0000000..d7ba299
--- /dev/null
+++ b/src/Usage/UsageQuery.php
@@ -0,0 +1,122 @@
+find($queries, 'event');
+ * ```
+ *
+ * When `groupByInterval` is present in the queries array, the ClickHouse adapter
+ * switches from raw row returns to aggregated results grouped by time bucket:
+ * - Events: SUM(value) per bucket
+ * - Gauges: argMax(value, time) per bucket
+ */
+class UsageQuery extends Query
+{
+ public const TYPE_GROUP_BY_INTERVAL = 'groupByInterval';
+
+ /**
+ * Valid interval values and their ClickHouse INTERVAL equivalents.
+ */
+ public const VALID_INTERVALS = [
+ '1m' => 'INTERVAL 1 MINUTE',
+ '5m' => 'INTERVAL 5 MINUTE',
+ '15m' => 'INTERVAL 15 MINUTE',
+ '1h' => 'INTERVAL 1 HOUR',
+ '1d' => 'INTERVAL 1 DAY',
+ '1w' => 'INTERVAL 1 WEEK',
+ '1M' => 'INTERVAL 1 MONTH',
+ ];
+
+ /**
+ * Override isMethod to accept groupByInterval in addition to all base Query methods.
+ */
+ public static function isMethod(string $value): bool
+ {
+ if ($value === self::TYPE_GROUP_BY_INTERVAL) {
+ return true;
+ }
+
+ return parent::isMethod($value);
+ }
+
+ /**
+ * Create a groupByInterval query.
+ *
+ * When passed to `find()`, this switches the adapter to return time-bucketed
+ * aggregated results instead of raw rows.
+ *
+ * @param string $attribute The time attribute to bucket (usually 'time')
+ * @param string $interval The bucket size: '1m', '5m', '15m', '1h', '1d', '1w', '1M'
+ * @return self
+ */
+ public static function groupByInterval(string $attribute, string $interval): self
+ {
+ if (!isset(self::VALID_INTERVALS[$interval])) {
+ throw new \InvalidArgumentException(
+ "Invalid interval '{$interval}'. Allowed: " . implode(', ', array_keys(self::VALID_INTERVALS))
+ );
+ }
+
+ return new self(self::TYPE_GROUP_BY_INTERVAL, $attribute, [$interval]);
+ }
+
+ /**
+ * Check if a query is a groupByInterval query.
+ *
+ * @param Query $query
+ * @return bool
+ */
+ public static function isGroupByInterval(Query $query): bool
+ {
+ return $query->getMethod() === self::TYPE_GROUP_BY_INTERVAL;
+ }
+
+ /**
+ * Extract the groupByInterval query from an array of queries, if present.
+ *
+ * @param array $queries
+ * @return self|null The groupByInterval query, or null if not present
+ */
+ public static function extractGroupByInterval(array $queries): ?self
+ {
+ foreach ($queries as $query) {
+ if ($query instanceof self && $query->getMethod() === self::TYPE_GROUP_BY_INTERVAL) {
+ return $query;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Remove groupByInterval queries from an array of queries.
+ *
+ * Returns the remaining queries that should be processed normally.
+ *
+ * @param array $queries
+ * @return array
+ */
+ public static function removeGroupByInterval(array $queries): array
+ {
+ return array_values(array_filter($queries, function (Query $query) {
+ return !self::isGroupByInterval($query);
+ }));
+ }
+}
diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php
new file mode 100644
index 0000000..b280ff1
--- /dev/null
+++ b/tests/Usage/Adapter/ClickHouseTest.php
@@ -0,0 +1,978 @@
+setNamespace('utopia_usage');
+ $adapter->setTenant('1');
+
+ // Optional customization via env vars
+ if ($database = getenv('CLICKHOUSE_DATABASE')) {
+ $adapter->setDatabase($database);
+ }
+
+ $this->usage = new Usage($adapter);
+ $this->usage->setup();
+ }
+
+ public function testMetricTenantOverridesAdapterTenantInBatch(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+ $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure);
+ $adapter->setNamespace('utopia_usage_shared');
+ $adapter->setSharedTables(true);
+ $adapter->setTenant('1');
+
+ if ($database = getenv('CLICKHOUSE_DATABASE')) {
+ $adapter->setDatabase($database);
+ }
+
+ $usage = new Usage($adapter);
+ $usage->setup();
+ $usage->purge();
+
+ $metrics = [
+ [
+ '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([
+ \Utopia\Query\Query::equal('metric', ['tenant-override']),
+ ], Usage::TYPE_EVENT);
+
+ $this->assertCount(1, $results);
+ $this->assertEquals('2', $results[0]->getTenant());
+
+ $usage->purge();
+ }
+
+ /**
+ * Test addBatch with explicit batch size parameter
+ */
+ 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' => []],
+ ];
+
+ // 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);
+ $this->assertGreaterThanOrEqual(4, count($results));
+ }
+
+ /**
+ * Test addBatch with gauge type
+ */
+ public function testAddBatchGaugeWithBatchSize(): void
+ {
+ $metrics = [
+ ['metric' => 'counter-1', 'value' => 100, 'tags' => []],
+ ['metric' => 'counter-2', 'value' => 200, 'tags' => []],
+ ['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);
+ $this->assertGreaterThanOrEqual(3, count($results));
+ }
+
+ /**
+ * Test large batch with small batch size
+ */
+ public function testLargeBatchWithSmallBatchSize(): void
+ {
+ $metrics = [];
+ for ($i = 0; $i < 100; $i++) {
+ $metrics[] = [
+ 'metric' => 'large-batch-metric',
+ 'value' => $i,
+ 'tags' => ['index' => (string) $i],
+ ];
+ }
+
+ $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 10));
+
+ // Verify metrics were processed
+ $results = $this->usage->find([
+ \Utopia\Query\Query::equal('metric', ['large-batch-metric']),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(1, count($results));
+ }
+
+ /**
+ * Test gauge metrics use argMax (latest value)
+ */
+ public function testGaugeMetricsLastValueWins(): void
+ {
+ $this->usage->purge([], Usage::TYPE_GAUGE);
+
+ $metrics = [
+ ['metric' => 'gauge-test', 'value' => 5, 'tags' => []],
+ ['metric' => 'gauge-test', 'value' => 10, 'tags' => []],
+ ['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);
+ $this->assertGreaterThanOrEqual(5, $total);
+ }
+
+ /**
+ * Test event metrics do aggregate (SUM)
+ */
+ public function testEventMetricsAggregate(): void
+ {
+ $this->usage->purge([], Usage::TYPE_EVENT);
+
+ $metrics = [
+ ['metric' => 'agg-test', 'value' => 5, 'tags' => []],
+ ['metric' => 'agg-test', 'value' => 10, 'tags' => []],
+ ['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);
+ $this->assertEquals(30, $total);
+ }
+
+ /**
+ * Test empty batch
+ */
+ public function testEmptyBatchClickHouse(): void
+ {
+ $this->assertTrue($this->usage->addBatch([], Usage::TYPE_EVENT));
+ }
+
+ /**
+ * Test batch with tags
+ */
+ 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']],
+ ];
+
+ $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT));
+
+ $results = $this->usage->find([
+ \Utopia\Query\Query::equal('metric', ['tagged']),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(1, count($results));
+ }
+
+ /**
+ * Test event-specific columns are extracted from tags
+ */
+ public function testEventColumnsExtractedFromTags(): void
+ {
+ $this->usage->purge([], Usage::TYPE_EVENT);
+
+ $metrics = [
+ [
+ 'metric' => 'event-cols-test',
+ 'value' => 42,
+ 'tags' => [
+ 'path' => '/v1/storage/files',
+ 'method' => 'POST',
+ 'status' => '201',
+ 'resource' => 'bucket',
+ 'resourceId' => 'bucket123',
+ 'country' => 'US',
+ 'userAgent' => 'test-agent',
+ 'region' => 'us-east',
+ ],
+ ],
+ ];
+
+ $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT));
+
+ $results = $this->usage->find([
+ \Utopia\Query\Query::equal('metric', ['event-cols-test']),
+ ], Usage::TYPE_EVENT);
+
+ $this->assertCount(1, $results);
+ $metric = $results[0];
+
+ // Event-specific columns should be set
+ $this->assertEquals('/v1/storage/files', $metric->getPath());
+ $this->assertEquals('POST', $metric->getMethod());
+ $this->assertEquals('201', $metric->getStatus());
+ $this->assertEquals('bucket', $metric->getResource());
+ $this->assertEquals('bucket123', $metric->getResourceId());
+ $this->assertEquals('US', $metric->getCountry());
+ $this->assertEquals('test-agent', $metric->getUserAgent());
+
+ // Remaining tags should only contain non-event fields
+ $tags = $metric->getTags();
+ $this->assertEquals('us-east', $tags['region'] ?? null);
+ $this->assertArrayNotHasKey('path', $tags);
+ $this->assertArrayNotHasKey('method', $tags);
+ $this->assertArrayNotHasKey('status', $tags);
+ $this->assertArrayNotHasKey('resource', $tags);
+ $this->assertArrayNotHasKey('resourceId', $tags);
+ $this->assertArrayNotHasKey('country', $tags);
+ $this->assertArrayNotHasKey('userAgent', $tags);
+ }
+
+ /**
+ * Test querying events by event-specific columns
+ */
+ public function testQueryEventsByColumns(): void
+ {
+ $this->usage->purge([], 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']],
+ ], Usage::TYPE_EVENT));
+
+ // Filter by path
+ $results = $this->usage->find([
+ \Utopia\Query\Query::equal('path', ['/v1/storage']),
+ ], Usage::TYPE_EVENT);
+ $this->assertCount(2, $results);
+
+ // Filter by method
+ $results = $this->usage->find([
+ \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([
+ \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([
+ \Utopia\Query\Query::equal('resource', ['database']),
+ ], Usage::TYPE_EVENT);
+ $this->assertCount(1, $results);
+
+ // Filter by resourceId
+ $results = $this->usage->find([
+ \Utopia\Query\Query::equal('resourceId', ['db1']),
+ ], Usage::TYPE_EVENT);
+ $this->assertCount(1, $results);
+ }
+
+ /**
+ * Test gauge table does not have event columns
+ */
+ public function testGaugeTableSimpleSchema(): void
+ {
+ $this->usage->purge([], Usage::TYPE_GAUGE);
+
+ $this->assertTrue($this->usage->addBatch([
+ ['metric' => 'gauge-simple', 'value' => 500, 'tags' => ['region' => 'us-east']],
+ ], Usage::TYPE_GAUGE));
+
+ $results = $this->usage->find([
+ \Utopia\Query\Query::equal('metric', ['gauge-simple']),
+ ], Usage::TYPE_GAUGE);
+
+ $this->assertCount(1, $results);
+ $this->assertEquals(500, $results[0]->getValue());
+ $this->assertEquals('gauge', $results[0]->getType());
+
+ // Gauge results should not have event-specific columns
+ $this->assertNull($results[0]->getPath());
+ $this->assertNull($results[0]->getMethod());
+ $this->assertNull($results[0]->getStatus());
+ $this->assertNull($results[0]->getResource());
+ $this->assertNull($results[0]->getResourceId());
+ }
+
+ /**
+ * Test finding across both tables (type=null)
+ */
+ public function testFindBothTables(): void
+ {
+ $this->usage->purge();
+
+ $this->assertTrue($this->usage->addBatch([
+ ['metric' => 'both-test-event', 'value' => 10, 'tags' => []],
+ ], Usage::TYPE_EVENT));
+
+ $this->assertTrue($this->usage->addBatch([
+ ['metric' => 'both-test-gauge', 'value' => 100, 'tags' => []],
+ ], Usage::TYPE_GAUGE));
+
+ // Find from both tables
+ $results = $this->usage->find([], null);
+ $this->assertGreaterThanOrEqual(2, count($results));
+
+ $metricNames = array_map(fn ($m) => $m->getMetric(), $results);
+ $this->assertContains('both-test-event', $metricNames);
+ $this->assertContains('both-test-gauge', $metricNames);
+ }
+
+ /**
+ * Test batch size at maximum (1000)
+ */
+ public function testBatchSizeAtMaximum(): void
+ {
+ $metrics = [];
+ for ($i = 0; $i < 500; $i++) {
+ $metrics[] = [
+ 'metric' => 'boundary-test',
+ 'value' => 1,
+ 'tags' => [],
+ ];
+ }
+
+ $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 1000));
+
+ $sum = $this->usage->sum([
+ \Utopia\Query\Query::equal('metric', ['boundary-test']),
+ ], 'value', Usage::TYPE_EVENT);
+ $this->assertEquals(500, $sum);
+ }
+
+ /**
+ * Test batch size of 1
+ */
+ 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' => []],
+ ];
+
+ $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 1));
+
+ // All metrics should be inserted
+ $results = $this->usage->find([], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(3, count($results));
+ }
+
+ /**
+ * Test default batch size (1000)
+ */
+ public function testDefaultBatchSize(): void
+ {
+ $metrics = [];
+ for ($i = 0; $i < 50; $i++) {
+ $metrics[] = [
+ 'metric' => 'default-batch-test',
+ 'value' => 1,
+ 'tags' => [],
+ ];
+ }
+
+ // Use default batch size
+ $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT));
+
+ $sum = $this->usage->sum([
+ \Utopia\Query\Query::equal('metric', ['default-batch-test']),
+ ], 'value', Usage::TYPE_EVENT);
+ $this->assertEquals(50, $sum);
+ }
+
+ /**
+ * Test metrics with special characters to ensure JSON encoding/decoding is correct
+ */
+ 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' => ['s' => $specialVal]],
+ ], Usage::TYPE_EVENT));
+
+ $results = $this->usage->find([
+ \Utopia\Query\Query::equal('metric', ['special-metric']),
+ ], Usage::TYPE_EVENT);
+
+ $this->assertEquals(1, count($results));
+ $this->assertEquals('special-metric', $results[0]->getMetric());
+ $tags = $results[0]->getTags();
+ $this->assertEquals($specialVal, $tags['s']);
+ }
+
+ /**
+ * Comprehensive test for find() with various query types
+ */
+ public function testFindComprehensive(): void
+ {
+ // Cleanup
+ $this->usage->purge();
+
+ // Setup test data
+ $this->usage->addBatch([
+ ['metric' => 'metric-A', 'value' => 10, 'tags' => ['category' => 'cat1']],
+ ], Usage::TYPE_EVENT);
+ $this->usage->addBatch([
+ ['metric' => 'metric-B', 'value' => 20, 'tags' => ['category' => 'cat2']],
+ ], Usage::TYPE_EVENT);
+
+ // 1. Array Equal (IN)
+ $results = $this->usage->find([
+ \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(2, count($results));
+
+ // 2. Scalar Equal
+ $results = $this->usage->find([
+ \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([
+ \Utopia\Query\Query::lessThan('value', 20),
+ \Utopia\Query\Query::equal('metric', ['metric-A']),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(1, count($results));
+ $this->assertEquals(10, $results[0]->getValue());
+
+ // 4. Greater Than
+ $results = $this->usage->find([
+ \Utopia\Query\Query::greaterThan('value', 10),
+ \Utopia\Query\Query::equal('metric', ['metric-B']),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(1, count($results));
+ $this->assertEquals(20, $results[0]->getValue());
+
+ // 5. Between
+ $results = $this->usage->find([
+ \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([
+ \Utopia\Query\Query::contains('metric', ['metric-A']),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(1, count($results));
+
+ // 7. Order Desc
+ $results = $this->usage->find([
+ \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']),
+ \Utopia\Query\Query::orderDesc('value'),
+ \Utopia\Query\Query::limit(2),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(2, count($results));
+ $this->assertTrue($results[0]->getValue() >= $results[1]->getValue());
+
+ // 8. Order Asc
+ $results = $this->usage->find([
+ \Utopia\Query\Query::equal('metric', ['metric-A', 'metric-B']),
+ \Utopia\Query\Query::orderAsc('value'),
+ \Utopia\Query\Query::limit(2),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(2, count($results));
+ $this->assertTrue($results[0]->getValue() <= $results[1]->getValue());
+ }
+
+ /**
+ * Test healthCheck() method
+ */
+ public function testHealthCheck(): void
+ {
+ $adapter = $this->usage->getAdapter();
+
+ $health = $adapter->healthCheck();
+
+ // Assert basic structure
+ $this->assertIsArray($health);
+ $this->assertArrayHasKey('healthy', $health);
+ $this->assertArrayHasKey('host', $health);
+ $this->assertArrayHasKey('port', $health);
+ $this->assertArrayHasKey('database', $health);
+ $this->assertArrayHasKey('secure', $health);
+
+ // Assert connection is healthy
+ $this->assertTrue($health['healthy'], 'ClickHouse should be healthy');
+
+ // Assert additional fields are present when healthy
+ $this->assertArrayHasKey('version', $health);
+ $this->assertArrayHasKey('uptime', $health);
+ $this->assertArrayHasKey('response_time', $health);
+ $this->assertIsString($health['version']);
+ $this->assertIsInt($health['uptime']);
+ $this->assertIsFloat($health['response_time']);
+ $this->assertGreaterThan(0, $health['response_time']);
+ }
+
+ /**
+ * Test healthCheck() with invalid connection
+ */
+ public function testHealthCheckFailure(): void
+ {
+ // Create adapter with invalid host
+ $adapter = new ClickHouseAdapter('invalid-host-that-does-not-exist', 'default', '', 8123, false);
+
+ $health = $adapter->healthCheck();
+
+ // Assert basic structure
+ $this->assertIsArray($health);
+ $this->assertArrayHasKey('healthy', $health);
+ $this->assertArrayHasKey('host', $health);
+
+ // Assert connection failed
+ $this->assertFalse($health['healthy'], 'ClickHouse should be unhealthy with invalid host');
+
+ // Assert error message is present
+ $this->assertArrayHasKey('error', $health);
+ if (isset($health['error'])) {
+ $this->assertIsString($health['error']);
+ $this->assertNotEmpty($health['error']);
+ }
+
+ // Assert response time is still recorded
+ $this->assertArrayHasKey('response_time', $health);
+ if (isset($health['response_time'])) {
+ $this->assertIsFloat($health['response_time']);
+ }
+ }
+
+ /**
+ * Test setTimeout() method with valid timeout
+ */
+ public function testSetTimeoutValid(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+ $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure);
+
+ // Test setting valid timeout
+ $result = $adapter->setTimeout(5000); // 5 seconds
+
+ // Should return self for chaining
+ $this->assertInstanceOf(ClickHouseAdapter::class, $result);
+
+ // Test that it still works after setting timeout
+ $health = $adapter->healthCheck();
+ $this->assertTrue($health['healthy']);
+ }
+
+ /**
+ * Test setTimeout() with minimum timeout (1 second)
+ */
+ public function testSetTimeoutMinimum(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port);
+ $adapter->setTimeout(1000); // 1 second minimum
+
+ $this->assertTrue(true); // If we reach here, no exception was thrown
+ }
+
+ /**
+ * Test setTimeout() with maximum timeout (10 minutes)
+ */
+ public function testSetTimeoutMaximum(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port);
+ $adapter->setTimeout(600000); // 10 minutes maximum
+
+ $this->assertTrue(true); // If we reach here, no exception was thrown
+ }
+
+ /**
+ * Test setTimeout() with timeout below minimum
+ */
+ public function testSetTimeoutBelowMinimum(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Timeout must be at least 1000 milliseconds');
+
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port);
+ $adapter->setTimeout(999); // Below minimum
+ }
+
+ /**
+ * Test setTimeout() with timeout above maximum
+ */
+ public function testSetTimeoutAboveMaximum(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Timeout cannot exceed 600000 milliseconds');
+
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port);
+ $adapter->setTimeout(600001); // Above maximum
+ }
+
+ /**
+ * Test compression functionality
+ */
+ public function testCompression(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+ $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure);
+ $adapter->setNamespace('utopia_usage_compression_test');
+ $adapter->setTenant('1');
+
+ if ($database = getenv('CLICKHOUSE_DATABASE')) {
+ $adapter->setDatabase($database);
+ }
+
+ $usage = new Usage($adapter);
+ $usage->setup();
+
+ // Test enabling compression
+ $result = $adapter->setCompression(true);
+ $this->assertInstanceOf(ClickHouseAdapter::class, $result);
+
+ // Test disabling compression
+ $result = $adapter->setCompression(false);
+ $this->assertInstanceOf(ClickHouseAdapter::class, $result);
+
+ // Enable compression for all subsequent operations
+ $adapter->setCompression(true);
+
+ // Insert data using addBatch with compression enabled
+ $batchResult = $usage->addBatch([
+ ['metric' => 'compression.test.batch', 'value' => 50, 'tags' => ['type' => 'batch']],
+ ['metric' => 'compression.test.batch', 'value' => 75, 'tags' => ['type' => 'batch']],
+ ['metric' => 'compression.test.single', 'value' => 100, 'tags' => ['type' => 'single']],
+ ], Usage::TYPE_EVENT);
+ $this->assertTrue($batchResult);
+
+ // Verify find query works with compression
+ $metrics = $usage->find([], Usage::TYPE_EVENT);
+ $this->assertIsArray($metrics);
+
+ // Verify count query works with compression
+ $count = $usage->count([], Usage::TYPE_EVENT);
+ $this->assertIsInt($count);
+
+ // Verify sum operation works with compression
+ $sum = $usage->sum([
+ \Utopia\Query\Query::equal('metric', ['compression.test.batch']),
+ ], 'value', Usage::TYPE_EVENT);
+ $this->assertIsInt($sum);
+ }
+
+ /**
+ * Test connection pooling functionality
+ */
+ public function testConnectionPooling(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+ $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure);
+ $adapter->setNamespace('utopia_usage_pooling_test');
+ $adapter->setTenant('1');
+
+ if ($database = getenv('CLICKHOUSE_DATABASE')) {
+ $adapter->setDatabase($database);
+ }
+
+ $usage = new Usage($adapter);
+ $usage->setup();
+
+ // Test enabling keep-alive
+ $result = $adapter->setKeepAlive(true);
+ $this->assertInstanceOf(ClickHouseAdapter::class, $result);
+
+ // Test disabling keep-alive
+ $result = $adapter->setKeepAlive(false);
+ $this->assertInstanceOf(ClickHouseAdapter::class, $result);
+
+ // Re-enable for testing
+ $adapter->setKeepAlive(true);
+
+ // Get initial stats
+ $stats = $adapter->getConnectionStats();
+ $this->assertIsArray($stats);
+ $this->assertArrayHasKey('request_count', $stats);
+ $this->assertArrayHasKey('keep_alive_enabled', $stats);
+ $this->assertArrayHasKey('compression_enabled', $stats);
+ $this->assertArrayHasKey('query_logging_enabled', $stats);
+ $this->assertTrue($stats['keep_alive_enabled']);
+
+ $initialCount = $stats['request_count'];
+
+ // Make some requests
+ $usage->addBatch([
+ ['metric' => 'pooling.test', 'value' => 100, 'tags' => ['test' => 'value']],
+ ], Usage::TYPE_EVENT);
+ $usage->find([], Usage::TYPE_EVENT);
+ $usage->count([], Usage::TYPE_EVENT);
+
+ // Verify request count increased
+ $newStats = $adapter->getConnectionStats();
+ $this->assertGreaterThan($initialCount, $newStats['request_count']);
+ $this->assertGreaterThanOrEqual(3, $newStats['request_count'] - $initialCount);
+ }
+
+ /**
+ * Test retry logic configuration
+ */
+ public function testRetryConfiguration(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+ $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure);
+
+ // Test setting max retries
+ $result = $adapter->setMaxRetries(5);
+ $this->assertInstanceOf(ClickHouseAdapter::class, $result);
+
+ // Test setting retry delay
+ $result = $adapter->setRetryDelay(200);
+ $this->assertInstanceOf(ClickHouseAdapter::class, $result);
+
+ // Verify stats reflect configuration
+ $stats = $adapter->getConnectionStats();
+ $this->assertSame(5, $stats['max_retries']);
+ $this->assertSame(200, $stats['retry_delay']);
+
+ // Test valid retry range (0-10)
+ $adapter->setMaxRetries(0);
+ $stats = $adapter->getConnectionStats();
+ $this->assertSame(0, $stats['max_retries']);
+
+ $adapter->setMaxRetries(10);
+ $stats = $adapter->getConnectionStats();
+ $this->assertSame(10, $stats['max_retries']);
+ }
+
+ /**
+ * Test retry validation errors
+ */
+ public function testRetryValidation(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+ $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure);
+
+ // Test max retries below minimum
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Max retries must be between 0 and 10');
+ $adapter->setMaxRetries(-1);
+ }
+
+ /**
+ * Test retry delay validation
+ */
+ public function testRetryDelayValidation(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+ $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure);
+
+ // Test retry delay below minimum
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Retry delay must be between 10 and 5000 milliseconds');
+ $adapter->setRetryDelay(5);
+ }
+
+ /**
+ * Test retry logic with successful operations
+ */
+ public function testRetryWithSuccessfulOperations(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+ $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure);
+ $adapter->setNamespace('utopia_usage_retry_test');
+ $adapter->setTenant('1');
+ $adapter->setMaxRetries(2);
+ $adapter->setRetryDelay(50);
+
+ if ($database = getenv('CLICKHOUSE_DATABASE')) {
+ $adapter->setDatabase($database);
+ }
+
+ $usage = new Usage($adapter);
+ $usage->setup();
+
+ // These operations should succeed on first attempt (no retries needed)
+ $result = $usage->addBatch([
+ ['metric' => 'retry.test', 'value' => 100, 'tags' => ['test' => 'success']],
+ ], Usage::TYPE_EVENT);
+ $this->assertTrue($result);
+
+ $count = $usage->count([], Usage::TYPE_EVENT);
+ $this->assertIsInt($count);
+ }
+
+ /**
+ * Test error messages include operation context
+ */
+ public function testErrorMessagesIncludeContext(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+ $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure);
+ $adapter->setNamespace('utopia_usage_error_test');
+ $adapter->setDatabase('nonexistent_db_for_testing_errors_12345');
+ $adapter->setTenant('1');
+ $adapter->setMaxRetries(0); // Disable retries for faster test
+
+ $usage = new Usage($adapter);
+
+ try {
+ // This should fail because database doesn't exist
+ $usage->find([], Usage::TYPE_EVENT);
+ $this->fail('Expected exception was not thrown');
+ } catch (\Exception $e) {
+ $errorMessage = $e->getMessage();
+
+ // Verify error message includes operation context
+ $this->assertStringContainsString('Operation: find()', $errorMessage, 'Error should include operation context');
+
+ // Verify error message includes query information
+ $this->assertStringContainsString('Query:', $errorMessage, 'Error should include query information');
+
+ // Verify error includes actual error details
+ $this->assertStringContainsString('ClickHouse', $errorMessage, 'Error should mention ClickHouse');
+ }
+ }
+
+ public function testAsyncInsertConfiguration(): void
+ {
+ $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse';
+ $username = getenv('CLICKHOUSE_USER') ?: 'default';
+ $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse';
+ $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123);
+ $secure = (bool) (getenv('CLICKHOUSE_SECURE') ?: false);
+
+ $adapter = new ClickHouseAdapter($host, $username, $password, $port, $secure);
+ $adapter->setNamespace('utopia_usage_async');
+ $adapter->setTenant('1');
+
+ if ($database = getenv('CLICKHOUSE_DATABASE')) {
+ $adapter->setDatabase($database);
+ }
+
+ // Enable async inserts
+ $adapter->setAsyncInserts(true, waitForConfirmation: true);
+
+ $stats = $adapter->getConnectionStats();
+ $this->assertTrue($stats['async_inserts']);
+ $this->assertTrue($stats['async_insert_wait']);
+
+ // Verify it works with async inserts enabled
+ $usage = new Usage($adapter);
+ $usage->setup();
+ $usage->purge();
+
+ $this->assertTrue($usage->addBatch([
+ ['metric' => 'async-test', 'value' => 42, 'tags' => []],
+ ], Usage::TYPE_EVENT));
+
+ $total = $usage->getTotal('async-test', [], Usage::TYPE_EVENT);
+ $this->assertEquals(42, $total);
+
+ // Test fire-and-forget mode
+ $adapter->setAsyncInserts(true, waitForConfirmation: false);
+ $stats = $adapter->getConnectionStats();
+ $this->assertFalse($stats['async_insert_wait']);
+
+ // Disable async inserts
+ $adapter->setAsyncInserts(false);
+ $stats = $adapter->getConnectionStats();
+ $this->assertFalse($stats['async_inserts']);
+
+ $usage->purge();
+ }
+}
diff --git a/tests/Usage/Adapter/DatabaseTest.php b/tests/Usage/Adapter/DatabaseTest.php
new file mode 100644
index 0000000..5e0c05a
--- /dev/null
+++ b/tests/Usage/Adapter/DatabaseTest.php
@@ -0,0 +1,111 @@
+database = new Database(new MariaDB($pdo), $cache);
+ $this->database->setDatabase('utopiaTests');
+ $this->database->setNamespace('utopia_usage');
+
+ $this->usage = new Usage(new AdapterDatabase($this->database));
+
+ // Create database if missing
+ try {
+ $this->database->create();
+ } catch (Duplicate $ex) {
+ // ignore duplicate exception
+ }
+
+ // Always run setup to ensure collection exists
+ try {
+
+ $this->usage->setup();
+ } catch (Duplicate $ex) {
+ // ignore duplicate exception
+ }
+ }
+
+ /**
+ * Test healthCheck() method
+ */
+ public function testHealthCheck(): void
+ {
+ $adapter = $this->usage->getAdapter();
+
+ $health = $adapter->healthCheck();
+
+ // Assert basic structure
+ $this->assertIsArray($health);
+ $this->assertArrayHasKey('healthy', $health);
+
+ // Assert connection is healthy
+ $this->assertTrue($health['healthy'], 'Database should be healthy');
+
+ // Assert additional fields are present when healthy
+ $this->assertArrayHasKey('database', $health);
+ $this->assertArrayHasKey('collection', $health);
+ $this->assertIsString($health['database']);
+ $this->assertIsString($health['collection']);
+ }
+
+ /**
+ * Test healthCheck() with database that doesn't exist
+ */
+ public function testHealthCheckWithNonExistentDatabase(): void
+ {
+ // Create a new database instance pointing to a non-existent database
+ $dbHost = 'mariadb';
+ $dbPort = '3306';
+ $dbUser = 'root';
+ $dbPass = 'password';
+
+ $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPdoAttributes());
+ $cache = new Cache(new NoCache());
+ $database = new Database(new MariaDB($pdo), $cache);
+ $database->setDatabase('nonexistent_database_xyz');
+ $database->setNamespace('test');
+
+ $adapter = new AdapterDatabase($database);
+
+ $health = $adapter->healthCheck();
+
+ // Assert basic structure
+ $this->assertIsArray($health);
+ $this->assertArrayHasKey('healthy', $health);
+
+ // Assert connection failed
+ $this->assertFalse($health['healthy'], 'Database should be unhealthy with non-existent database');
+
+ // Assert error message is present
+ $this->assertArrayHasKey('error', $health);
+ if (isset($health['error'])) {
+ $this->assertIsString($health['error']);
+ $this->assertNotEmpty($health['error']);
+ }
+ }
+}
diff --git a/tests/Usage/MetricTest.php b/tests/Usage/MetricTest.php
new file mode 100644
index 0000000..1acda3a
--- /dev/null
+++ b/tests/Usage/MetricTest.php
@@ -0,0 +1,686 @@
+assertIsArray($schema);
+ $this->assertCount(11, $schema);
+
+ // Test metric attribute
+ $metricAttr = $schema[0];
+ $this->assertEquals('metric', $metricAttr['$id']);
+ $this->assertEquals('string', $metricAttr['type']);
+ $this->assertEquals(255, $metricAttr['size']);
+ $this->assertTrue($metricAttr['required']);
+
+ // Test value attribute
+ $valueAttr = $schema[1];
+ $this->assertEquals('value', $valueAttr['$id']);
+ $this->assertEquals('integer', $valueAttr['type']);
+ $this->assertTrue($valueAttr['required']);
+
+ // Test time attribute (optional)
+ $timeAttr = $schema[2];
+ $this->assertEquals('time', $timeAttr['$id']);
+ $this->assertEquals('datetime', $timeAttr['type']);
+ $this->assertFalse($timeAttr['required']);
+
+ // Test event-specific columns
+ $pathAttr = $schema[3];
+ $this->assertEquals('path', $pathAttr['$id']);
+ $this->assertFalse($pathAttr['required']);
+
+ $methodAttr = $schema[4];
+ $this->assertEquals('method', $methodAttr['$id']);
+ $this->assertFalse($methodAttr['required']);
+
+ $statusAttr = $schema[5];
+ $this->assertEquals('status', $statusAttr['$id']);
+ $this->assertFalse($statusAttr['required']);
+
+ $resourceAttr = $schema[6];
+ $this->assertEquals('resource', $resourceAttr['$id']);
+ $this->assertFalse($resourceAttr['required']);
+
+ $resourceIdAttr = $schema[7];
+ $this->assertEquals('resourceId', $resourceIdAttr['$id']);
+ $this->assertFalse($resourceIdAttr['required']);
+
+ // Test country attribute (optional)
+ $countryAttr = $schema[8];
+ $this->assertEquals('country', $countryAttr['$id']);
+ $this->assertFalse($countryAttr['required']);
+
+ // Test userAgent attribute (optional)
+ $userAgentAttr = $schema[9];
+ $this->assertEquals('userAgent', $userAgentAttr['$id']);
+ $this->assertFalse($userAgentAttr['required']);
+
+ // Test tags attribute (optional)
+ $tagsAttr = $schema[10];
+ $this->assertEquals('tags', $tagsAttr['$id']);
+ $this->assertEquals('string', $tagsAttr['type']);
+ $this->assertFalse($tagsAttr['required']);
+ }
+
+ /**
+ * Test Metric::getGaugeSchema() returns correct attribute definitions
+ */
+ public function testGetGaugeSchemaReturnsAttributeDefinitions(): void
+ {
+ $schema = Metric::getGaugeSchema();
+
+ $this->assertIsArray($schema);
+ $this->assertCount(4, $schema);
+
+ $this->assertEquals('metric', $schema[0]['$id']);
+ $this->assertEquals('value', $schema[1]['$id']);
+ $this->assertEquals('time', $schema[2]['$id']);
+ $this->assertEquals('tags', $schema[3]['$id']);
+ }
+
+ /**
+ * Test backward-compatible getSchema() returns event schema
+ */
+ public function testGetSchemaReturnsEventSchema(): void
+ {
+ $schema = Metric::getSchema();
+ $eventSchema = Metric::getEventSchema();
+ $this->assertEquals($eventSchema, $schema);
+ }
+
+ /**
+ * Test Metric::getEventIndexes() returns correct index definitions
+ */
+ public function testGetEventIndexesReturnsIndexDefinitions(): void
+ {
+ $indexes = Metric::getEventIndexes();
+
+ $this->assertIsArray($indexes);
+ $this->assertCount(9, $indexes);
+
+ // Test metric index
+ $metricIndex = $indexes[0];
+ $this->assertEquals('index-metric', $metricIndex['$id']);
+ $this->assertEquals('key', $metricIndex['type']);
+ $this->assertEquals(['metric'], $metricIndex['attributes']);
+
+ // Test time index
+ $timeIndex = $indexes[1];
+ $this->assertEquals('index-time', $timeIndex['$id']);
+ $this->assertEquals(['time'], $timeIndex['attributes']);
+
+ // Test event-specific indexes
+ $this->assertEquals('index-path', $indexes[2]['$id']);
+ $this->assertEquals('index-method', $indexes[3]['$id']);
+ $this->assertEquals('index-status', $indexes[4]['$id']);
+ $this->assertEquals('index-resource', $indexes[5]['$id']);
+ $this->assertEquals('index-resourceId', $indexes[6]['$id']);
+ $this->assertEquals('index-country', $indexes[7]['$id']);
+ $this->assertEquals('index-userAgent', $indexes[8]['$id']);
+ }
+
+ /**
+ * Test Metric::getGaugeIndexes() returns correct index definitions
+ */
+ public function testGetGaugeIndexesReturnsIndexDefinitions(): void
+ {
+ $indexes = Metric::getGaugeIndexes();
+
+ $this->assertIsArray($indexes);
+ $this->assertCount(2, $indexes);
+
+ $this->assertEquals('index-metric', $indexes[0]['$id']);
+ $this->assertEquals('index-time', $indexes[1]['$id']);
+ }
+
+ /**
+ * Test backward-compatible getIndexes() returns event indexes
+ */
+ public function testGetIndexesReturnsEventIndexes(): void
+ {
+ $indexes = Metric::getIndexes();
+ $eventIndexes = Metric::getEventIndexes();
+ $this->assertEquals($eventIndexes, $indexes);
+ }
+
+ /**
+ * Test Metric::validate() accepts valid event data
+ */
+ public function testValidateAcceptsValidEventData(): void
+ {
+ $validData = [
+ 'metric' => 'requests',
+ 'value' => 100,
+ 'time' => '2024-01-01 12:00:00',
+ 'path' => '/v1/storage/files',
+ 'method' => 'POST',
+ 'status' => '201',
+ 'resource' => 'bucket',
+ 'resourceId' => 'abc123',
+ 'tags' => ['region' => 'us-east', 'env' => 'prod'],
+ ];
+
+ // Should not throw exception
+ Metric::validate($validData, 'event');
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test Metric::validate() accepts valid gauge data
+ */
+ public function testValidateAcceptsValidGaugeData(): void
+ {
+ $validData = [
+ 'metric' => 'storage',
+ 'value' => 10000,
+ 'time' => '2024-01-01 12:00:00',
+ 'tags' => ['region' => 'us-east'],
+ ];
+
+ Metric::validate($validData, 'gauge');
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test Metric::validate() accepts minimal required data
+ */
+ public function testValidateAcceptsMinimalData(): void
+ {
+ $minimalData = [
+ 'metric' => 'requests',
+ 'value' => 50,
+ ];
+
+ Metric::validate($minimalData, 'event');
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test Metric::validate() rejects missing required metric
+ */
+ public function testValidateRejectsMissingMetric(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Required attribute 'metric' is missing");
+
+ Metric::validate([
+ 'value' => 100,
+ ], 'event');
+ }
+
+ /**
+ * Test Metric::validate() rejects missing required value
+ */
+ public function testValidateRejectsMissingValue(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Required attribute 'value' is missing");
+
+ Metric::validate([
+ 'metric' => 'requests',
+ ], 'event');
+ }
+
+ /**
+ * Test Metric::validate() rejects non-string metric
+ */
+ public function testValidateRejectsNonStringMetric(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Attribute 'metric' must be a string");
+
+ Metric::validate([
+ 'metric' => 123,
+ 'value' => 100,
+ ], 'event');
+ }
+
+ /**
+ * Test Metric::validate() rejects oversized metric string
+ */
+ public function testValidateRejectsOversizedMetric(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("exceeds maximum size of 255 characters");
+
+ Metric::validate([
+ 'metric' => str_repeat('a', 256),
+ 'value' => 100,
+ ], 'event');
+ }
+
+ /**
+ * Test Metric::validate() rejects non-integer value
+ */
+ public function testValidateRejectsNonIntegerValue(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Attribute 'value' must be an integer");
+
+ Metric::validate([
+ 'metric' => 'requests',
+ 'value' => '100',
+ ], 'event');
+ }
+
+ /**
+ * Test Metric::validate() accepts DateTime object for time
+ */
+ public function testValidateAcceptsDateTimeForTime(): void
+ {
+ $data = [
+ 'metric' => 'requests',
+ 'value' => 100,
+ 'time' => new \DateTime('2024-01-01 12:00:00'),
+ ];
+
+ Metric::validate($data, 'event');
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test Metric::validate() accepts datetime string for time
+ */
+ public function testValidateAcceptsDatetimeStringForTime(): void
+ {
+ $data = [
+ 'metric' => 'requests',
+ 'value' => 100,
+ 'time' => '2024-01-01 12:00:00',
+ ];
+
+ Metric::validate($data, 'event');
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test Metric::validate() rejects invalid datetime string
+ */
+ public function testValidateRejectsInvalidDatetimeString(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("not a valid datetime string");
+
+ Metric::validate([
+ 'metric' => 'requests',
+ 'value' => 100,
+ 'time' => 'invalid-date',
+ ], 'event');
+ }
+
+ /**
+ * Test Metric::validate() rejects non-array tags
+ */
+ public function testValidateRejectsNonArrayTags(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Attribute 'tags' must be an array");
+
+ Metric::validate([
+ 'metric' => 'requests',
+ 'value' => 100,
+ 'tags' => 'not-an-array',
+ ], 'event');
+ }
+
+ /**
+ * Test Metric::validate() accepts empty tags array
+ */
+ public function testValidateAcceptsEmptyTags(): void
+ {
+ $data = [
+ 'metric' => 'requests',
+ 'value' => 100,
+ 'tags' => [],
+ ];
+
+ Metric::validate($data, 'event');
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test Metric constructor initializes with data
+ */
+ public function testConstructorInitializesWithData(): void
+ {
+ $data = [
+ '$id' => 'metric-1',
+ 'metric' => 'requests',
+ 'value' => 100,
+ 'type' => 'event',
+ 'path' => '/v1/storage/files',
+ 'method' => 'POST',
+ 'status' => '201',
+ 'resource' => 'bucket',
+ 'resourceId' => 'abc123',
+ 'tags' => ['env' => 'prod'],
+ ];
+
+ $metric = new Metric($data);
+
+ $this->assertEquals('metric-1', $metric->getId());
+ $this->assertEquals('requests', $metric->getMetric());
+ $this->assertEquals(100, $metric->getValue());
+ $this->assertEquals('event', $metric->getType());
+ $this->assertEquals('/v1/storage/files', $metric->getPath());
+ $this->assertEquals('POST', $metric->getMethod());
+ $this->assertEquals('201', $metric->getStatus());
+ $this->assertEquals('bucket', $metric->getResource());
+ $this->assertEquals('abc123', $metric->getResourceId());
+ $this->assertEquals(['env' => 'prod'], $metric->getTags());
+ }
+
+ /**
+ * Test Metric::getId() returns metric ID
+ */
+ public function testGetIdReturnsMetricId(): void
+ {
+ $metric = new Metric(['$id' => 'metric-123']);
+ $this->assertEquals('metric-123', $metric->getId());
+ }
+
+ /**
+ * Test Metric::getId() returns empty string when ID not set
+ */
+ public function testGetIdReturnsEmptyStringWhenNotSet(): void
+ {
+ $metric = new Metric([]);
+ $this->assertEquals('', $metric->getId());
+ }
+
+ /**
+ * Test Metric::getMetric() returns metric name
+ */
+ public function testGetMetricReturnsMetricName(): void
+ {
+ $metric = new Metric(['metric' => 'bandwidth']);
+ $this->assertEquals('bandwidth', $metric->getMetric());
+ }
+
+ /**
+ * Test Metric::getValue() returns metric value
+ */
+ public function testGetValueReturnsValue(): void
+ {
+ $metric = new Metric(['value' => 1024]);
+ $this->assertEquals(1024, $metric->getValue());
+ }
+
+ /**
+ * Test Metric::getValue() returns default when not set
+ */
+ public function testGetValueReturnsDefaultWhenNotSet(): void
+ {
+ $metric = new Metric([]);
+ $this->assertEquals(0, $metric->getValue());
+ }
+
+ /**
+ * Test Metric::getType() returns type
+ */
+ public function testGetTypeReturnsType(): void
+ {
+ $metric = new Metric(['type' => 'gauge']);
+ $this->assertEquals('gauge', $metric->getType());
+ }
+
+ /**
+ * Test Metric::getType() returns default type
+ */
+ public function testGetTypeReturnsDefaultType(): void
+ {
+ $metric = new Metric([]);
+ $this->assertEquals('event', $metric->getType());
+ }
+
+ /**
+ * Test event-specific getters return null when not set
+ */
+ public function testEventGettersReturnNullWhenNotSet(): void
+ {
+ $metric = new Metric([]);
+ $this->assertNull($metric->getPath());
+ $this->assertNull($metric->getMethod());
+ $this->assertNull($metric->getStatus());
+ $this->assertNull($metric->getResource());
+ $this->assertNull($metric->getResourceId());
+ }
+
+ /**
+ * Test event-specific getters return correct values
+ */
+ public function testEventGettersReturnCorrectValues(): void
+ {
+ $metric = new Metric([
+ 'path' => '/v1/databases',
+ 'method' => 'GET',
+ 'status' => '200',
+ 'resource' => 'database',
+ 'resourceId' => 'db123',
+ ]);
+ $this->assertEquals('/v1/databases', $metric->getPath());
+ $this->assertEquals('GET', $metric->getMethod());
+ $this->assertEquals('200', $metric->getStatus());
+ $this->assertEquals('database', $metric->getResource());
+ $this->assertEquals('db123', $metric->getResourceId());
+ }
+
+ /**
+ * Test Metric::getTime() returns timestamp
+ */
+ public function testGetTimeReturnsTimestamp(): void
+ {
+ $time = '2024-01-01 12:00:00';
+ $metric = new Metric(['time' => $time]);
+ $this->assertEquals($time, $metric->getTime());
+ }
+
+ /**
+ * Test Metric::getTime() returns null when not set
+ */
+ public function testGetTimeReturnsNullWhenNotSet(): void
+ {
+ $metric = new Metric([]);
+ $this->assertNull($metric->getTime());
+ }
+
+ /**
+ * Test Metric::getTags() returns tags
+ */
+ public function testGetTagsReturnsTags(): void
+ {
+ $tags = ['region' => 'us-east', 'env' => 'prod'];
+ $metric = new Metric(['tags' => $tags]);
+ $this->assertEquals($tags, $metric->getTags());
+ }
+
+ /**
+ * Test Metric::getTags() returns empty array when not set
+ */
+ public function testGetTagsReturnsEmptyArrayWhenNotSet(): void
+ {
+ $metric = new Metric([]);
+ $this->assertEquals([], $metric->getTags());
+ }
+
+ /**
+ * Test Metric::getTenant() returns tenant ID as string
+ */
+ public function testGetTenantReturnsTenantId(): void
+ {
+ $metric = new Metric(['tenant' => '123']);
+ $this->assertEquals('123', $metric->getTenant());
+ $this->assertIsString($metric->getTenant());
+ }
+
+ /**
+ * Test Metric::getTenant() returns null when not set
+ */
+ public function testGetTenantReturnsNullWhenNotSet(): void
+ {
+ $metric = new Metric([]);
+ $this->assertNull($metric->getTenant());
+ }
+
+ /**
+ * Test Metric::getTenant() converts numeric tenant to string
+ */
+ public function testGetTenantConvertsNumericToString(): void
+ {
+ $metric = new Metric(['tenant' => 456]);
+ $this->assertEquals('456', $metric->getTenant());
+ $this->assertIsString($metric->getTenant());
+ }
+
+ /**
+ * Test Metric::getAttributes() returns all attributes
+ */
+ public function testGetAttributesReturnsAllAttributes(): void
+ {
+ $data = [
+ '$id' => 'metric-1',
+ 'metric' => 'requests',
+ 'value' => 100,
+ ];
+
+ $metric = new Metric($data);
+ $attributes = $metric->getAttributes();
+
+ $this->assertIsArray($attributes);
+ $this->assertEquals('metric-1', $attributes['$id']);
+ $this->assertEquals('requests', $attributes['metric']);
+ $this->assertEquals(100, $attributes['value']);
+ }
+
+ /**
+ * Test Metric::getAttribute() returns attribute value
+ */
+ public function testGetAttributeReturnsValue(): void
+ {
+ $metric = new Metric(['custom' => 'custom-value']);
+ $this->assertEquals('custom-value', $metric->getAttribute('custom'));
+ }
+
+ /**
+ * Test Metric::getAttribute() returns default when not set
+ */
+ public function testGetAttributeReturnsDefaultWhenNotSet(): void
+ {
+ $metric = new Metric([]);
+ $this->assertEquals('default', $metric->getAttribute('missing', 'default'));
+ }
+
+ /**
+ * Test Metric::setAttribute() sets attribute and returns self
+ */
+ public function testSetAttributeSetsAndReturnsSelf(): void
+ {
+ $metric = new Metric([]);
+ $result = $metric->setAttribute('custom', 'value');
+
+ $this->assertSame($metric, $result);
+ $this->assertEquals('value', $metric->getAttribute('custom'));
+ }
+
+ /**
+ * Test Metric::setAttribute() supports method chaining
+ */
+ public function testSetAttributeSupportsChaining(): void
+ {
+ $metric = (new Metric([]))
+ ->setAttribute('attr1', 'value1')
+ ->setAttribute('attr2', 'value2');
+
+ $this->assertEquals('value1', $metric->getAttribute('attr1'));
+ $this->assertEquals('value2', $metric->getAttribute('attr2'));
+ }
+
+ /**
+ * Test Metric::hasAttribute() returns true when attribute exists
+ */
+ public function testHasAttributeReturnsTrueWhenExists(): void
+ {
+ $metric = new Metric(['key' => 'value']);
+ $this->assertTrue($metric->hasAttribute('key'));
+ }
+
+ /**
+ * Test Metric::hasAttribute() returns false when attribute doesn't exist
+ */
+ public function testHasAttributeReturnsFalseWhenNotExists(): void
+ {
+ $metric = new Metric([]);
+ $this->assertFalse($metric->hasAttribute('missing'));
+ }
+
+ /**
+ * Test Metric::removeAttribute() removes attribute and returns self
+ */
+ public function testRemoveAttributeRemovesAndReturnsSelf(): void
+ {
+ $metric = new Metric(['key' => 'value']);
+ $result = $metric->removeAttribute('key');
+
+ $this->assertSame($metric, $result);
+ $this->assertFalse($metric->hasAttribute('key'));
+ }
+
+ /**
+ * Test Metric::isEmpty() returns false when ID is set
+ */
+ public function testIsEmptyReturnsFalseWhenIdSet(): void
+ {
+ $metric = new Metric(['$id' => 'metric-1']);
+ $this->assertFalse($metric->isEmpty());
+ }
+
+ /**
+ * Test Metric::isEmpty() returns true when ID is not set
+ */
+ public function testIsEmptyReturnsTrueWhenNoId(): void
+ {
+ $metric = new Metric([]);
+ $this->assertTrue($metric->isEmpty());
+ }
+
+ /**
+ * Test Metric::toArray() returns array representation
+ */
+ public function testToArrayReturnsArray(): void
+ {
+ $data = [
+ '$id' => 'metric-1',
+ 'metric' => 'requests',
+ 'value' => 100,
+ ];
+
+ $metric = new Metric($data);
+ $array = $metric->toArray();
+
+ $this->assertIsArray($array);
+ $this->assertEquals('metric-1', $array['$id']);
+ $this->assertEquals('requests', $array['metric']);
+ $this->assertEquals(100, $array['value']);
+ }
+
+ /**
+ * Test EVENT_COLUMNS constant
+ */
+ public function testEventColumnsConstant(): void
+ {
+ $expected = ['path', 'method', 'status', 'resource', 'resourceId', 'country', 'userAgent'];
+ $this->assertEquals($expected, Metric::EVENT_COLUMNS);
+ }
+}
diff --git a/tests/Usage/UsageBase.php b/tests/Usage/UsageBase.php
new file mode 100644
index 0000000..1070787
--- /dev/null
+++ b/tests/Usage/UsageBase.php
@@ -0,0 +1,643 @@
+initializeUsage();
+ $this->createUsageMetrics();
+ }
+
+ public function tearDown(): void
+ {
+ $this->usage->purge();
+ }
+
+ 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']],
+ ], Usage::TYPE_EVENT));
+
+ // Gauges: point-in-time snapshots
+ $this->assertTrue($this->usage->addBatch([
+ ['metric' => 'storage', 'value' => 10000, 'tags' => ['region' => 'us-east']],
+ ], Usage::TYPE_GAUGE));
+ }
+
+ public function testAddBatchEvent(): void
+ {
+ $this->usage->purge();
+
+ // addBatch with event type -- values should sum
+ $this->assertTrue($this->usage->addBatch([
+ ['metric' => 'add-metric', 'value' => 10, 'tags' => []],
+ ['metric' => 'add-metric', 'value' => 5, 'tags' => []],
+ ], Usage::TYPE_EVENT));
+
+ $sum = $this->usage->sum([
+ Query::equal('metric', ['add-metric']),
+ ], 'value', Usage::TYPE_EVENT);
+ $this->assertEquals(15, $sum);
+ }
+
+ public function testAddBatchGauge(): void
+ {
+ $this->usage->purge();
+
+ // addBatch with gauge type
+ $this->assertTrue($this->usage->addBatch([
+ ['metric' => 'gauge-metric', 'value' => 100, 'tags' => []],
+ ['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);
+ $this->assertGreaterThanOrEqual(100, $total);
+ }
+
+ public function testAddBatchWithBatchSize(): void
+ {
+ $this->usage->purge();
+
+ $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']],
+ ];
+
+ $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT, 2));
+
+ $results = $this->usage->find([
+ Query::equal('metric', ['batch-requests']),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(1, count($results));
+ }
+
+ public function testFind(): void
+ {
+ $results = $this->usage->find([
+ Query::equal('metric', ['requests']),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(1, count($results));
+ }
+
+ 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([
+ Query::greaterThanEqual('time', $start),
+ Query::lessThanEqual('time', $end),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(0, count($results));
+ }
+
+ public function testCount(): void
+ {
+ $count = $this->usage->count([
+ Query::equal('metric', ['requests']),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(1, $count);
+ }
+
+ public function testSum(): void
+ {
+ $sum = $this->usage->sum([
+ Query::equal('metric', ['requests']),
+ ], 'value', Usage::TYPE_EVENT);
+ $this->assertEquals(250, $sum); // 100 + 150
+ }
+
+ public function testGetTotal(): void
+ {
+ $total = $this->usage->getTotal('requests', [], Usage::TYPE_EVENT);
+ $this->assertEquals(250, $total); // event: SUM
+
+ $total = $this->usage->getTotal('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);
+
+ $this->assertIsArray($totals);
+ $this->assertArrayHasKey('requests', $totals);
+ $this->assertArrayHasKey('bandwidth', $totals);
+
+ $this->assertEquals(250, $totals['requests']);
+ $this->assertEquals(5000, $totals['bandwidth']);
+
+ // Gauge metrics batch
+ $gaugeTotals = $this->usage->getTotalBatch(['storage'], [], Usage::TYPE_GAUGE);
+ $this->assertEquals(10000, $gaugeTotals['storage']);
+ }
+
+ public function testGetTotalBatchWithMissingMetric(): void
+ {
+ $totals = $this->usage->getTotalBatch(['requests', 'nonexistent-metric'], [], Usage::TYPE_EVENT);
+
+ $this->assertEquals(250, $totals['requests']);
+ $this->assertEquals(0, $totals['nonexistent-metric']);
+ }
+
+ public function testGetTotalBatchEmpty(): void
+ {
+ $totals = $this->usage->getTotalBatch([]);
+ $this->assertIsArray($totals);
+ $this->assertEmpty($totals);
+ }
+
+ 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(
+ ['requests'],
+ '1h',
+ $start,
+ $end,
+ [],
+ true,
+ Usage::TYPE_EVENT,
+ );
+
+ $this->assertIsArray($results);
+ $this->assertArrayHasKey('requests', $results);
+ $this->assertArrayHasKey('total', $results['requests']);
+ $this->assertArrayHasKey('data', $results['requests']);
+ $this->assertGreaterThanOrEqual(0, $results['requests']['total']);
+ }
+
+ 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(
+ ['requests', 'bandwidth'],
+ '1d',
+ $start,
+ $end,
+ [],
+ true,
+ Usage::TYPE_EVENT,
+ );
+
+ $this->assertArrayHasKey('requests', $results);
+ $this->assertArrayHasKey('bandwidth', $results);
+ }
+
+ public function testEqualWithArrayValues(): void
+ {
+ // Test equal query with array of values (IN clause)
+ $results = $this->usage->find([
+ Query::equal('metric', ['requests', 'bandwidth']),
+ ], Usage::TYPE_EVENT);
+
+ // Should find all metrics matching either 'requests' or 'bandwidth'
+ $this->assertGreaterThanOrEqual(2, count($results));
+ }
+
+ public function testContainsQuery(): void
+ {
+ // Test contains query with multiple values from events
+ $results = $this->usage->find([
+ Query::contains('metric', ['requests', 'bandwidth']),
+ ], Usage::TYPE_EVENT);
+
+ // Should find all metrics matching either 'requests' or 'bandwidth'
+ $this->assertGreaterThanOrEqual(2, count($results));
+ }
+
+ public function testLessThanEqualQuery(): void
+ {
+ $now = (new \DateTime())->format('Y-m-d\TH:i:s');
+ $results = $this->usage->find([
+ Query::lessThanEqual('time', $now),
+ ], Usage::TYPE_EVENT);
+
+ $this->assertGreaterThanOrEqual(0, count($results));
+ }
+
+ public function testGreaterThanEqualQuery(): void
+ {
+ $past = (new \DateTime())->modify('-24 hours')->format('Y-m-d\TH:i:s');
+ $results = $this->usage->find([
+ Query::greaterThanEqual('time', $past),
+ ], Usage::TYPE_EVENT);
+
+ $this->assertGreaterThanOrEqual(0, count($results));
+ }
+
+ public function testPurge(): void
+ {
+ sleep(2);
+
+ $this->usage->addBatch([
+ ['metric' => 'purge-test', 'value' => 999, 'tags' => []],
+ ], Usage::TYPE_EVENT);
+
+ sleep(2);
+
+ $status = $this->usage->purge([], Usage::TYPE_EVENT);
+ $this->assertTrue($status);
+
+ $results = $this->usage->find([
+ Query::equal('metric', ['purge-test']),
+ ], Usage::TYPE_EVENT);
+ $this->assertEquals(0, count($results));
+ }
+
+ public function testPurgeWithQueries(): void
+ {
+ $this->usage->purge();
+
+ $this->assertTrue($this->usage->addBatch([
+ ['metric' => 'purge-keep', 'value' => 10, 'tags' => []],
+ ['metric' => 'purge-remove', 'value' => 20, 'tags' => []],
+ ], Usage::TYPE_EVENT));
+
+ // Purge only the 'purge-remove' metric
+ $status = $this->usage->purge([
+ Query::equal('metric', ['purge-remove']),
+ ], Usage::TYPE_EVENT);
+ $this->assertTrue($status);
+
+ // 'purge-remove' should be gone
+ $sum = $this->usage->sum([
+ Query::equal('metric', ['purge-remove']),
+ ], 'value', Usage::TYPE_EVENT);
+ $this->assertEquals(0, $sum);
+
+ // 'purge-keep' should still exist
+ $sum = $this->usage->sum([
+ Query::equal('metric', ['purge-keep']),
+ ], 'value', Usage::TYPE_EVENT);
+ $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([
+ Query::equal('metric', ['requests']),
+ Query::limit(1),
+ ], Usage::TYPE_EVENT);
+
+ $this->assertEquals(1, count($results));
+
+ $results2 = $this->usage->find([
+ Query::equal('metric', ['requests']),
+ Query::limit(1),
+ Query::offset(1),
+ ], Usage::TYPE_EVENT);
+
+ $this->assertLessThanOrEqual(1, count($results2));
+ }
+
+ public function testEmptyBatch(): void
+ {
+ $this->assertTrue($this->usage->addBatch([], Usage::TYPE_EVENT));
+ }
+
+ 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']],
+ ];
+
+ $this->assertTrue($this->usage->addBatch($metrics, Usage::TYPE_EVENT));
+
+ $results = $this->usage->find([
+ Query::equal('metric', ['tagged']),
+ ], Usage::TYPE_EVENT);
+ $this->assertGreaterThanOrEqual(1, count($results));
+ }
+
+ public function testGroupByIntervalHourly(): void
+ {
+ $this->usage->purge();
+
+ // 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' => []],
+ ], 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([
+ UsageQuery::groupByInterval('time', '1h'),
+ Query::equal('metric', ['gbi-requests']),
+ Query::greaterThanEqual('time', $start),
+ Query::lessThanEqual('time', $end),
+ ], Usage::TYPE_EVENT);
+
+ $this->assertGreaterThanOrEqual(1, count($results));
+
+ // All results should have bucketed time values and summed values
+ $totalValue = 0;
+ foreach ($results as $metric) {
+ $this->assertEquals('gbi-requests', $metric->getMetric());
+ $this->assertNotNull($metric->getTime());
+ $totalValue += $metric->getValue();
+ }
+
+ // SUM should be 100 + 50 = 150
+ $this->assertEquals(150, $totalValue);
+ }
+
+ public function testGroupByIntervalDaily(): void
+ {
+ $this->usage->purge();
+
+ $this->assertTrue($this->usage->addBatch([
+ ['metric' => 'gbi-daily', 'value' => 200, 'tags' => []],
+ ['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([
+ UsageQuery::groupByInterval('time', '1d'),
+ Query::equal('metric', ['gbi-daily']),
+ Query::greaterThanEqual('time', $start),
+ Query::lessThanEqual('time', $end),
+ ], Usage::TYPE_EVENT);
+
+ $this->assertGreaterThanOrEqual(1, count($results));
+
+ $totalValue = 0;
+ foreach ($results as $metric) {
+ $this->assertEquals('gbi-daily', $metric->getMetric());
+ $totalValue += $metric->getValue();
+ }
+
+ $this->assertEquals(500, $totalValue);
+ }
+
+ public function testGroupByIntervalGauge(): void
+ {
+ $this->usage->purge();
+
+ $this->assertTrue($this->usage->addBatch([
+ ['metric' => 'gbi-storage', 'value' => 1000, 'tags' => []],
+ ['metric' => 'gbi-storage', 'value' => 2000, 'tags' => []],
+ ['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([
+ UsageQuery::groupByInterval('time', '1h'),
+ Query::equal('metric', ['gbi-storage']),
+ Query::greaterThanEqual('time', $start),
+ Query::lessThanEqual('time', $end),
+ ], Usage::TYPE_GAUGE);
+
+ $this->assertGreaterThanOrEqual(1, count($results));
+
+ // Gauge uses argMax — should return the latest value per bucket
+ foreach ($results as $metric) {
+ $this->assertEquals('gbi-storage', $metric->getMetric());
+ $this->assertGreaterThanOrEqual(1000, $metric->getValue());
+ }
+ }
+
+ public function testGroupByIntervalInvalidInterval(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ UsageQuery::groupByInterval('time', '2h');
+ }
+
+ public function testGroupByIntervalWithLimitOffset(): void
+ {
+ $this->usage->purge();
+
+ $this->assertTrue($this->usage->addBatch([
+ ['metric' => 'gbi-limit', 'value' => 10, 'tags' => []],
+ ['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([
+ UsageQuery::groupByInterval('time', '1h'),
+ Query::equal('metric', ['gbi-limit']),
+ Query::greaterThanEqual('time', $start),
+ Query::lessThanEqual('time', $end),
+ Query::limit(10),
+ ], Usage::TYPE_EVENT);
+
+ $this->assertGreaterThanOrEqual(1, count($results));
+ }
+}
+
diff --git a/tests/Usage/UsageQueryTest.php b/tests/Usage/UsageQueryTest.php
new file mode 100644
index 0000000..4d00353
--- /dev/null
+++ b/tests/Usage/UsageQueryTest.php
@@ -0,0 +1,118 @@
+assertInstanceOf(UsageQuery::class, $query);
+ $this->assertEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $query->getMethod());
+ $this->assertEquals('time', $query->getAttribute());
+ $this->assertEquals(['1h'], $query->getValues());
+ $this->assertEquals('1h', $query->getValue());
+ }
+
+ public function testGroupByIntervalAllValidIntervals(): void
+ {
+ $validIntervals = ['1m', '5m', '15m', '1h', '1d', '1w', '1M'];
+
+ foreach ($validIntervals as $interval) {
+ $query = UsageQuery::groupByInterval('time', $interval);
+ $this->assertEquals($interval, $query->getValue());
+ }
+ }
+
+ public function testGroupByIntervalInvalidInterval(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage("Invalid interval '2h'");
+ UsageQuery::groupByInterval('time', '2h');
+ }
+
+ public function testGroupByIntervalInvalidIntervalEmpty(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ UsageQuery::groupByInterval('time', '');
+ }
+
+ public function testIsGroupByInterval(): void
+ {
+ $groupByQuery = UsageQuery::groupByInterval('time', '1h');
+ $regularQuery = Query::equal('metric', ['bandwidth']);
+
+ $this->assertTrue(UsageQuery::isGroupByInterval($groupByQuery));
+ $this->assertFalse(UsageQuery::isGroupByInterval($regularQuery));
+ }
+
+ public function testExtractGroupByInterval(): void
+ {
+ $groupByQuery = UsageQuery::groupByInterval('time', '1h');
+ $equalQuery = Query::equal('metric', ['bandwidth']);
+ $timeQuery = Query::greaterThanEqual('time', '2026-03-01');
+
+ $queries = [$equalQuery, $groupByQuery, $timeQuery];
+
+ $extracted = UsageQuery::extractGroupByInterval($queries);
+
+ $this->assertNotNull($extracted);
+ $this->assertInstanceOf(UsageQuery::class, $extracted);
+ $this->assertEquals('1h', $extracted->getValue());
+ }
+
+ public function testExtractGroupByIntervalReturnsNullWhenMissing(): void
+ {
+ $queries = [
+ Query::equal('metric', ['bandwidth']),
+ Query::greaterThanEqual('time', '2026-03-01'),
+ ];
+
+ $this->assertNull(UsageQuery::extractGroupByInterval($queries));
+ }
+
+ public function testRemoveGroupByInterval(): void
+ {
+ $groupByQuery = UsageQuery::groupByInterval('time', '1h');
+ $equalQuery = Query::equal('metric', ['bandwidth']);
+ $timeQuery = Query::greaterThanEqual('time', '2026-03-01');
+
+ $queries = [$equalQuery, $groupByQuery, $timeQuery];
+ $remaining = UsageQuery::removeGroupByInterval($queries);
+
+ $this->assertCount(2, $remaining);
+
+ foreach ($remaining as $query) {
+ $this->assertNotEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $query->getMethod());
+ }
+ }
+
+ public function testValidIntervalsConstant(): void
+ {
+ $this->assertIsArray(UsageQuery::VALID_INTERVALS);
+ $this->assertArrayHasKey('1m', UsageQuery::VALID_INTERVALS);
+ $this->assertArrayHasKey('5m', UsageQuery::VALID_INTERVALS);
+ $this->assertArrayHasKey('15m', UsageQuery::VALID_INTERVALS);
+ $this->assertArrayHasKey('1h', UsageQuery::VALID_INTERVALS);
+ $this->assertArrayHasKey('1d', UsageQuery::VALID_INTERVALS);
+ $this->assertArrayHasKey('1w', UsageQuery::VALID_INTERVALS);
+ $this->assertArrayHasKey('1M', UsageQuery::VALID_INTERVALS);
+
+ // Verify interval SQL values
+ $this->assertEquals('INTERVAL 1 HOUR', UsageQuery::VALID_INTERVALS['1h']);
+ $this->assertEquals('INTERVAL 1 DAY', UsageQuery::VALID_INTERVALS['1d']);
+ $this->assertEquals('INTERVAL 1 MINUTE', UsageQuery::VALID_INTERVALS['1m']);
+ $this->assertEquals('INTERVAL 1 MONTH', UsageQuery::VALID_INTERVALS['1M']);
+ }
+
+ public function testUsageQueryExtendsQuery(): void
+ {
+ $query = UsageQuery::groupByInterval('time', '1h');
+ $this->assertInstanceOf(Query::class, $query);
+ }
+}