diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..d18cee0
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,22 @@
+name: "CodeQL"
+
+on: [ pull_request ]
+jobs:
+ codeql:
+ name: CodeQL
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ - name: Run linter
+ run: composer lint
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
new file mode 100644
index 0000000..01dc041
--- /dev/null
+++ b/.github/workflows/linter.yml
@@ -0,0 +1,16 @@
+name: "Linter"
+
+on: [ pull_request ]
+jobs:
+ lint:
+ name: Linter
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - 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..a09ff00
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,19 @@
+name: "Tests"
+
+on: [ pull_request ]
+jobs:
+ tests:
+ name: Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Build
+ run: |
+ docker compose build
+ docker compose up -d --wait
+
+ - name: Run Tests
+ run: docker compose exec usage php vendor/bin/phpunit
diff --git a/.gitignore b/.gitignore
index a725465..aa30d8b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
-vendor/
\ No newline at end of file
+vendor/
+.phpunit.result.cache
\ 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..3962c2e
--- /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](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..5f0ff9c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,28 @@
+FROM composer:2 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 addgroup -S appgroup && adduser -S appuser -G appgroup
+
+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
+
+USER appuser
+
+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..b59a9bd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,317 @@
+# 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
+
+- **Pluggable Adapters**: Use different storage backends (Database, ClickHouse)
+- **Database Adapter**: Store metrics in any SQL database via utopia-php/database
+- **ClickHouse Adapter**: High-performance analytics storage for massive scale
+- **Flexible Periods**: Hourly (1h), Daily (1d), and Infinite (inf) periods
+- **Dual Upsert Semantics**: Additive (`increment`) and replace (`set`) upserts
+- **In-Memory Buffering**: Collect metrics and flush in batch for high-throughput scenarios
+- **Auto Period Fan-Out**: `increment()`, `set()`, `collect()`, `collectSet()` automatically write to all periods
+- **Batch Operations**: `incrementBatch()` and `setBatch()` for efficient bulk writes
+- **Async Inserts**: ClickHouse adapter supports server-side async inserts
+- **Rich Queries**: Filter, limit, offset, and aggregate metrics
+- **Tag Support**: Add custom tags for multi-dimensional analytics
+
+## Getting Started
+
+Install using composer:
+```bash
+composer require utopia-php/usage
+```
+
+### Using Database Adapter
+
+The Database adapter stores metrics using utopia-php/database, supporting MySQL, MariaDB, PostgreSQL, and more.
+
+```php
+ 3, // Seconds
+ PDO::ATTR_PERSISTENT => true,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_EMULATE_PREPARES => true,
+ PDO::ATTR_STRINGIFY_FETCHES => true,
+]);
+
+$cache = new Cache(new NoCache());
+$database = new Database(new MySQL($pdo), $cache);
+$database->setNamespace('namespace');
+
+// Create Usage instance with Database adapter
+$adapter = new DatabaseAdapter($database);
+$usage = new Usage($adapter);
+$usage->setup();
+```
+
+### Using ClickHouse Adapter
+
+The ClickHouse adapter provides high-performance analytics storage for massive scale metrics.
+
+```php
+setup();
+```
+
+### Using Custom Adapter
+
+You can create custom adapters by extending the `Utopia\Usage\Adapter` abstract class.
+
+```php
+setup();
+```
+
+## Metric Types
+
+The library supports two types of metrics with different upsert semantics:
+
+### Increment (Additive Upsert)
+
+Values are **summed** when the same metric/period/time bucket already exists. Use for event-driven counters like request counts, bandwidth, etc.
+
+```php
+// Single metric, auto fan-out to all periods (1h, 1d, inf)
+$usage->increment('requests', 1);
+$usage->increment('bandwidth', 5000, ['region' => 'us-east']);
+
+// Batch with explicit periods
+$usage->incrementBatch([
+ ['metric' => 'requests', 'value' => 100, 'period' => '1h', 'tags' => ['method' => 'GET']],
+ ['metric' => 'bandwidth', 'value' => 50000, 'period' => '1h', 'tags' => ['region' => 'us-east']],
+]);
+```
+
+### Set (Replace Upsert)
+
+Values **replace** the existing value when the same metric/period/time bucket already exists. Use for periodic recounts or resource gauges (e.g., current storage size, active user count).
+
+```php
+// Single metric, auto fan-out to all periods (1h, 1d, inf)
+$usage->set('storage.size', 1048576);
+$usage->set('users.active', 42, ['plan' => 'pro']);
+
+// Batch with explicit periods
+$usage->setBatch([
+ ['metric' => 'storage.size', 'value' => 1048576, 'period' => '1h', 'tags' => []],
+ ['metric' => 'users.active', 'value' => 42, 'period' => '1d', 'tags' => []],
+]);
+```
+
+## In-Memory Buffering
+
+For high-throughput scenarios (e.g., inside a request loop or worker), use `collect()` / `collectSet()` to accumulate metrics in memory and `flush()` to write them in batch.
+
+```php
+// Accumulate increment metrics (values are summed in-memory)
+$usage->collect('requests', 1);
+$usage->collect('requests', 1);
+$usage->collect('bandwidth', 5000);
+
+// Accumulate set metrics (last-write-wins in-memory)
+$usage->collectSet('storage.size', 1048576);
+
+// Check if flush is recommended (threshold or interval reached)
+if ($usage->shouldFlush()) {
+ $usage->flush();
+}
+
+// Or flush explicitly
+$usage->flush();
+```
+
+### Flush Configuration
+
+```php
+// Flush when 5000 collect() calls have been made (default: 10,000)
+$usage->setFlushThreshold(5000);
+
+// Flush when 10 seconds have elapsed since last flush (default: 20)
+$usage->setFlushInterval(10);
+```
+
+## Querying Metrics
+
+**Get Usage By Period**
+
+```php
+$metrics = $usage->getByPeriod('requests', '1h');
+// Returns an array of Metric objects
+```
+
+**Get Usage Between Dates**
+
+```php
+$start = '2024-01-01 00:00:00';
+$end = '2024-01-31 23:59:59';
+
+$metrics = $usage->getBetweenDates('requests', $start, $end);
+```
+
+**Count and Sum Usage**
+
+```php
+// Count total records
+$count = $usage->countByPeriod('requests', '1h');
+
+// Sum all values
+$sum = $usage->sumByPeriod('requests', '1h');
+```
+
+**Find with Query Objects**
+
+```php
+use Utopia\Query\Query;
+
+$metrics = $usage->find([
+ Query::equal('metric', ['requests', 'bandwidth']),
+ Query::greaterThan('value', 100),
+ Query::orderDesc('time'),
+ Query::limit(10),
+]);
+
+$count = $usage->count([
+ Query::equal('period', ['1h']),
+]);
+```
+
+**Purge Old Usage**
+
+```php
+use Utopia\Query\Query;
+use Utopia\Database\DateTime;
+
+$datetime = DateTime::addSeconds(new \DateTime(), -86400); // Delete metrics older than 24 hours
+$usage->purge([
+ Query::lessThan('time', $datetime),
+]);
+```
+
+## Periods
+
+The library supports three types of periods:
+
+- `1h` - Hourly periods (`Y-m-d H:00`)
+- `1d` - Daily periods (`Y-m-d 00:00`)
+- `inf` - Infinite/lifetime periods (`0000-00-00 00:00`)
+
+## Adapters
+
+### Database Adapter
+
+The Database adapter uses [utopia-php/database](https://github.com/utopia-php/database) to store metrics in SQL databases.
+
+**Features**:
+- Works with MySQL, MariaDB, PostgreSQL, SQLite
+- Full query support (filters, sorting, pagination)
+- ACID compliance for data consistency
+- Additive upsert via `upsertDocumentsWithIncrease`
+- Replace upsert via `upsertDocuments`
+
+### ClickHouse Adapter
+
+The ClickHouse adapter uses the HTTP interface to store metrics in ClickHouse for high-performance analytics.
+
+**Features**:
+- SummingMergeTree for additive upserts (`usage` table)
+- ReplacingMergeTree for replace upserts (`usage_snapshot` table)
+- Automatic partitioning by month
+- Efficient compression and storage
+- Bloom filter indexes for fast lookups
+- Async insert support for server-side batching
+- Deterministic IDs for correct merge behavior
+
+**Example**:
+```php
+use Utopia\Usage\Usage;
+use Utopia\Usage\Adapter\ClickHouse;
+
+$adapter = new ClickHouse(
+ host: 'clickhouse.example.com',
+ username: 'metrics_user',
+ password: 'secure_password',
+ port: 8123,
+ secure: true // Use HTTPS
+);
+
+// Enable async inserts (server-side batching)
+$adapter->setAsyncInserts(true, waitForConfirmation: true);
+
+$usage = new Usage($adapter);
+$usage->setup();
+```
+
+### Creating Custom Adapters
+
+Extend the `Utopia\Usage\Adapter` abstract class and implement these methods:
+
+- `getName(): string` - Return adapter name
+- `setup(): void` - Initialize storage structure
+- `healthCheck(): array` - Check adapter health
+- `incrementBatch(array $metrics, int $batchSize): bool` - Additive upsert batch
+- `setBatch(array $metrics, int $batchSize): bool` - Replace upsert batch
+- `getByPeriod(string $metric, string $period, array $queries): array` - Get metrics by period
+- `getBetweenDates(string $metric, string $startDate, string $endDate, array $queries): array` - Get metrics in date range
+- `countByPeriod(string $metric, string $period, array $queries): int` - Count metrics
+- `sumByPeriod(string $metric, string $period, array $queries): int` - Sum metric values
+- `purge(array $queries = []): bool` - Delete old metrics
+- `find(array $queries): array` - Find metrics with query objects
+- `count(array $queries): int` - Count metrics with query objects
+
+## 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..65b4e29 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
"name": "utopia-php/usage",
- "description": "Light and Fast Usage library",
+ "description": "Usage analytics and metered billing library with ClickHouse support",
"type": "library",
"license": "MIT",
"authors": [
@@ -9,10 +9,29 @@
"email": "team@appwrite.io"
}
],
+ "scripts": {
+ "lint": "./vendor/bin/pint --test",
+ "format": "./vendor/bin/pint",
+ "test": "./vendor/bin/phpunit"
+ },
"minimum-stability": "stable",
"require": {
- "utopia-php/fetch": "^0.4.2",
- "utopia-php/database": "^4.3"
+ "php": ">=8.3",
+ "utopia-php/fetch": "0.5.*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5",
+ "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..bc4492f 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,98 +4,80 @@
"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": "944ca92a0481c2d4a8c137d33548de17",
"packages": [
{
- "name": "brick/math",
- "version": "0.14.1",
+ "name": "utopia-php/fetch",
+ "version": "0.5.1",
"source": {
"type": "git",
- "url": "https://github.com/brick/math.git",
- "reference": "f05858549e5f9d7bb45875a75583240a38a281d0"
+ "url": "https://github.com/utopia-php/fetch.git",
+ "reference": "a96a010e1c273f3888765449687baf58cbc61fcd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0",
- "reference": "f05858549e5f9d7bb45875a75583240a38a281d0",
+ "url": "https://api.github.com/repos/utopia-php/fetch/zipball/a96a010e1c273f3888765449687baf58cbc61fcd",
+ "reference": "a96a010e1c273f3888765449687baf58cbc61fcd",
"shasum": ""
},
"require": {
- "php": "^8.2"
+ "php": ">=8.1"
},
"require-dev": {
- "php-coveralls/php-coveralls": "^2.2",
- "phpstan/phpstan": "2.1.22",
- "phpunit/phpunit": "^11.5"
+ "laravel/pint": "^1.5.0",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^9.5"
},
"type": "library",
"autoload": {
"psr-4": {
- "Brick\\Math\\": "src/"
+ "Utopia\\Fetch\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
- "description": "Arbitrary-precision arithmetic library",
- "keywords": [
- "Arbitrary-precision",
- "BigInteger",
- "BigRational",
- "arithmetic",
- "bigdecimal",
- "bignum",
- "bignumber",
- "brick",
- "decimal",
- "integer",
- "math",
- "mathematics",
- "rational"
- ],
+ "description": "A simple library that provides an interface for making HTTP Requests.",
"support": {
- "issues": "https://github.com/brick/math/issues",
- "source": "https://github.com/brick/math/tree/0.14.1"
+ "issues": "https://github.com/utopia-php/fetch/issues",
+ "source": "https://github.com/utopia-php/fetch/tree/0.5.1"
},
- "funding": [
- {
- "url": "https://github.com/BenMorel",
- "type": "github"
- }
- ],
- "time": "2025-11-24T14:40:29+00:00"
- },
+ "time": "2025-12-18T16:25:10+00:00"
+ }
+ ],
+ "packages-dev": [
{
- "name": "composer/semver",
- "version": "3.4.4",
+ "name": "doctrine/instantiator",
+ "version": "2.0.0",
"source": {
"type": "git",
- "url": "https://github.com/composer/semver.git",
- "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
+ "url": "https://github.com/doctrine/instantiator.git",
+ "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
- "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0",
+ "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0",
"shasum": ""
},
"require": {
- "php": "^5.3.2 || ^7.0 || ^8.0"
+ "php": "^8.1"
},
"require-dev": {
- "phpstan/phpstan": "^1.11",
- "symfony/phpunit-bridge": "^3 || ^7"
+ "doctrine/coding-standard": "^11",
+ "ext-pdo": "*",
+ "ext-phar": "*",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/phpstan": "^1.9.4",
+ "phpstan/phpstan-phpunit": "^1.3",
+ "phpunit/phpunit": "^9.5.27",
+ "vimeo/psalm": "^5.4"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "3.x-dev"
- }
- },
"autoload": {
"psr-4": {
- "Composer\\Semver\\": "src"
+ "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -104,2385 +86,1844 @@
],
"authors": [
{
- "name": "Nils Adermann",
- "email": "naderman@naderman.de",
- "homepage": "http://www.naderman.de"
- },
- {
- "name": "Jordi Boggiano",
- "email": "j.boggiano@seld.be",
- "homepage": "http://seld.be"
- },
- {
- "name": "Rob Bast",
- "email": "rob.bast@gmail.com",
- "homepage": "http://robbast.nl"
+ "name": "Marco Pivetta",
+ "email": "ocramius@gmail.com",
+ "homepage": "https://ocramius.github.io/"
}
],
- "description": "Semver library that offers utilities, version constraint parsing and validation.",
+ "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+ "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
"keywords": [
- "semantic",
- "semver",
- "validation",
- "versioning"
+ "constructor",
+ "instantiate"
],
"support": {
- "irc": "ircs://irc.libera.chat:6697/composer",
- "issues": "https://github.com/composer/semver/issues",
- "source": "https://github.com/composer/semver/tree/3.4.4"
+ "issues": "https://github.com/doctrine/instantiator/issues",
+ "source": "https://github.com/doctrine/instantiator/tree/2.0.0"
},
"funding": [
{
- "url": "https://packagist.com",
+ "url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
- "url": "https://github.com/composer",
- "type": "github"
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+ "type": "tidelift"
}
],
- "time": "2025-08-20T19:15:30+00:00"
+ "time": "2022-12-30T00:23:10+00:00"
},
{
- "name": "google/protobuf",
- "version": "v4.33.2",
+ "name": "laravel/pint",
+ "version": "v1.29.0",
"source": {
"type": "git",
- "url": "https://github.com/protocolbuffers/protobuf-php.git",
- "reference": "fbd96b7bf1343f4b0d8fb358526c7ba4d72f1318"
+ "url": "https://github.com/laravel/pint.git",
+ "reference": "bdec963f53172c5e36330f3a400604c69bf02d39"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/fbd96b7bf1343f4b0d8fb358526c7ba4d72f1318",
- "reference": "fbd96b7bf1343f4b0d8fb358526c7ba4d72f1318",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39",
+ "reference": "bdec963f53172c5e36330f3a400604c69bf02d39",
"shasum": ""
},
"require": {
- "php": ">=8.1.0"
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "ext-tokenizer": "*",
+ "ext-xml": "*",
+ "php": "^8.2.0"
},
"require-dev": {
- "phpunit/phpunit": ">=5.0.0 <8.5.27"
- },
- "suggest": {
- "ext-bcmath": "Need to support JSON deserialization"
- },
- "type": "library",
+ "friendsofphp/php-cs-fixer": "^3.94.2",
+ "illuminate/view": "^12.54.1",
+ "larastan/larastan": "^3.9.3",
+ "laravel-zero/framework": "^12.0.5",
+ "mockery/mockery": "^1.6.12",
+ "nunomaduro/termwind": "^2.4.0",
+ "pestphp/pest": "^3.8.6",
+ "shipfastlabs/agent-detector": "^1.1.0"
+ },
+ "bin": [
+ "builds/pint"
+ ],
+ "type": "project",
"autoload": {
"psr-4": {
- "Google\\Protobuf\\": "src/Google/Protobuf",
- "GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf"
+ "App\\": "app/",
+ "Database\\Seeders\\": "database/seeders/",
+ "Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "BSD-3-Clause"
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
],
- "description": "proto library for PHP",
- "homepage": "https://developers.google.com/protocol-buffers/",
+ "description": "An opinionated code formatter for PHP.",
+ "homepage": "https://laravel.com",
"keywords": [
- "proto"
+ "dev",
+ "format",
+ "formatter",
+ "lint",
+ "linter",
+ "php"
],
"support": {
- "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.2"
+ "issues": "https://github.com/laravel/pint/issues",
+ "source": "https://github.com/laravel/pint"
},
- "time": "2025-12-05T22:12:22+00:00"
+ "time": "2026-03-12T15:51:39+00:00"
},
{
- "name": "mongodb/mongodb",
- "version": "2.1.2",
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
"source": {
"type": "git",
- "url": "https://github.com/mongodb/mongo-php-library.git",
- "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67"
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/0a2472ba9cbb932f7e43a8770aedb2fc30612a67",
- "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"shasum": ""
},
"require": {
- "composer-runtime-api": "^2.0",
- "ext-mongodb": "^2.1",
- "php": "^8.1",
- "psr/log": "^1.1.4|^2|^3",
- "symfony/polyfill-php85": "^1.32"
+ "php": "^7.1 || ^8.0"
},
- "replace": {
- "mongodb/builder": "*"
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
},
"require-dev": {
- "doctrine/coding-standard": "^12.0",
- "phpunit/phpunit": "^10.5.35",
- "rector/rector": "^2.1.4",
- "squizlabs/php_codesniffer": "^3.7",
- "vimeo/psalm": "6.5.*"
+ "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",
- "extra": {
- "branch-alias": {
- "dev-master": "1.x-dev"
- }
- },
"autoload": {
"files": [
- "src/functions.php"
+ "src/DeepCopy/deep_copy.php"
],
"psr-4": {
- "MongoDB\\": "src/"
+ "DeepCopy\\": "src/DeepCopy/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "Apache-2.0"
- ],
- "authors": [
- {
- "name": "Andreas Braun",
- "email": "andreas.braun@mongodb.com"
- },
- {
- "name": "Jeremy Mikola",
- "email": "jmikola@gmail.com"
- },
- {
- "name": "JΓ©rΓ΄me Tamarelle",
- "email": "jerome.tamarelle@mongodb.com"
- }
+ "MIT"
],
- "description": "MongoDB driver library",
- "homepage": "https://jira.mongodb.org/browse/PHPLIB",
+ "description": "Create deep copies (clones) of your objects",
"keywords": [
- "database",
- "driver",
- "mongodb",
- "persistence"
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
],
"support": {
- "issues": "https://github.com/mongodb/mongo-php-library/issues",
- "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.2"
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
},
- "time": "2025-10-06T12:12:40+00:00"
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
},
{
- "name": "nyholm/psr7",
- "version": "1.8.2",
+ "name": "nikic/php-parser",
+ "version": "v5.7.0",
"source": {
"type": "git",
- "url": "https://github.com/Nyholm/psr7.git",
- "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
- "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"shasum": ""
},
"require": {
- "php": ">=7.2",
- "psr/http-factory": "^1.0",
- "psr/http-message": "^1.1 || ^2.0"
- },
- "provide": {
- "php-http/message-factory-implementation": "1.0",
- "psr/http-factory-implementation": "1.0",
- "psr/http-message-implementation": "1.0"
+ "ext-ctype": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
},
"require-dev": {
- "http-interop/http-factory-tests": "^0.9",
- "php-http/message-factory": "^1.0",
- "php-http/psr7-integration-tests": "^1.0",
- "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
- "symfony/error-handler": "^4.4"
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
},
+ "bin": [
+ "bin/php-parse"
+ ],
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.8-dev"
+ "dev-master": "5.x-dev"
}
},
"autoload": {
"psr-4": {
- "Nyholm\\Psr7\\": "src/"
+ "PhpParser\\": "lib/PhpParser"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Tobias Nyholm",
- "email": "tobias.nyholm@gmail.com"
- },
- {
- "name": "Martijn van der Ven",
- "email": "martijn@vanderven.se"
+ "name": "Nikita Popov"
}
],
- "description": "A fast PHP7 implementation of PSR-7",
- "homepage": "https://tnyholm.se",
+ "description": "A PHP parser written in PHP",
"keywords": [
- "psr-17",
- "psr-7"
+ "parser",
+ "php"
],
"support": {
- "issues": "https://github.com/Nyholm/psr7/issues",
- "source": "https://github.com/Nyholm/psr7/tree/1.8.2"
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
},
- "funding": [
- {
- "url": "https://github.com/Zegnat",
- "type": "github"
- },
- {
- "url": "https://github.com/nyholm",
- "type": "github"
- }
- ],
- "time": "2024-09-09T07:06:30+00:00"
+ "time": "2025-12-06T11:56:16+00:00"
},
{
- "name": "nyholm/psr7-server",
- "version": "1.1.0",
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
"source": {
"type": "git",
- "url": "https://github.com/Nyholm/psr7-server.git",
- "reference": "4335801d851f554ca43fa6e7d2602141538854dc"
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc",
- "reference": "4335801d851f554ca43fa6e7d2602141538854dc",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
"shasum": ""
},
"require": {
- "php": "^7.1 || ^8.0",
- "psr/http-factory": "^1.0",
- "psr/http-message": "^1.0 || ^2.0"
- },
- "require-dev": {
- "nyholm/nsa": "^1.1",
- "nyholm/psr7": "^1.3",
- "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3"
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
},
"type": "library",
- "autoload": {
- "psr-4": {
- "Nyholm\\Psr7Server\\": "src/"
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
}
},
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Tobias Nyholm",
- "email": "tobias.nyholm@gmail.com"
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
},
{
- "name": "Martijn van der Ven",
- "email": "martijn@vanderven.se"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
}
],
- "description": "Helper classes to handle PSR-7 server requests",
- "homepage": "http://tnyholm.se",
- "keywords": [
- "psr-17",
- "psr-7"
- ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
"support": {
- "issues": "https://github.com/Nyholm/psr7-server/issues",
- "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0"
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
},
"funding": [
{
- "url": "https://github.com/Zegnat",
- "type": "github"
- },
- {
- "url": "https://github.com/nyholm",
+ "url": "https://github.com/theseer",
"type": "github"
}
],
- "time": "2023-11-08T09:30:43+00:00"
+ "time": "2024-03-03T12:33:53+00:00"
},
{
- "name": "open-telemetry/api",
- "version": "1.7.1",
+ "name": "phar-io/version",
+ "version": "3.2.1",
"source": {
"type": "git",
- "url": "https://github.com/opentelemetry-php/api.git",
- "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4"
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4",
- "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
"shasum": ""
},
"require": {
- "open-telemetry/context": "^1.4",
- "php": "^8.1",
- "psr/log": "^1.1|^2.0|^3.0",
- "symfony/polyfill-php82": "^1.26"
- },
- "conflict": {
- "open-telemetry/sdk": "<=1.0.8"
+ "php": "^7.2 || ^8.0"
},
"type": "library",
- "extra": {
- "spi": {
- "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [
- "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager"
- ]
- },
- "branch-alias": {
- "dev-main": "1.8.x-dev"
- }
- },
"autoload": {
- "files": [
- "Trace/functions.php"
- ],
- "psr-4": {
- "OpenTelemetry\\API\\": "."
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "Apache-2.0"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "opentelemetry-php contributors",
- "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ "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": "API for OpenTelemetry PHP.",
- "keywords": [
- "Metrics",
- "api",
- "apm",
- "logging",
- "opentelemetry",
- "otel",
- "tracing"
- ],
+ "description": "Library for handling version information and constraints",
"support": {
- "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
- "docs": "https://opentelemetry.io/docs/languages/php",
- "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
- "source": "https://github.com/open-telemetry/opentelemetry-php"
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
},
- "time": "2025-10-19T10:49:48+00:00"
+ "time": "2022-02-21T01:04:05+00:00"
},
{
- "name": "open-telemetry/context",
- "version": "1.4.0",
+ "name": "phpunit/php-code-coverage",
+ "version": "9.2.32",
"source": {
"type": "git",
- "url": "https://github.com/opentelemetry-php/context.git",
- "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf"
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/d4c4470b541ce72000d18c339cfee633e4c8e0cf",
- "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5",
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5",
"shasum": ""
},
"require": {
- "php": "^8.1",
- "symfony/polyfill-php82": "^1.26"
+ "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-ffi": "To allow context switching in Fibers"
+ "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": "1.0.x-dev"
+ "dev-main": "9.2.x-dev"
}
},
"autoload": {
- "files": [
- "fiber/initialize_fiber_handler.php"
- ],
- "psr-4": {
- "OpenTelemetry\\Context\\": "."
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "Apache-2.0"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "opentelemetry-php contributors",
- "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "Context implementation for OpenTelemetry PHP.",
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
"keywords": [
- "Context",
- "opentelemetry",
- "otel"
+ "coverage",
+ "testing",
+ "xunit"
],
"support": {
- "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
- "docs": "https://opentelemetry.io/docs/php",
- "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
- "source": "https://github.com/open-telemetry/opentelemetry-php"
+ "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"
},
- "time": "2025-09-19T00:05:49+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-22T04:23:01+00:00"
},
{
- "name": "open-telemetry/exporter-otlp",
- "version": "1.3.3",
+ "name": "phpunit/php-file-iterator",
+ "version": "3.0.6",
"source": {
"type": "git",
- "url": "https://github.com/opentelemetry-php/exporter-otlp.git",
- "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d"
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/07b02bc71838463f6edcc78d3485c04b48fb263d",
- "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
"shasum": ""
},
"require": {
- "open-telemetry/api": "^1.0",
- "open-telemetry/gen-otlp-protobuf": "^1.1",
- "open-telemetry/sdk": "^1.0",
- "php": "^8.1",
- "php-http/discovery": "^1.14"
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.0.x-dev"
+ "dev-master": "3.0-dev"
}
},
"autoload": {
- "files": [
- "_register.php"
- ],
- "psr-4": {
- "OpenTelemetry\\Contrib\\Otlp\\": "."
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "Apache-2.0"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "opentelemetry-php contributors",
- "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "OTLP exporter for OpenTelemetry.",
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
"keywords": [
- "Metrics",
- "exporter",
- "gRPC",
- "http",
- "opentelemetry",
- "otel",
- "otlp",
- "tracing"
+ "filesystem",
+ "iterator"
],
"support": {
- "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
- "docs": "https://opentelemetry.io/docs/languages/php",
- "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
- "source": "https://github.com/open-telemetry/opentelemetry-php"
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
},
- "time": "2025-11-13T08:04:37+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2021-12-02T12:48:52+00:00"
},
{
- "name": "open-telemetry/gen-otlp-protobuf",
- "version": "1.8.0",
+ "name": "phpunit/php-invoker",
+ "version": "3.1.1",
"source": {
"type": "git",
- "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git",
- "reference": "673af5b06545b513466081884b47ef15a536edde"
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/673af5b06545b513466081884b47ef15a536edde",
- "reference": "673af5b06545b513466081884b47ef15a536edde",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
"shasum": ""
},
"require": {
- "google/protobuf": "^3.22 || ^4.0",
- "php": "^8.0"
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^9.3"
},
"suggest": {
- "ext-protobuf": "For better performance, when dealing with the protobuf format"
+ "ext-pcntl": "*"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.x-dev"
+ "dev-master": "3.1-dev"
}
},
"autoload": {
- "psr-4": {
- "Opentelemetry\\Proto\\": "Opentelemetry/Proto/",
- "GPBMetadata\\Opentelemetry\\": "GPBMetadata/Opentelemetry/"
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "Apache-2.0"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "opentelemetry-php contributors",
- "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "PHP protobuf files for communication with OpenTelemetry OTLP collectors/servers.",
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
"keywords": [
- "Metrics",
- "apm",
- "gRPC",
- "logging",
- "opentelemetry",
- "otel",
- "otlp",
- "protobuf",
- "tracing"
+ "process"
],
"support": {
- "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
- "docs": "https://opentelemetry.io/docs/php",
- "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
- "source": "https://github.com/open-telemetry/opentelemetry-php"
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
},
- "time": "2025-09-17T23:10:12+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:58:55+00:00"
},
{
- "name": "open-telemetry/sdk",
- "version": "1.10.0",
+ "name": "phpunit/php-text-template",
+ "version": "2.0.4",
"source": {
"type": "git",
- "url": "https://github.com/opentelemetry-php/sdk.git",
- "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99"
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99",
- "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
"shasum": ""
},
"require": {
- "ext-json": "*",
- "nyholm/psr7-server": "^1.1",
- "open-telemetry/api": "^1.7",
- "open-telemetry/context": "^1.4",
- "open-telemetry/sem-conv": "^1.0",
- "php": "^8.1",
- "php-http/discovery": "^1.14",
- "psr/http-client": "^1.0",
- "psr/http-client-implementation": "^1.0",
- "psr/http-factory-implementation": "^1.0",
- "psr/http-message": "^1.0.1|^2.0",
- "psr/log": "^1.1|^2.0|^3.0",
- "ramsey/uuid": "^3.0 || ^4.0",
- "symfony/polyfill-mbstring": "^1.23",
- "symfony/polyfill-php82": "^1.26",
- "tbachert/spi": "^1.0.5"
+ "php": ">=7.3"
},
- "suggest": {
- "ext-gmp": "To support unlimited number of synchronous metric readers",
- "ext-mbstring": "To increase performance of string operations",
- "open-telemetry/sdk-configuration": "File-based OpenTelemetry SDK configuration"
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
- "spi": {
- "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [
- "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig",
- "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig"
- ],
- "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [
- "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver"
- ],
- "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [
- "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager"
- ]
- },
"branch-alias": {
- "dev-main": "1.9.x-dev"
+ "dev-master": "2.0-dev"
}
},
"autoload": {
- "files": [
- "Common/Util/functions.php",
- "Logs/Exporter/_register.php",
- "Metrics/MetricExporter/_register.php",
- "Propagation/_register.php",
- "Trace/SpanExporter/_register.php",
- "Common/Dev/Compatibility/_load.php",
- "_autoload.php"
- ],
- "psr-4": {
- "OpenTelemetry\\SDK\\": "."
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "Apache-2.0"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "opentelemetry-php contributors",
- "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "SDK for OpenTelemetry PHP.",
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
"keywords": [
- "Metrics",
- "apm",
- "logging",
- "opentelemetry",
- "otel",
- "sdk",
- "tracing"
+ "template"
],
"support": {
- "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
- "docs": "https://opentelemetry.io/docs/languages/php",
- "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
- "source": "https://github.com/open-telemetry/opentelemetry-php"
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
},
- "time": "2025-11-25T10:59:15+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T05:33:50+00:00"
},
{
- "name": "open-telemetry/sem-conv",
- "version": "1.37.0",
+ "name": "phpunit/php-timer",
+ "version": "5.0.3",
"source": {
"type": "git",
- "url": "https://github.com/opentelemetry-php/sem-conv.git",
- "reference": "8da7ec497c881e39afa6657d72586e27efbd29a1"
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/8da7ec497c881e39afa6657d72586e27efbd29a1",
- "reference": "8da7ec497c881e39afa6657d72586e27efbd29a1",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
"shasum": ""
},
"require": {
- "php": "^8.0"
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.x-dev"
+ "dev-master": "5.0-dev"
}
},
"autoload": {
- "psr-4": {
- "OpenTelemetry\\SemConv\\": "."
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "Apache-2.0"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "opentelemetry-php contributors",
- "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "Semantic conventions for OpenTelemetry PHP.",
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
"keywords": [
- "Metrics",
- "apm",
- "logging",
- "opentelemetry",
- "otel",
- "semantic conventions",
- "semconv",
- "tracing"
+ "timer"
],
"support": {
- "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
- "docs": "https://opentelemetry.io/docs/php",
- "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
- "source": "https://github.com/open-telemetry/opentelemetry-php"
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
},
- "time": "2025-09-03T12:08:10+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:16:10+00:00"
},
{
- "name": "php-http/discovery",
- "version": "1.20.0",
+ "name": "phpunit/phpunit",
+ "version": "9.6.34",
"source": {
"type": "git",
- "url": "https://github.com/php-http/discovery.git",
- "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "b36f02317466907a230d3aa1d34467041271ef4a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
- "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a",
+ "reference": "b36f02317466907a230d3aa1d34467041271ef4a",
"shasum": ""
},
"require": {
- "composer-plugin-api": "^1.0|^2.0",
- "php": "^7.1 || ^8.0"
- },
- "conflict": {
- "nyholm/psr7": "<1.0",
- "zendframework/zend-diactoros": "*"
+ "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"
},
- "provide": {
- "php-http/async-client-implementation": "*",
- "php-http/client-implementation": "*",
- "psr/http-client-implementation": "*",
- "psr/http-factory-implementation": "*",
- "psr/http-message-implementation": "*"
+ "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"
},
- "require-dev": {
- "composer/composer": "^1.0.2|^2.0",
- "graham-campbell/phpspec-skip-example-extension": "^5.0",
- "php-http/httplug": "^1.0 || ^2.0",
- "php-http/message-factory": "^1.0",
- "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
- "sebastian/comparator": "^3.0.5 || ^4.0.8",
- "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
- },
- "type": "composer-plugin",
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
"extra": {
- "class": "Http\\Discovery\\Composer\\Plugin",
- "plugin-optional": true
+ "branch-alias": {
+ "dev-master": "9.6-dev"
+ }
},
"autoload": {
- "psr-4": {
- "Http\\Discovery\\": "src/"
- },
- "exclude-from-classmap": [
- "src/Composer/Plugin.php"
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "MΓ‘rk SΓ‘gi-KazΓ‘r",
- "email": "mark.sagikazar@gmail.com"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
- "homepage": "http://php-http.org",
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
"keywords": [
- "adapter",
- "client",
- "discovery",
- "factory",
- "http",
- "message",
- "psr17",
- "psr7"
+ "phpunit",
+ "testing",
+ "xunit"
],
"support": {
- "issues": "https://github.com/php-http/discovery/issues",
- "source": "https://github.com/php-http/discovery/tree/1.20.0"
+ "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"
},
- "time": "2024-10-02T11:20:13+00:00"
+ "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": "psr/container",
- "version": "2.0.2",
+ "name": "sebastian/cli-parser",
+ "version": "1.0.2",
"source": {
"type": "git",
- "url": "https://github.com/php-fig/container.git",
- "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
- "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
"shasum": ""
},
"require": {
- "php": ">=7.4.0"
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0.x-dev"
+ "dev-master": "1.0-dev"
}
},
"autoload": {
- "psr-4": {
- "Psr\\Container\\": "src/"
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "Common Container Interface (PHP FIG PSR-11)",
- "homepage": "https://github.com/php-fig/container",
- "keywords": [
- "PSR-11",
- "container",
- "container-interface",
- "container-interop",
- "psr"
- ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
"support": {
- "issues": "https://github.com/php-fig/container/issues",
- "source": "https://github.com/php-fig/container/tree/2.0.2"
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2"
},
- "time": "2021-11-05T16:47:00+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T06:27:43+00:00"
},
{
- "name": "psr/http-client",
- "version": "1.0.3",
+ "name": "sebastian/code-unit",
+ "version": "1.0.8",
"source": {
"type": "git",
- "url": "https://github.com/php-fig/http-client.git",
- "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+ "url": "https://github.com/sebastianbergmann/code-unit.git",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
- "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
"shasum": ""
},
"require": {
- "php": "^7.0 || ^8.0",
- "psr/http-message": "^1.0 || ^2.0"
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.0.x-dev"
+ "dev-master": "1.0-dev"
}
},
"autoload": {
- "psr-4": {
- "Psr\\Http\\Client\\": "src/"
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "Common interface for HTTP clients",
- "homepage": "https://github.com/php-fig/http-client",
- "keywords": [
- "http",
- "http-client",
- "psr",
- "psr-18"
- ],
+ "description": "Collection of value objects that represent the PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/code-unit",
"support": {
- "source": "https://github.com/php-fig/http-client"
+ "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
},
- "time": "2023-09-23T14:17:50+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:08:54+00:00"
},
{
- "name": "psr/http-factory",
- "version": "1.1.0",
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "2.0.3",
"source": {
"type": "git",
- "url": "https://github.com/php-fig/http-factory.git",
- "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
- "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
"shasum": ""
},
"require": {
- "php": ">=7.1",
- "psr/http-message": "^1.0 || ^2.0"
+ "php": ">=7.3"
},
- "type": "library",
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.0.x-dev"
+ "dev-master": "2.0-dev"
}
},
"autoload": {
- "psr-4": {
- "Psr\\Http\\Message\\": "src/"
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
}
],
- "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
- "keywords": [
- "factory",
- "http",
- "message",
- "psr",
- "psr-17",
- "psr-7",
- "request",
- "response"
- ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
"support": {
- "source": "https://github.com/php-fig/http-factory"
- },
- "time": "2024-04-15T12:06:14+00:00"
- },
- {
- "name": "psr/http-message",
- "version": "2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/http-message.git",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
},
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
- "shasum": ""
- },
- "require": {
- "php": "^7.2 || ^8.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Message\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
+ "funding": [
{
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
}
],
- "description": "Common interface for HTTP messages",
- "homepage": "https://github.com/php-fig/http-message",
- "keywords": [
- "http",
- "http-message",
- "psr",
- "psr-7",
- "request",
- "response"
- ],
- "support": {
- "source": "https://github.com/php-fig/http-message/tree/2.0"
- },
- "time": "2023-04-04T09:54:51+00:00"
+ "time": "2020-09-28T05:30:19+00:00"
},
{
- "name": "psr/log",
- "version": "3.0.2",
+ "name": "sebastian/comparator",
+ "version": "4.0.10",
"source": {
"type": "git",
- "url": "https://github.com/php-fig/log.git",
- "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
- "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d",
"shasum": ""
},
"require": {
- "php": ">=8.0.0"
+ "php": ">=7.3",
+ "sebastian/diff": "^4.0",
+ "sebastian/exporter": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.x-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
- "psr-4": {
- "Psr\\Log\\": "src"
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
+ "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": "Common interface for logging libraries",
- "homepage": "https://github.com/php-fig/log",
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
"keywords": [
- "log",
- "psr",
- "psr-3"
+ "comparator",
+ "compare",
+ "equality"
],
"support": {
- "source": "https://github.com/php-fig/log/tree/3.0.2"
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10"
},
- "time": "2024-09-11T13:17:53+00:00"
+ "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": "ramsey/collection",
- "version": "2.1.1",
+ "name": "sebastian/complexity",
+ "version": "2.0.3",
"source": {
"type": "git",
- "url": "https://github.com/ramsey/collection.git",
- "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2"
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2",
- "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a",
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a",
"shasum": ""
},
"require": {
- "php": "^8.1"
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=7.3"
},
"require-dev": {
- "captainhook/plugin-composer": "^5.3",
- "ergebnis/composer-normalize": "^2.45",
- "fakerphp/faker": "^1.24",
- "hamcrest/hamcrest-php": "^2.0",
- "jangregor/phpstan-prophecy": "^2.1",
- "mockery/mockery": "^1.6",
- "php-parallel-lint/php-console-highlighter": "^1.0",
- "php-parallel-lint/php-parallel-lint": "^1.4",
- "phpspec/prophecy-phpunit": "^2.3",
- "phpstan/extension-installer": "^1.4",
- "phpstan/phpstan": "^2.1",
- "phpstan/phpstan-mockery": "^2.0",
- "phpstan/phpstan-phpunit": "^2.0",
- "phpunit/phpunit": "^10.5",
- "ramsey/coding-standard": "^2.3",
- "ramsey/conventional-commits": "^1.6",
- "roave/security-advisories": "dev-latest"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
- "captainhook": {
- "force-install": true
- },
- "ramsey/conventional-commits": {
- "configFile": "conventional-commits.json"
+ "branch-alias": {
+ "dev-master": "2.0-dev"
}
},
"autoload": {
- "psr-4": {
- "Ramsey\\Collection\\": "src/"
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Ben Ramsey",
- "email": "ben@benramsey.com",
- "homepage": "https://benramsey.com"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "A PHP library for representing and manipulating collections.",
- "keywords": [
- "array",
- "collection",
- "hash",
- "map",
- "queue",
- "set"
- ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
"support": {
- "issues": "https://github.com/ramsey/collection/issues",
- "source": "https://github.com/ramsey/collection/tree/2.1.1"
- },
- "time": "2025-03-22T05:38:12+00:00"
- },
- {
- "name": "ramsey/uuid",
- "version": "4.9.1",
- "source": {
- "type": "git",
- "url": "https://github.com/ramsey/uuid.git",
- "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440"
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3"
},
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440",
- "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440",
- "shasum": ""
- },
- "require": {
- "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
- "php": "^8.0",
- "ramsey/collection": "^1.2 || ^2.0"
- },
- "replace": {
- "rhumsaa/uuid": "self.version"
- },
- "require-dev": {
- "captainhook/captainhook": "^5.25",
- "captainhook/plugin-composer": "^5.3",
- "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
- "ergebnis/composer-normalize": "^2.47",
- "mockery/mockery": "^1.6",
- "paragonie/random-lib": "^2",
- "php-mock/php-mock": "^2.6",
- "php-mock/php-mock-mockery": "^1.5",
- "php-parallel-lint/php-parallel-lint": "^1.4.0",
- "phpbench/phpbench": "^1.2.14",
- "phpstan/extension-installer": "^1.4",
- "phpstan/phpstan": "^2.1",
- "phpstan/phpstan-mockery": "^2.0",
- "phpstan/phpstan-phpunit": "^2.0",
- "phpunit/phpunit": "^9.6",
- "slevomat/coding-standard": "^8.18",
- "squizlabs/php_codesniffer": "^3.13"
- },
- "suggest": {
- "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.",
- "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.",
- "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.",
- "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter",
- "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type."
- },
- "type": "library",
- "extra": {
- "captainhook": {
- "force-install": true
- }
- },
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "Ramsey\\Uuid\\": "src/"
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
}
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).",
- "keywords": [
- "guid",
- "identifier",
- "uuid"
],
- "support": {
- "issues": "https://github.com/ramsey/uuid/issues",
- "source": "https://github.com/ramsey/uuid/tree/4.9.1"
- },
- "time": "2025-09-04T20:59:21+00:00"
+ "time": "2023-12-22T06:19:30+00:00"
},
{
- "name": "symfony/deprecation-contracts",
- "version": "v3.6.0",
+ "name": "sebastian/diff",
+ "version": "4.0.6",
"source": {
"type": "git",
- "url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc",
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3",
+ "symfony/process": "^4.2 || ^5"
},
"type": "library",
"extra": {
- "thanks": {
- "url": "https://github.com/symfony/contracts",
- "name": "symfony/contracts"
- },
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
- "files": [
- "function.php"
+ "classmap": [
+ "src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
},
{
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
}
],
- "description": "A generic function and convention to trigger deprecation notices",
- "homepage": "https://symfony.com",
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6"
},
"funding": [
{
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
+ "url": "https://github.com/sebastianbergmann",
"type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2024-03-02T06:30:58+00:00"
},
{
- "name": "symfony/http-client",
- "version": "v7.4.1",
+ "name": "sebastian/environment",
+ "version": "5.1.5",
"source": {
"type": "git",
- "url": "https://github.com/symfony/http-client.git",
- "reference": "26cc224ea7103dda90e9694d9e139a389092d007"
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/26cc224ea7103dda90e9694d9e139a389092d007",
- "reference": "26cc224ea7103dda90e9694d9e139a389092d007",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
"shasum": ""
},
"require": {
- "php": ">=8.2",
- "psr/log": "^1|^2|^3",
- "symfony/deprecation-contracts": "^2.5|^3",
- "symfony/http-client-contracts": "~3.4.4|^3.5.2",
- "symfony/polyfill-php83": "^1.29",
- "symfony/service-contracts": "^2.5|^3"
- },
- "conflict": {
- "amphp/amp": "<2.5",
- "amphp/socket": "<1.1",
- "php-http/discovery": "<1.15",
- "symfony/http-foundation": "<6.4"
- },
- "provide": {
- "php-http/async-client-implementation": "*",
- "php-http/client-implementation": "*",
- "psr/http-client-implementation": "1.0",
- "symfony/http-client-implementation": "3.0"
+ "php": ">=7.3"
},
"require-dev": {
- "amphp/http-client": "^4.2.1|^5.0",
- "amphp/http-tunnel": "^1.0|^2.0",
- "guzzlehttp/promises": "^1.4|^2.0",
- "nyholm/psr7": "^1.0",
- "php-http/httplug": "^1.0|^2.0",
- "psr/http-client": "^1.0",
- "symfony/amphp-http-client-meta": "^1.0|^2.0",
- "symfony/cache": "^6.4|^7.0|^8.0",
- "symfony/dependency-injection": "^6.4|^7.0|^8.0",
- "symfony/http-kernel": "^6.4|^7.0|^8.0",
- "symfony/messenger": "^6.4|^7.0|^8.0",
- "symfony/process": "^6.4|^7.0|^8.0",
- "symfony/rate-limiter": "^6.4|^7.0|^8.0",
- "symfony/stopwatch": "^6.4|^7.0|^8.0"
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-posix": "*"
},
"type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.1-dev"
+ }
+ },
"autoload": {
- "psr-4": {
- "Symfony\\Component\\HttpClient\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
+ "classmap": [
+ "src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
}
],
- "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
- "homepage": "https://symfony.com",
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "http://www.github.com/sebastianbergmann/environment",
"keywords": [
- "http"
+ "Xdebug",
+ "environment",
+ "hhvm"
],
"support": {
- "source": "https://github.com/symfony/http-client/tree/v7.4.1"
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5"
},
"funding": [
{
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://github.com/nicolas-grekas",
+ "url": "https://github.com/sebastianbergmann",
"type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
}
],
- "time": "2025-12-04T21:12:57+00:00"
+ "time": "2023-02-03T06:03:51+00:00"
},
{
- "name": "symfony/http-client-contracts",
- "version": "v3.6.0",
+ "name": "sebastian/exporter",
+ "version": "4.0.8",
"source": {
"type": "git",
- "url": "https://github.com/symfony/http-client-contracts.git",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c"
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+ "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "ext-mbstring": "*",
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
- "thanks": {
- "url": "https://github.com/symfony/contracts",
- "name": "symfony/contracts"
- },
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
- "psr-4": {
- "Symfony\\Contracts\\HttpClient\\": ""
- },
- "exclude-from-classmap": [
- "/Test/"
+ "classmap": [
+ "src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
},
{
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
}
],
- "description": "Generic abstractions related to HTTP clients",
- "homepage": "https://symfony.com",
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
"keywords": [
- "abstractions",
- "contracts",
- "decoupling",
- "interfaces",
- "interoperability",
- "standards"
+ "export",
+ "exporter"
],
"support": {
- "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
},
"funding": [
{
- "url": "https://symfony.com/sponsor",
- "type": "custom"
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
},
{
- "url": "https://github.com/fabpot",
- "type": "github"
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
},
{
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
"type": "tidelift"
}
],
- "time": "2025-04-29T11:18:49+00:00"
+ "time": "2025-09-24T06:03:27+00:00"
},
{
- "name": "symfony/polyfill-mbstring",
- "version": "v1.33.0",
+ "name": "sebastian/global-state",
+ "version": "5.0.8",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
"shasum": ""
},
"require": {
- "ext-iconv": "*",
- "php": ">=7.2"
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
},
- "provide": {
- "ext-mbstring": "*"
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^9.3"
},
"suggest": {
- "ext-mbstring": "For best performance"
+ "ext-uopz": "*"
},
"type": "library",
"extra": {
- "thanks": {
- "url": "https://github.com/symfony/polyfill",
- "name": "symfony/polyfill"
+ "branch-alias": {
+ "dev-master": "5.0-dev"
}
},
"autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Mbstring\\": ""
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
}
],
- "description": "Symfony polyfill for the Mbstring extension",
- "homepage": "https://symfony.com",
+ "description": "Snapshotting of global state",
+ "homepage": "http://www.github.com/sebastianbergmann/global-state",
"keywords": [
- "compatibility",
- "mbstring",
- "polyfill",
- "portable",
- "shim"
+ "global state"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
},
"funding": [
{
- "url": "https://symfony.com/sponsor",
- "type": "custom"
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
},
{
- "url": "https://github.com/fabpot",
- "type": "github"
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
},
{
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
},
{
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
"type": "tidelift"
}
],
- "time": "2024-12-23T08:48:59+00:00"
+ "time": "2025-08-10T07:10:35+00:00"
},
{
- "name": "symfony/polyfill-php82",
- "version": "v1.33.0",
+ "name": "sebastian/lines-of-code",
+ "version": "1.0.4",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-php82.git",
- "reference": "5d2ed36f7734637dacc025f179698031951b1692"
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692",
- "reference": "5d2ed36f7734637dacc025f179698031951b1692",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5",
"shasum": ""
},
"require": {
- "php": ">=7.2"
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
- "thanks": {
- "url": "https://github.com/symfony/polyfill",
- "name": "symfony/polyfill"
+ "branch-alias": {
+ "dev-master": "1.0-dev"
}
},
"autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Php82\\": ""
- },
"classmap": [
- "Resources/stubs"
+ "src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "polyfill",
- "portable",
- "shim"
- ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
"support": {
- "source": "https://github.com/symfony/polyfill-php82/tree/v1.33.0"
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4"
},
"funding": [
{
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://github.com/nicolas-grekas",
+ "url": "https://github.com/sebastianbergmann",
"type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2023-12-22T06:20:34+00:00"
},
{
- "name": "symfony/polyfill-php83",
- "version": "v1.33.0",
+ "name": "sebastian/object-enumerator",
+ "version": "4.0.4",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-php83.git",
- "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
- "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
"shasum": ""
},
"require": {
- "php": ">=7.2"
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
- "thanks": {
- "url": "https://github.com/symfony/polyfill",
- "name": "symfony/polyfill"
+ "branch-alias": {
+ "dev-master": "4.0-dev"
}
},
"autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Php83\\": ""
- },
"classmap": [
- "Resources/stubs"
+ "src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
}
],
- "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "polyfill",
- "portable",
- "shim"
- ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
"support": {
- "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
},
"funding": [
{
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://github.com/nicolas-grekas",
+ "url": "https://github.com/sebastianbergmann",
"type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
}
],
- "time": "2025-07-08T02:45:35+00:00"
+ "time": "2020-10-26T13:12:34+00:00"
},
{
- "name": "symfony/polyfill-php85",
- "version": "v1.33.0",
+ "name": "sebastian/object-reflector",
+ "version": "2.0.4",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-php85.git",
- "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91"
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
- "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
"shasum": ""
},
"require": {
- "php": ">=7.2"
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
- "thanks": {
- "url": "https://github.com/symfony/polyfill",
- "name": "symfony/polyfill"
+ "branch-alias": {
+ "dev-master": "2.0-dev"
}
},
"autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Php85\\": ""
- },
"classmap": [
- "Resources/stubs"
+ "src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
}
],
- "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "polyfill",
- "portable",
- "shim"
- ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
"support": {
- "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0"
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
},
"funding": [
{
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
+ "url": "https://github.com/sebastianbergmann",
"type": "github"
- },
- {
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
}
],
- "time": "2025-06-23T16:12:55+00:00"
+ "time": "2020-10-26T13:14:26+00:00"
},
{
- "name": "symfony/service-contracts",
- "version": "v3.6.1",
+ "name": "sebastian/recursion-context",
+ "version": "4.0.6",
"source": {
"type": "git",
- "url": "https://github.com/symfony/service-contracts.git",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0",
+ "reference": "539c6691e0623af6dc6f9c20384c120f963465a0",
"shasum": ""
},
"require": {
- "php": ">=8.1",
- "psr/container": "^1.1|^2.0",
- "symfony/deprecation-contracts": "^2.5|^3"
+ "php": ">=7.3"
},
- "conflict": {
- "ext-psr": "<1.1|>=2"
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
- "thanks": {
- "url": "https://github.com/symfony/contracts",
- "name": "symfony/contracts"
- },
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
- "psr-4": {
- "Symfony\\Contracts\\Service\\": ""
- },
- "exclude-from-classmap": [
- "/Test/"
+ "classmap": [
+ "src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
},
{
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
}
],
- "description": "Generic abstractions related to writing services",
- "homepage": "https://symfony.com",
- "keywords": [
- "abstractions",
- "contracts",
- "decoupling",
- "interfaces",
- "interoperability",
- "standards"
- ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6"
},
"funding": [
{
- "url": "https://symfony.com/sponsor",
- "type": "custom"
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
},
{
- "url": "https://github.com/fabpot",
- "type": "github"
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
},
{
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
},
{
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
"type": "tidelift"
}
],
- "time": "2025-07-15T11:30:57+00:00"
+ "time": "2025-08-10T06:57:39+00:00"
},
{
- "name": "tbachert/spi",
- "version": "v1.0.5",
+ "name": "sebastian/resource-operations",
+ "version": "3.0.4",
"source": {
"type": "git",
- "url": "https://github.com/Nevay/spi.git",
- "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002"
+ "url": "https://github.com/sebastianbergmann/resource-operations.git",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Nevay/spi/zipball/e7078767866d0a9e0f91d3f9d42a832df5e39002",
- "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
"shasum": ""
},
"require": {
- "composer-plugin-api": "^2.0",
- "composer/semver": "^1.0 || ^2.0 || ^3.0",
- "php": "^8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "composer/composer": "^2.0",
- "infection/infection": "^0.27.9",
- "phpunit/phpunit": "^10.5",
- "psalm/phar": "^5.18"
+ "phpunit/phpunit": "^9.0"
},
- "type": "composer-plugin",
+ "type": "library",
"extra": {
- "class": "Nevay\\SPI\\Composer\\Plugin",
"branch-alias": {
- "dev-main": "1.0.x-dev"
- },
- "plugin-optional": true
+ "dev-main": "3.0-dev"
+ }
},
"autoload": {
- "psr-4": {
- "Nevay\\SPI\\": "src/"
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "Apache-2.0"
+ "BSD-3-Clause"
],
- "description": "Service provider loading facility",
- "keywords": [
- "service provider"
+ "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": {
- "issues": "https://github.com/Nevay/spi/issues",
- "source": "https://github.com/Nevay/spi/tree/v1.0.5"
- },
- "time": "2025-06-29T15:42:06+00:00"
- },
- {
- "name": "utopia-php/cache",
- "version": "0.13.1",
- "source": {
- "type": "git",
- "url": "https://github.com/utopia-php/cache.git",
- "reference": "97220cb3b3822b166ee016d1646e2ae2815dc540"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/utopia-php/cache/zipball/97220cb3b3822b166ee016d1646e2ae2815dc540",
- "reference": "97220cb3b3822b166ee016d1646e2ae2815dc540",
- "shasum": ""
+ "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4"
},
- "require": {
- "ext-json": "*",
- "ext-memcached": "*",
- "ext-redis": "*",
- "php": ">=8.0",
- "utopia-php/pools": "0.8.*",
- "utopia-php/telemetry": "0.1.*"
- },
- "require-dev": {
- "laravel/pint": "1.2.*",
- "phpstan/phpstan": "^1.12",
- "phpunit/phpunit": "^9.3",
- "vimeo/psalm": "4.13.1"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Utopia\\Cache\\": "src/Cache"
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
}
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "A simple cache library to manage application cache storing, loading and purging",
- "keywords": [
- "cache",
- "framework",
- "php",
- "upf",
- "utopia"
],
- "support": {
- "issues": "https://github.com/utopia-php/cache/issues",
- "source": "https://github.com/utopia-php/cache/tree/0.13.1"
- },
- "time": "2025-05-09T14:43:52+00:00"
+ "time": "2024-03-14T16:00:52+00:00"
},
{
- "name": "utopia-php/compression",
- "version": "0.1.3",
+ "name": "sebastian/type",
+ "version": "3.2.1",
"source": {
"type": "git",
- "url": "https://github.com/utopia-php/compression.git",
- "reference": "66f093557ba66d98245e562036182016c7dcfe8a"
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/compression/zipball/66f093557ba66d98245e562036182016c7dcfe8a",
- "reference": "66f093557ba66d98245e562036182016c7dcfe8a",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
"shasum": ""
},
"require": {
- "php": ">=8.0"
+ "php": ">=7.3"
},
"require-dev": {
- "laravel/pint": "1.2.*",
- "phpunit/phpunit": "^9.3",
- "vimeo/psalm": "4.0.1"
+ "phpunit/phpunit": "^9.5"
},
"type": "library",
- "autoload": {
- "psr-4": {
- "Utopia\\Compression\\": "src/Compression"
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.2-dev"
}
},
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "A simple Compression library to handle file compression",
- "keywords": [
- "compression",
- "framework",
- "php",
- "upf",
- "utopia"
- ],
- "support": {
- "issues": "https://github.com/utopia-php/compression/issues",
- "source": "https://github.com/utopia-php/compression/tree/0.1.3"
- },
- "time": "2025-01-15T15:15:51+00:00"
- },
- {
- "name": "utopia-php/database",
- "version": "4.3.0",
- "source": {
- "type": "git",
- "url": "https://github.com/utopia-php/database.git",
- "reference": "fe7a1326ad623609e65587fe8c01a630a7075fee"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/utopia-php/database/zipball/fe7a1326ad623609e65587fe8c01a630a7075fee",
- "reference": "fe7a1326ad623609e65587fe8c01a630a7075fee",
- "shasum": ""
- },
- "require": {
- "ext-mbstring": "*",
- "ext-mongodb": "*",
- "ext-pdo": "*",
- "php": ">=8.1",
- "utopia-php/cache": "0.13.*",
- "utopia-php/framework": "0.33.*",
- "utopia-php/mongo": "0.11.*",
- "utopia-php/pools": "0.8.*"
- },
- "require-dev": {
- "fakerphp/faker": "1.23.*",
- "laravel/pint": "*",
- "pcov/clobber": "2.*",
- "phpstan/phpstan": "1.*",
- "phpunit/phpunit": "9.*",
- "rregeer/phpunit-coverage-check": "0.3.*",
- "swoole/ide-helper": "5.1.3",
- "utopia-php/cli": "0.14.*"
- },
- "type": "library",
"autoload": {
- "psr-4": {
- "Utopia\\Database\\": "src/Database"
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
- "description": "A simple library to manage application persistence using multiple database adapters",
- "keywords": [
- "database",
- "framework",
- "php",
- "upf",
- "utopia"
+ "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/utopia-php/database/issues",
- "source": "https://github.com/utopia-php/database/tree/4.3.0"
- },
- "time": "2025-11-14T03:43:10+00:00"
- },
- {
- "name": "utopia-php/fetch",
- "version": "0.4.2",
- "source": {
- "type": "git",
- "url": "https://github.com/utopia-php/fetch.git",
- "reference": "83986d1be75a2fae4e684107fe70dd78a8e19b77"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/utopia-php/fetch/zipball/83986d1be75a2fae4e684107fe70dd78a8e19b77",
- "reference": "83986d1be75a2fae4e684107fe70dd78a8e19b77",
- "shasum": ""
- },
- "require": {
- "php": ">=8.0"
- },
- "require-dev": {
- "laravel/pint": "^1.5.0",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^9.5"
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "source": "https://github.com/sebastianbergmann/type/tree/3.2.1"
},
- "type": "library",
- "autoload": {
- "psr-4": {
- "Utopia\\Fetch\\": "src/"
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
}
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
],
- "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"
- },
- "time": "2025-04-25T13:48:02+00:00"
+ "time": "2023-02-03T06:13:03+00:00"
},
{
- "name": "utopia-php/framework",
- "version": "0.33.34",
+ "name": "sebastian/version",
+ "version": "3.0.2",
"source": {
"type": "git",
- "url": "https://github.com/utopia-php/http.git",
- "reference": "76def92594c32504ec80eaacdb60ff8fad73c856"
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/http/zipball/76def92594c32504ec80eaacdb60ff8fad73c856",
- "reference": "76def92594c32504ec80eaacdb60ff8fad73c856",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c",
"shasum": ""
},
"require": {
- "php": ">=8.3",
- "utopia-php/compression": "0.1.*",
- "utopia-php/telemetry": "0.1.*",
- "utopia-php/validators": "0.1.*"
- },
- "require-dev": {
- "laravel/pint": "1.*",
- "phpbench/phpbench": "1.*",
- "phpstan/phpstan": "1.*",
- "phpunit/phpunit": "9.*"
+ "php": ">=7.3"
},
"type": "library",
- "autoload": {
- "psr-4": {
- "Utopia\\": "src/"
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
}
},
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "A simple, light and advanced PHP framework",
- "keywords": [
- "framework",
- "php",
- "upf"
- ],
- "support": {
- "issues": "https://github.com/utopia-php/http/issues",
- "source": "https://github.com/utopia-php/http/tree/0.33.34"
- },
- "time": "2025-12-08T07:55:31+00:00"
- },
- {
- "name": "utopia-php/mongo",
- "version": "0.11.0",
- "source": {
- "type": "git",
- "url": "https://github.com/utopia-php/mongo.git",
- "reference": "34bc0cda8ea368cde68702a6fffe2c3ac625398e"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/utopia-php/mongo/zipball/34bc0cda8ea368cde68702a6fffe2c3ac625398e",
- "reference": "34bc0cda8ea368cde68702a6fffe2c3ac625398e",
- "shasum": ""
- },
- "require": {
- "ext-mongodb": "2.1.*",
- "mongodb/mongodb": "2.1.*",
- "php": ">=8.0",
- "ramsey/uuid": "4.9.*"
- },
- "require-dev": {
- "fakerphp/faker": "1.*",
- "laravel/pint": "*",
- "phpstan/phpstan": "*",
- "phpunit/phpunit": "9.*",
- "swoole/ide-helper": "5.1.*"
- },
- "type": "library",
"autoload": {
- "psr-4": {
- "Utopia\\Mongo\\": "src"
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
"authors": [
{
- "name": "Eldad Fux",
- "email": "eldad@appwrite.io"
- },
- {
- "name": "Wess",
- "email": "wess@appwrite.io"
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "A simple library to manage Mongo database",
- "keywords": [
- "database",
- "mongo",
- "php",
- "upf",
- "utopia"
- ],
+ "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/utopia-php/mongo/issues",
- "source": "https://github.com/utopia-php/mongo/tree/0.11.0"
- },
- "time": "2025-10-20T11:11:23+00:00"
- },
- {
- "name": "utopia-php/pools",
- "version": "0.8.2",
- "source": {
- "type": "git",
- "url": "https://github.com/utopia-php/pools.git",
- "reference": "05c67aba42eb68ac65489cc1e7fc5db83db2dd4d"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/utopia-php/pools/zipball/05c67aba42eb68ac65489cc1e7fc5db83db2dd4d",
- "reference": "05c67aba42eb68ac65489cc1e7fc5db83db2dd4d",
- "shasum": ""
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
},
- "require": {
- "php": ">=8.3",
- "utopia-php/telemetry": "0.1.*"
- },
- "require-dev": {
- "laravel/pint": "1.*",
- "phpstan/phpstan": "1.*",
- "phpunit/phpunit": "11.*"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Utopia\\Pools\\": "src/Pools"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
+ "funding": [
{
- "name": "Team Appwrite",
- "email": "team@appwrite.io"
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
}
],
- "description": "A simple library to manage connection pools",
- "keywords": [
- "framework",
- "php",
- "pools",
- "utopia"
- ],
- "support": {
- "issues": "https://github.com/utopia-php/pools/issues",
- "source": "https://github.com/utopia-php/pools/tree/0.8.2"
- },
- "time": "2025-04-17T02:04:54+00:00"
+ "time": "2020-09-28T06:39:44+00:00"
},
{
- "name": "utopia-php/telemetry",
- "version": "0.1.1",
+ "name": "theseer/tokenizer",
+ "version": "1.3.1",
"source": {
"type": "git",
- "url": "https://github.com/utopia-php/telemetry.git",
- "reference": "437f0021777f0e575dfb9e8a1a081b3aed75e33f"
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/437f0021777f0e575dfb9e8a1a081b3aed75e33f",
- "reference": "437f0021777f0e575dfb9e8a1a081b3aed75e33f",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
"shasum": ""
},
"require": {
- "ext-opentelemetry": "*",
- "ext-protobuf": "*",
- "nyholm/psr7": "^1.8",
- "open-telemetry/exporter-otlp": "^1.1",
- "open-telemetry/sdk": "^1.1",
- "php": ">=8.0",
- "symfony/http-client": "^7.1"
- },
- "require-dev": {
- "laravel/pint": "^1.2",
- "phpbench/phpbench": "^1.2",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^9.5.25"
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.2 || ^8.0"
},
"type": "library",
"autoload": {
- "psr-4": {
- "Utopia\\Telemetry\\": "src/Telemetry"
- }
+ "classmap": [
+ "src/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "MIT"
+ "BSD-3-Clause"
],
- "keywords": [
- "framework",
- "php",
- "upf"
+ "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/utopia-php/telemetry/issues",
- "source": "https://github.com/utopia-php/telemetry/tree/0.1.1"
- },
- "time": "2025-03-17T11:57:52+00:00"
- },
- {
- "name": "utopia-php/validators",
- "version": "0.1.0",
- "source": {
- "type": "git",
- "url": "https://github.com/utopia-php/validators.git",
- "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/utopia-php/validators/zipball/5c57d5b6cf964f8981807c1d3ea8df620c869080",
- "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080",
- "shasum": ""
- },
- "require": {
- "php": ">=8.0"
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
},
- "require-dev": {
- "laravel/pint": "1.*",
- "phpstan/phpstan": "1.*",
- "phpunit/phpunit": "11.*"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Utopia\\": "src/"
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
}
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
],
- "description": "A lightweight collection of reusable validators for Utopia projects",
- "keywords": [
- "php",
- "utopia",
- "validation",
- "validator"
- ],
- "support": {
- "issues": "https://github.com/utopia-php/validators/issues",
- "source": "https://github.com/utopia-php/validators/tree/0.1.0"
- },
- "time": "2025-11-18T11:05:46+00:00"
+ "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.3"
+ },
"platform-dev": {},
+ "platform-overrides": {
+ "php": "8.3.3"
+ },
"plugin-api-version": "2.6.0"
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..992de5b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,42 @@
+services:
+ 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:
+ clickhouse:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "php", "--version"]
+ interval: 5s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
+
+networks:
+ usage:
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..125ff8b
--- /dev/null
+++ b/src/Usage/Adapter.php
@@ -0,0 +1,229 @@
+ 'toStartOfHour',
+ '1d' => 'toStartOfDay',
+ 'inf' => null,
+ ];
+
+ /** Valid aggregate functions */
+ public const AGGREGATES = ['sum', 'count', 'avg', 'min', 'max'];
+
+ /**
+ * Get adapter name.
+ */
+ abstract public function getName(): string;
+
+ /**
+ * Create required tables / collections.
+ */
+ abstract public function setup(): void;
+
+ /**
+ * Check adapter health.
+ *
+ * @return array
+ */
+ abstract public function healthCheck(): array;
+
+ // βββ Events (append-only log) ββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Insert one or more events.
+ *
+ * Each event is an associative array with at least:
+ * - metric (string) e.g. "api.request", "bandwidth", "executions"
+ * - value (int|float) e.g. request count, bytes, ms
+ *
+ * Plus optional dimensions:
+ * - path, method, status, userAgent, resource, resourceId
+ *
+ * @param array> $events
+ */
+ abstract public function addEvents(array $events, int $batchSize = 1000): bool;
+
+ /**
+ * Query events with flexible filtering.
+ *
+ * Returns aggregated results grouped by requested dimensions + time buckets.
+ *
+ * @param array $filters Key-value filters. Values can be strings or arrays (IN).
+ * @param string $period Aggregation period: '1h', '1d'
+ * @param string|null $startDate ISO 8601 datetime
+ * @param string|null $endDate ISO 8601 datetime
+ * @param array $groupBy Dimensions to group by (besides time bucket)
+ * @param string $aggregate 'sum', 'count', 'avg', 'min', 'max'
+ * @param string $orderBy Column to order by
+ * @param string $orderDirection 'ASC' or 'DESC'
+ * @param int $limit
+ * @param int $offset
+ * @return array>
+ */
+ abstract public function queryEvents(
+ array $filters = [],
+ string $period = '1h',
+ ?string $startDate = null,
+ ?string $endDate = null,
+ array $groupBy = [],
+ string $aggregate = 'sum',
+ string $orderBy = 'time',
+ string $orderDirection = 'DESC',
+ int $limit = 100,
+ int $offset = 0,
+ ): array;
+
+ /**
+ * Get the total (sum) of a metric's value, optionally filtered.
+ *
+ * Useful for billing: "total bandwidth in January".
+ *
+ * @param string $metric
+ * @param array $filters
+ * @param string|null $startDate
+ * @param string|null $endDate
+ * @return int|float
+ */
+ abstract public function sumEvents(
+ string $metric,
+ array $filters = [],
+ ?string $startDate = null,
+ ?string $endDate = null,
+ ): int|float;
+
+ /**
+ * Count events matching filters.
+ *
+ * @param array $filters
+ * @param string|null $startDate
+ * @param string|null $endDate
+ */
+ abstract public function countEvents(
+ array $filters = [],
+ ?string $startDate = null,
+ ?string $endDate = null,
+ ): int;
+
+ // βββ Gauges (resource snapshots) βββββββββββββββββββββββββββββββββ
+
+ /**
+ * Set gauge values (resource counts).
+ *
+ * Each gauge entry:
+ * - metric (string) e.g. "projects.count", "storage.bytes"
+ * - value (int|float)
+ * - resource (string) optional resource identifier
+ *
+ * Uses replace-upsert: same (metric, resource, period, time_bucket) replaces.
+ *
+ * @param array> $gauges
+ */
+ abstract public function setGauges(array $gauges, int $batchSize = 1000): bool;
+
+ /**
+ * Query gauge values (latest or time-series).
+ *
+ * @param array $filters
+ * @param string $period '1h', '1d'
+ * @param string|null $startDate
+ * @param string|null $endDate
+ * @param array $groupBy
+ * @param int $limit
+ * @param int $offset
+ * @return array>
+ */
+ abstract public function queryGauges(
+ array $filters = [],
+ string $period = '1d',
+ ?string $startDate = null,
+ ?string $endDate = null,
+ array $groupBy = [],
+ int $limit = 100,
+ int $offset = 0,
+ ): array;
+
+ /**
+ * Get the latest gauge value for a metric.
+ *
+ * @param string $metric
+ * @param array $filters
+ * @return int|float
+ */
+ abstract public function getGauge(
+ string $metric,
+ array $filters = [],
+ ): int|float;
+
+ // βββ Purge βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Delete events matching filters. Empty filters = delete all.
+ *
+ * @param array $filters
+ */
+ abstract public function purgeEvents(array $filters = []): bool;
+
+ /**
+ * Delete gauges matching filters. Empty filters = delete all.
+ *
+ * @param array $filters
+ */
+ abstract public function purgeGauges(array $filters = []): bool;
+
+ // βββ Metric templates βββββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Resolve a metric template by replacing {placeholder} tokens.
+ *
+ * Example:
+ * resolveMetric('{databaseInternalId}.collections', ['databaseInternalId' => 'db123'])
+ * => 'db123.collections'
+ *
+ * @param string $template Metric template with {placeholders}
+ * @param array $params Placeholder values
+ */
+ public static function resolveMetric(string $template, array $params = []): string
+ {
+ foreach ($params as $key => $value) {
+ $template = str_replace('{' . $key . '}', $value, $template);
+ }
+ return $template;
+ }
+
+ // βββ Multi-tenancy βββββββββββββββββββββββββββββββββββββββββββββββ
+
+ abstract public function setNamespace(string $namespace): self;
+
+ abstract public function setTenant(int|string|null $tenant): 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..07814f8
--- /dev/null
+++ b/src/Usage/Adapter/ClickHouse.php
@@ -0,0 +1,967 @@
+validateHost($host);
+ $this->host = $host;
+ $this->port = $port;
+ $this->username = $username;
+ $this->password = $password;
+ $this->secure = $secure;
+
+ $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);
+ }
+
+ // βββ Configuration βββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function getName(): string
+ {
+ return 'ClickHouse';
+ }
+
+ public function setUseFinal(bool $useFinal): self
+ {
+ $this->useFinal = $useFinal;
+ return $this;
+ }
+
+ public function setDatabase(string $database): self
+ {
+ $this->database = $database;
+ return $this;
+ }
+
+ public function setMaxRetries(int $maxRetries): self
+ {
+ $this->maxRetries = max(0, min(10, $maxRetries));
+ return $this;
+ }
+
+ public function setRetryDelay(int $milliseconds): self
+ {
+ $this->retryDelay = max(10, min(5000, $milliseconds));
+ return $this;
+ }
+
+ public function setTimeout(int $milliseconds): self
+ {
+ $this->client->setTimeout(max(1000, min(600000, $milliseconds)));
+ return $this;
+ }
+
+ public function setAsyncInserts(bool $enabled, bool $waitForConfirmation = false): self
+ {
+ $this->client->addHeader('X-ClickHouse-Setting-async_insert', $enabled ? '1' : '0');
+ $this->client->addHeader('X-ClickHouse-Setting-wait_for_async_insert', $waitForConfirmation ? '1' : '0');
+ return $this;
+ }
+
+ public function setNamespace(string $namespace): self
+ {
+ $this->namespace = $namespace;
+ return $this;
+ }
+
+ public function setTenant(int|string|null $tenant): self
+ {
+ $this->tenant = $tenant;
+ return $this;
+ }
+
+ public function setSharedTables(bool $sharedTables): self
+ {
+ $this->sharedTables = $sharedTables;
+ return $this;
+ }
+
+ // βββ Setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function setup(): void
+ {
+ $this->setOperationContext('setup()');
+ $db = $this->esc($this->database);
+
+ $this->query("CREATE DATABASE IF NOT EXISTS {$db}");
+
+ // Events table (SummingMergeTree β value column is summed on merge)
+ $eventsTable = $this->eventsTable();
+ $tenantCol = $this->sharedTables ? "tenant Nullable(String),\n " : '';
+ $orderKey = $this->sharedTables
+ ? '(tenant, metric, resource, resourceId, path, method, status, userAgent, timestamp)'
+ : '(metric, resource, resourceId, path, method, status, userAgent, timestamp)';
+
+ $this->query("
+ CREATE TABLE IF NOT EXISTS {$eventsTable} (
+ {$tenantCol}timestamp DateTime64(3),
+ metric LowCardinality(String),
+ value Int64,
+ path String DEFAULT '',
+ method LowCardinality(String) DEFAULT '',
+ status UInt16 DEFAULT 0,
+ userAgent String DEFAULT '',
+ resource LowCardinality(String) DEFAULT '',
+ resourceId String DEFAULT '',
+ INDEX idx_metric (metric) TYPE bloom_filter GRANULARITY 1,
+ INDEX idx_path (path) TYPE bloom_filter GRANULARITY 1,
+ INDEX idx_status (status) TYPE minmax GRANULARITY 1
+ )
+ ENGINE = SummingMergeTree(value)
+ ORDER BY {$orderKey}
+ PARTITION BY toYYYYMM(timestamp)
+ SETTINGS index_granularity = 8192" . ($this->sharedTables ? ', allow_nullable_key = 1' : '') . "
+ ");
+
+ // Gauges table (ReplacingMergeTree β last insert per ORDER BY key wins)
+ $gaugesTable = $this->gaugesTable();
+ $gaugeOrderKey = $this->sharedTables
+ ? '(tenant, metric, resource, resourceId, period, time)'
+ : '(metric, resource, resourceId, period, time)';
+
+ $this->query("
+ CREATE TABLE IF NOT EXISTS {$gaugesTable} (
+ {$tenantCol}time DateTime64(3),
+ period LowCardinality(String),
+ metric LowCardinality(String),
+ value Int64,
+ resource LowCardinality(String) DEFAULT '',
+ resourceId String DEFAULT '',
+ INDEX idx_metric (metric) TYPE bloom_filter GRANULARITY 1
+ )
+ ENGINE = ReplacingMergeTree()
+ ORDER BY {$gaugeOrderKey}
+ PARTITION BY toYYYYMM(time)
+ SETTINGS index_granularity = 8192" . ($this->sharedTables ? ', allow_nullable_key = 1' : '') . "
+ ");
+ }
+
+ public function healthCheck(): array
+ {
+ $this->setOperationContext('healthCheck()');
+ $start = microtime(true);
+
+ try {
+ $response = $this->query('SELECT 1 as ping FORMAT JSON');
+ $json = json_decode($response, true);
+
+ if (!is_array($json) || !isset($json['data'][0]['ping'])) {
+ return ['healthy' => false, 'error' => 'Invalid response'];
+ }
+
+ $result = [
+ 'healthy' => true,
+ 'host' => $this->host,
+ 'port' => $this->port,
+ 'database' => $this->database,
+ 'response_time' => round(microtime(true) - $start, 3),
+ ];
+
+ try {
+ $vr = $this->query('SELECT version() as version, uptime() as uptime FORMAT JSON');
+ $vj = json_decode($vr, true);
+ if (is_array($vj) && isset($vj['data'][0])) {
+ $result['version'] = (string) $vj['data'][0]['version'];
+ $result['uptime'] = (int) $vj['data'][0]['uptime'];
+ }
+ } catch (Exception) {
+ // optional
+ }
+
+ return $result;
+ } catch (Exception $e) {
+ return [
+ 'healthy' => false,
+ 'host' => $this->host,
+ 'port' => $this->port,
+ 'error' => $e->getMessage(),
+ 'response_time' => round(microtime(true) - $start, 3),
+ ];
+ }
+ }
+
+ // βββ Events ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function addEvents(array $events, int $batchSize = self::INSERT_BATCH_SIZE): bool
+ {
+ if (empty($events)) {
+ return true;
+ }
+
+ $this->setOperationContext('addEvents()');
+ $table = $this->eventsTableName();
+
+ // Add millisecond offsets to events without explicit timestamps
+ // so that rows within the same batch get unique ORDER BY keys
+ // and are not collapsed by SummingMergeTree.
+ $baseTime = new \DateTime();
+ foreach ($events as $i => &$event) {
+ if (!isset($event['timestamp'])) {
+ $ts = clone $baseTime;
+ $ts->modify('+' . $i . ' milliseconds');
+ $event['timestamp'] = $ts;
+ }
+ }
+ unset($event);
+
+ foreach (array_chunk($events, max(1, $batchSize)) as $chunk) {
+ $rows = [];
+ foreach ($chunk as $event) {
+ $rows[] = $this->buildEventRow($event);
+ }
+ $this->insert($table, $rows);
+ }
+
+ return true;
+ }
+
+ public function queryEvents(
+ array $filters = [],
+ string $period = '1h',
+ ?string $startDate = null,
+ ?string $endDate = null,
+ array $groupBy = [],
+ string $aggregate = 'sum',
+ string $orderBy = 'time',
+ string $orderDirection = 'DESC',
+ int $limit = 100,
+ int $offset = 0,
+ ): array {
+ $this->setOperationContext('queryEvents()');
+ $this->validatePeriod($period);
+ $this->validateAggregate($aggregate);
+
+ $table = $this->eventsTableRef();
+ $params = [];
+ $paramCounter = 0;
+
+ // Time bucket expression
+ $timeBucket = self::PERIODS[$period] ?? 'toStartOfHour';
+ $isInf = ($period === 'inf');
+
+ // SELECT columns
+ $selectParts = [];
+ if ($isInf) {
+ $selectParts[] = "toDateTime64('1970-01-01 00:00:00.000', 3) as time";
+ } else {
+ $timeBucketExpr = "{$timeBucket}(timestamp)";
+ $selectParts[] = "{$timeBucketExpr} as time";
+ }
+ foreach ($groupBy as $dim) {
+ $this->validateEventDimension($dim);
+ $selectParts[] = $this->esc($dim);
+ }
+ $selectParts[] = "{$aggregate}(value) as value";
+ $selectParts[] = "count() as count";
+
+ // WHERE
+ $where = $this->buildFilters($filters, $params, $paramCounter, 'event');
+ $where = $this->addTimeRange($where, $params, $paramCounter, $startDate, $endDate, 'timestamp');
+ $where = $this->addTenantFilter($where, $params);
+
+ $whereClause = !empty($where) ? ' WHERE ' . implode(' AND ', $where) : '';
+
+ // GROUP BY
+ $groupParts = $isInf ? [] : ['time'];
+ foreach ($groupBy as $dim) {
+ $groupParts[] = $this->esc($dim);
+ }
+ $groupByClause = !empty($groupParts) ? ' GROUP BY ' . implode(', ', $groupParts) : '';
+
+ // ORDER BY
+ $orderCol = $orderBy === 'time' ? 'time' : $this->esc($orderBy);
+ $dir = strtoupper($orderDirection) === 'ASC' ? 'ASC' : 'DESC';
+ $orderClause = " ORDER BY {$orderCol} {$dir}";
+
+ $params['limit'] = $limit;
+ $params['offset'] = $offset;
+
+ $sql = "SELECT " . implode(', ', $selectParts)
+ . " FROM {$table}{$whereClause}{$groupByClause}{$orderClause}"
+ . " LIMIT {limit:UInt64} OFFSET {offset:UInt64}"
+ . " FORMAT JSON";
+
+ $result = $this->query($sql, $params);
+ return $this->parseRows($result);
+ }
+
+ public function sumEvents(
+ string $metric,
+ array $filters = [],
+ ?string $startDate = null,
+ ?string $endDate = null,
+ ): int|float {
+ $this->setOperationContext('sumEvents()');
+
+ $table = $this->eventsTableRef();
+ $params = [];
+ $paramCounter = 0;
+
+ $where = $this->buildFilters(array_merge(['metric' => $metric], $filters), $params, $paramCounter, 'event');
+ $where = $this->addTimeRange($where, $params, $paramCounter, $startDate, $endDate, 'timestamp');
+ $where = $this->addTenantFilter($where, $params);
+
+ $whereClause = !empty($where) ? ' WHERE ' . implode(' AND ', $where) : '';
+
+ $sql = "SELECT sum(value) as total FROM {$table}{$whereClause} FORMAT JSON";
+ $result = $this->query($sql, $params);
+ $json = json_decode($result, true);
+
+ return (int) ($json['data'][0]['total'] ?? 0);
+ }
+
+ public function countEvents(
+ array $filters = [],
+ ?string $startDate = null,
+ ?string $endDate = null,
+ ): int {
+ $this->setOperationContext('countEvents()');
+
+ $table = $this->eventsTableRef();
+ $params = [];
+ $paramCounter = 0;
+
+ $where = $this->buildFilters($filters, $params, $paramCounter, 'event');
+ $where = $this->addTimeRange($where, $params, $paramCounter, $startDate, $endDate, 'timestamp');
+ $where = $this->addTenantFilter($where, $params);
+
+ $whereClause = !empty($where) ? ' WHERE ' . implode(' AND ', $where) : '';
+
+ $sql = "SELECT count() as total FROM {$table}{$whereClause} FORMAT JSON";
+ $result = $this->query($sql, $params);
+ $json = json_decode($result, true);
+
+ return (int) ($json['data'][0]['total'] ?? 0);
+ }
+
+ // βββ Gauges ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function setGauges(array $gauges, int $batchSize = self::INSERT_BATCH_SIZE): bool
+ {
+ if (empty($gauges)) {
+ return true;
+ }
+
+ $this->setOperationContext('setGauges()');
+ $table = $this->gaugesTableName();
+
+ foreach (array_chunk($gauges, max(1, $batchSize)) as $chunk) {
+ $rows = [];
+ foreach ($chunk as $gauge) {
+ $rows[] = $this->buildGaugeRow($gauge);
+ }
+ $this->insert($table, $rows);
+ }
+
+ return true;
+ }
+
+ public function queryGauges(
+ array $filters = [],
+ string $period = '1d',
+ ?string $startDate = null,
+ ?string $endDate = null,
+ array $groupBy = [],
+ int $limit = 100,
+ int $offset = 0,
+ ): array {
+ $this->setOperationContext('queryGauges()');
+ $this->validatePeriod($period);
+
+ $table = $this->gaugesTableRef();
+ $params = [];
+ $paramCounter = 0;
+
+ // SELECT
+ $selectParts = ['time', 'period'];
+ foreach ($groupBy as $dim) {
+ $this->validateGaugeDimension($dim);
+ $selectParts[] = $this->esc($dim);
+ }
+ $selectParts[] = 'value';
+
+ // WHERE
+ $where = $this->buildFilters($filters, $params, $paramCounter, 'gauge');
+ $where = $this->addTimeRange($where, $params, $paramCounter, $startDate, $endDate, 'time');
+ $where = $this->addTenantFilter($where, $params);
+
+ // Period filter
+ $pn = 'p_period_' . $paramCounter++;
+ $where[] = "period = {{$pn}:String}";
+ $params[$pn] = $period === 'inf' ? 'inf' : $period;
+
+ $whereClause = !empty($where) ? ' WHERE ' . implode(' AND ', $where) : '';
+ $orderClause = ' ORDER BY time DESC';
+
+ $params['limit'] = $limit;
+ $params['offset'] = $offset;
+
+ $sql = "SELECT " . implode(', ', $selectParts)
+ . " FROM {$table}{$whereClause}{$orderClause}"
+ . " LIMIT {limit:UInt64} OFFSET {offset:UInt64}"
+ . " FORMAT JSON";
+
+ $result = $this->query($sql, $params);
+ return $this->parseRows($result);
+ }
+
+ public function getGauge(string $metric, array $filters = []): int|float
+ {
+ $this->setOperationContext('getGauge()');
+
+ $table = $this->gaugesTableRef();
+ $params = [];
+ $paramCounter = 0;
+
+ $where = $this->buildFilters(array_merge(['metric' => $metric], $filters), $params, $paramCounter, 'gauge');
+ $where = $this->addTenantFilter($where, $params);
+
+ $whereClause = !empty($where) ? ' WHERE ' . implode(' AND ', $where) : '';
+
+ $sql = "SELECT value FROM {$table}{$whereClause} ORDER BY time DESC LIMIT 1 FORMAT JSON";
+ $result = $this->query($sql, $params);
+ $json = json_decode($result, true);
+
+ return (int) ($json['data'][0]['value'] ?? 0);
+ }
+
+ // βββ Purge βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function purgeEvents(array $filters = []): bool
+ {
+ $this->setOperationContext('purgeEvents()');
+ return $this->purgeTable($this->eventsTableName(), $filters, 'event');
+ }
+
+ public function purgeGauges(array $filters = []): bool
+ {
+ $this->setOperationContext('purgeGauges()');
+ return $this->purgeTable($this->gaugesTableName(), $filters, 'gauge');
+ }
+
+ // βββ Internal: Table names βββββββββββββββββββββββββββββββββββββββ
+
+ private function eventsTableName(): string
+ {
+ $name = 'events';
+ return !empty($this->namespace) ? $this->namespace . '_' . $name : $name;
+ }
+
+ private function gaugesTableName(): string
+ {
+ $name = 'gauges';
+ return !empty($this->namespace) ? $this->namespace . '_' . $name : $name;
+ }
+
+ private function eventsTable(): string
+ {
+ return $this->esc($this->database) . '.' . $this->esc($this->eventsTableName());
+ }
+
+ private function gaugesTable(): string
+ {
+ return $this->esc($this->database) . '.' . $this->esc($this->gaugesTableName());
+ }
+
+ private function eventsTableRef(): string
+ {
+ return $this->eventsTable() . ($this->useFinal ? ' FINAL' : '');
+ }
+
+ private function gaugesTableRef(): string
+ {
+ return $this->gaugesTable() . ($this->useFinal ? ' FINAL' : '');
+ }
+
+ // βββ Internal: Row builders ββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * @return array
+ */
+ private function buildEventRow(array $event): array
+ {
+ $row = [
+ 'timestamp' => $this->formatDateTime($event['timestamp'] ?? null),
+ 'metric' => (string) ($event['metric'] ?? ''),
+ 'value' => (int) ($event['value'] ?? 0),
+ 'path' => (string) ($event['path'] ?? ''),
+ 'method' => (string) ($event['method'] ?? ''),
+ 'status' => (int) ($event['status'] ?? 0),
+ 'userAgent' => (string) ($event['userAgent'] ?? ''),
+ 'resource' => (string) ($event['resource'] ?? ''),
+ 'resourceId' => (string) ($event['resourceId'] ?? ''),
+ ];
+
+ if ($this->sharedTables) {
+ $tenant = $event['$tenant'] ?? $this->tenant;
+ $row['tenant'] = $tenant !== null ? (string) $tenant : null;
+ }
+
+ return $row;
+ }
+
+ /**
+ * @return array
+ */
+ private function buildGaugeRow(array $gauge): array
+ {
+ $period = $gauge['period'] ?? '1d';
+ $now = new \DateTime();
+
+ if ($period === 'inf') {
+ $timeBucket = '1970-01-01 00:00:00.000';
+ } elseif ($period === '1h') {
+ $timeBucket = $now->format('Y-m-d H:00:00.000');
+ } else {
+ $timeBucket = $now->format('Y-m-d 00:00:00.000');
+ }
+
+ $row = [
+ 'time' => $gauge['time'] ?? $timeBucket,
+ 'period' => $period,
+ 'metric' => (string) ($gauge['metric'] ?? ''),
+ 'value' => (int) ($gauge['value'] ?? 0),
+ 'resource' => (string) ($gauge['resource'] ?? ''),
+ 'resourceId' => (string) ($gauge['resourceId'] ?? ''),
+ ];
+
+ if ($this->sharedTables) {
+ $tenant = $gauge['$tenant'] ?? $this->tenant;
+ $row['tenant'] = $tenant !== null ? (string) $tenant : null;
+ }
+
+ return $row;
+ }
+
+ // βββ Internal: Query helpers βββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Build WHERE conditions from key-value filters.
+ *
+ * @param array $filters
+ * @param array $params
+ * @param int $paramCounter
+ * @param string $type 'event' or 'gauge'
+ * @return array WHERE conditions
+ */
+ private function buildFilters(array $filters, array &$params, int &$paramCounter, string $type): array
+ {
+ $conditions = [];
+ $validDimensions = $type === 'event' ? self::EVENT_DIMENSIONS : self::GAUGE_DIMENSIONS;
+
+ foreach ($filters as $key => $value) {
+ if (!in_array($key, $validDimensions, true)) {
+ throw new \InvalidArgumentException("Invalid filter dimension '{$key}' for {$type}. Allowed: " . implode(', ', $validDimensions));
+ }
+
+ $escaped = $this->esc($key);
+
+ if (is_array($value)) {
+ if (empty($value)) {
+ // Empty IN() is invalid SQL; match nothing
+ $conditions[] = '0';
+ continue;
+ }
+ $inParts = [];
+ foreach ($value as $v) {
+ $pn = 'p_' . $paramCounter++;
+ $inParts[] = "{{$pn}:String}";
+ $params[$pn] = (string) $v;
+ }
+ $conditions[] = "{$escaped} IN (" . implode(', ', $inParts) . ")";
+ } else {
+ $pn = 'p_' . $paramCounter++;
+ if ($key === 'status') {
+ $conditions[] = "{$escaped} = {{$pn}:UInt16}";
+ $params[$pn] = (int) $value;
+ } else {
+ $conditions[] = "{$escaped} = {{$pn}:String}";
+ $params[$pn] = (string) $value;
+ }
+ }
+ }
+
+ return $conditions;
+ }
+
+ /**
+ * @param array $conditions
+ * @param array $params
+ * @return array
+ */
+ private function addTimeRange(array $conditions, array &$params, int &$paramCounter, ?string $startDate, ?string $endDate, string $column): array
+ {
+ if ($startDate !== null) {
+ $pn = 'p_start_' . $paramCounter++;
+ $conditions[] = $this->esc($column) . " >= {{$pn}:DateTime64(3)}";
+ $params[$pn] = $this->formatDateTime($startDate);
+ }
+ if ($endDate !== null) {
+ $pn = 'p_end_' . $paramCounter++;
+ $conditions[] = $this->esc($column) . " <= {{$pn}:DateTime64(3)}";
+ $params[$pn] = $this->ceilDateTime($endDate);
+ }
+ return $conditions;
+ }
+
+ /**
+ * @param array $conditions
+ * @param array $params
+ * @return array
+ */
+ private function addTenantFilter(array $conditions, array &$params): array
+ {
+ if ($this->sharedTables && $this->tenant !== null) {
+ $conditions[] = "tenant = {p_tenant:Nullable(String)}";
+ $params['p_tenant'] = (string) $this->tenant;
+ }
+ return $conditions;
+ }
+
+ /**
+ * @return array>
+ */
+ private function parseRows(string $result): array
+ {
+ if (empty(trim($result))) {
+ return [];
+ }
+
+ $json = json_decode($result, true);
+ if (!is_array($json) || !isset($json['data']) || !is_array($json['data'])) {
+ return [];
+ }
+
+ $rows = [];
+ foreach ($json['data'] as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+
+ // Cast numeric fields (use +0 to preserve decimals from avg/float aggregates)
+ if (isset($row['value'])) {
+ $row['value'] = $row['value'] + 0;
+ }
+ if (isset($row['count'])) {
+ $row['count'] = (int) $row['count'];
+ }
+ if (isset($row['status'])) {
+ $row['status'] = (int) $row['status'];
+ }
+ if (isset($row['tenant'])) {
+ if ($row['tenant'] !== null && is_numeric($row['tenant'])) {
+ $row['tenant'] = $row['tenant'] + 0;
+ }
+ }
+
+ $rows[] = $row;
+ }
+
+ return $rows;
+ }
+
+ private function purgeTable(string $tableName, array $filters, string $type): bool
+ {
+ $escapedTable = $this->esc($this->database) . '.' . $this->esc($tableName);
+ $params = [];
+ $paramCounter = 0;
+
+ $where = $this->buildFilters($filters, $params, $paramCounter, $type);
+ $where = $this->addTenantFilter($where, $params);
+
+ $whereClause = !empty($where) ? ' WHERE ' . implode(' AND ', $where) : ' WHERE 1=1';
+
+ $this->query("DELETE FROM {$escapedTable}{$whereClause}", $params);
+ return true;
+ }
+
+ // βββ Internal: HTTP transport ββββββββββββββββββββββββββββββββββββ
+
+ private function query(string $sql, array $params = []): string
+ {
+ $attempt = 0;
+ $lastException = null;
+
+ while ($attempt <= $this->maxRetries) {
+ try {
+ return $this->executeQuery($sql, $params);
+ } catch (Exception $e) {
+ $lastException = $e;
+ if ($attempt < $this->maxRetries && $this->isRetryable($e)) {
+ $attempt++;
+ usleep($this->retryDelay * (2 ** ($attempt - 1)) * 1000);
+ continue;
+ }
+ throw $e;
+ }
+ }
+
+ throw $lastException ?? new Exception('Unknown error');
+ }
+
+ private function executeQuery(string $sql, array $params): string
+ {
+ $scheme = $this->secure ? 'https' : 'http';
+ $url = "{$scheme}://{$this->host}:{$this->port}/";
+
+ $this->client->addHeader('X-ClickHouse-Database', $this->database);
+ $this->client->addHeader('Connection', 'keep-alive');
+
+ $body = ['query' => $sql];
+ foreach ($params as $key => $value) {
+ $body['param_' . $key] = $this->formatParam($value);
+ }
+
+ $response = $this->client->fetch(
+ url: $url,
+ method: Client::METHOD_POST,
+ body: $body,
+ );
+
+ $httpCode = $response->getStatusCode();
+ if ($httpCode !== 200) {
+ $responseBody = $response->getBody();
+ $responseBody = is_string($responseBody) ? $responseBody : '';
+ $msg = "ClickHouse HTTP {$httpCode}: {$responseBody}";
+ if ($this->operationContext) {
+ $msg = "[{$this->operationContext}] {$msg}";
+ }
+ throw new Exception($msg);
+ }
+
+ $result = $response->getBody();
+ return is_string($result) ? $result : '';
+ }
+
+ /**
+ * @param array $rows JSON strings
+ */
+ private function insert(string $table, array $rows): void
+ {
+ if (empty($rows)) {
+ return;
+ }
+
+ $scheme = $this->secure ? 'https' : 'http';
+ $escaped = $this->esc($table);
+ $queryParams = ['query' => "INSERT INTO {$escaped} FORMAT JSONEachRow"];
+ $url = "{$scheme}://{$this->host}:{$this->port}/?" . http_build_query($queryParams);
+
+ $jsonRows = array_map(function ($row) {
+ $encoded = json_encode($row);
+ if ($encoded === false) {
+ throw new Exception("Failed to JSON encode row: " . json_last_error_msg());
+ }
+ return $encoded;
+ }, $rows);
+
+ $body = implode("\n", $jsonRows);
+
+ $attempt = 0;
+ $lastException = null;
+
+ while ($attempt <= $this->maxRetries) {
+ try {
+ $this->client->addHeader('X-ClickHouse-Database', $this->database);
+ $this->client->addHeader('Content-Type', 'application/x-ndjson');
+ $this->client->addHeader('Connection', 'keep-alive');
+
+ try {
+ $response = $this->client->fetch(
+ url: $url,
+ method: Client::METHOD_POST,
+ body: $body,
+ );
+ } finally {
+ $this->client->removeHeader('Content-Type');
+ }
+
+ $httpCode = $response->getStatusCode();
+ if ($httpCode !== 200) {
+ $responseBody = $response->getBody();
+ $responseBody = is_string($responseBody) ? $responseBody : '';
+ throw new Exception("ClickHouse insert HTTP {$httpCode}: {$responseBody}");
+ }
+
+ return;
+ } catch (Exception $e) {
+ $lastException = $e;
+ if ($attempt < $this->maxRetries && $this->isRetryable($e)) {
+ $attempt++;
+ usleep($this->retryDelay * (2 ** ($attempt - 1)) * 1000);
+ continue;
+ }
+ throw $e;
+ }
+ }
+
+ throw $lastException ?? new Exception('Unknown insert error');
+ }
+
+ // βββ Internal: Utilities βββββββββββββββββββββββββββββββββββββββββ
+
+ private function esc(string $identifier): string
+ {
+ return '`' . str_replace('`', '``', $identifier) . '`';
+ }
+
+ private function formatDateTime($value): string
+ {
+ if ($value === null) {
+ return (new \DateTime())->format('Y-m-d H:i:s.v');
+ }
+ if ($value instanceof \DateTime) {
+ return $value->format('Y-m-d H:i:s.v');
+ }
+ if (is_string($value)) {
+ return (new \DateTime($value))->format('Y-m-d H:i:s.v');
+ }
+ return (new \DateTime())->format('Y-m-d H:i:s.v');
+ }
+
+ /**
+ * Format a date for use as an upper-bound (end date) in range queries.
+ * Rounds up to .999 ms so that events within the same second are included.
+ */
+ private function ceilDateTime($value): string
+ {
+ if ($value instanceof \DateTime) {
+ $dt = clone $value;
+ } elseif (is_string($value)) {
+ $dt = new \DateTime($value);
+ } else {
+ $dt = new \DateTime();
+ }
+
+ // If the input had no sub-second precision, round up to end of second
+ if ((int) $dt->format('v') === 0) {
+ return $dt->format('Y-m-d H:i:s') . '.999';
+ }
+
+ return $dt->format('Y-m-d H:i:s.v');
+ }
+
+ private function formatParam(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)) {
+ return json_encode($value) ?: '';
+ }
+ return (string) $value;
+ }
+
+ private function validateHost(string $host): void
+ {
+ if (empty($host)) {
+ throw new Exception('ClickHouse host cannot be empty');
+ }
+
+ // Accept IPs, hostnames, and localhost
+ if (!preg_match('/^[a-zA-Z0-9._-]+$/', $host) && !filter_var($host, FILTER_VALIDATE_IP)) {
+ throw new Exception('Invalid ClickHouse host');
+ }
+ }
+
+ private function validatePeriod(string $period): void
+ {
+ if (!array_key_exists($period, self::PERIODS)) {
+ throw new \InvalidArgumentException("Invalid period '{$period}'. Allowed: " . implode(', ', array_keys(self::PERIODS)));
+ }
+ }
+
+ private function validateAggregate(string $aggregate): void
+ {
+ if (!in_array($aggregate, self::AGGREGATES, true)) {
+ throw new \InvalidArgumentException("Invalid aggregate '{$aggregate}'. Allowed: " . implode(', ', self::AGGREGATES));
+ }
+ }
+
+ private function validateEventDimension(string $dim): void
+ {
+ if (!in_array($dim, self::EVENT_DIMENSIONS, true)) {
+ throw new \InvalidArgumentException("Invalid event dimension '{$dim}'");
+ }
+ }
+
+ private function validateGaugeDimension(string $dim): void
+ {
+ if (!in_array($dim, self::GAUGE_DIMENSIONS, true)) {
+ throw new \InvalidArgumentException("Invalid gauge dimension '{$dim}'");
+ }
+ }
+
+ private function setOperationContext(?string $context): void
+ {
+ $this->operationContext = $context;
+ }
+
+ private function isRetryable(Exception $e): bool
+ {
+ $msg = strtolower($e->getMessage());
+ $patterns = ['connection', 'timeout', 'refused', 'reset', 'broken pipe', 'unavailable', '502', '503', '504', '429'];
+ foreach ($patterns as $pattern) {
+ if (str_contains($msg, $pattern)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/Usage/Metric.php b/src/Usage/Metric.php
new file mode 100644
index 0000000..cd8df7c
--- /dev/null
+++ b/src/Usage/Metric.php
@@ -0,0 +1,91 @@
+
+ */
+class Metric extends ArrayObject
+{
+ public function __construct(array $input = [])
+ {
+ parent::__construct($input);
+ }
+
+ public function getId(): string
+ {
+ return (string) ($this['$id'] ?? $this['id'] ?? '');
+ }
+
+ public function getMetric(): string
+ {
+ return (string) ($this['metric'] ?? '');
+ }
+
+ public function getValue(int $default = 0): int|float
+ {
+ $v = $this['value'] ?? $default;
+ if (is_int($v) || is_float($v)) {
+ return $v;
+ }
+ return is_numeric($v) ? +$v : $default;
+ }
+
+ public function getTime(): ?string
+ {
+ return isset($this['time']) && is_string($this['time']) ? $this['time'] : null;
+ }
+
+ public function getResource(): string
+ {
+ return (string) ($this['resource'] ?? '');
+ }
+
+ public function getResourceId(): string
+ {
+ return (string) ($this['resourceId'] ?? '');
+ }
+
+ public function getTenant(): int|string|null
+ {
+ $t = $this['tenant'] ?? null;
+ if ($t === null) {
+ return null;
+ }
+ if (is_int($t)) {
+ return $t;
+ }
+ if (is_numeric($t)) {
+ return $t + 0;
+ }
+ return (string) $t;
+ }
+
+ public function getAttribute(string $name, mixed $default = null): mixed
+ {
+ return $this[$name] ?? $default;
+ }
+
+ public function setAttribute(string $key, mixed $value): static
+ {
+ $this[$key] = $value;
+ return $this;
+ }
+
+ public function hasAttribute(string $name): bool
+ {
+ return isset($this[$name]);
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->getArrayCopy();
+ }
+}
diff --git a/src/Usage/Usage.php b/src/Usage/Usage.php
new file mode 100644
index 0000000..3f4466c
--- /dev/null
+++ b/src/Usage/Usage.php
@@ -0,0 +1,523 @@
+> Buffered events for deferred flush */
+ private array $eventBuffer = [];
+
+ /** @var array> Buffered gauges keyed by metric:resource:projectId */
+ private array $gaugeBuffer = [];
+
+ private int $bufferCount = 0;
+ private int $flushThreshold = 10_000;
+ private int $flushInterval = 20;
+ private float $lastFlushTime;
+
+ public function __construct(Adapter $adapter)
+ {
+ $this->adapter = $adapter;
+ $this->lastFlushTime = microtime(true);
+ }
+
+ public function getAdapter(): Adapter
+ {
+ return $this->adapter;
+ }
+
+ /**
+ * @return array
+ */
+ public function healthCheck(): array
+ {
+ return $this->adapter->healthCheck();
+ }
+
+ public function setup(): void
+ {
+ $this->adapter->setup();
+ }
+
+ // βββ Events ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Add events directly (no buffering).
+ *
+ * @param array> $events
+ */
+ public function addEvents(array $events, int $batchSize = 1000): bool
+ {
+ return $this->adapter->addEvents($events, $batchSize);
+ }
+
+ /**
+ * Collect an event into the in-memory buffer.
+ *
+ * @param array $event
+ */
+ public function collectEvent(array $event): self
+ {
+ $this->eventBuffer[] = $event;
+ $this->bufferCount++;
+ return $this;
+ }
+
+ /**
+ * Query events with flexible filters.
+ *
+ * @param array $filters
+ * @param array $groupBy
+ * @return array>
+ */
+ public function queryEvents(
+ array $filters = [],
+ string $period = '1h',
+ ?string $startDate = null,
+ ?string $endDate = null,
+ array $groupBy = [],
+ string $aggregate = 'sum',
+ string $orderBy = 'time',
+ string $orderDirection = 'DESC',
+ int $limit = 100,
+ int $offset = 0,
+ ): array {
+ return $this->adapter->queryEvents(
+ $filters,
+ $period,
+ $startDate,
+ $endDate,
+ $groupBy,
+ $aggregate,
+ $orderBy,
+ $orderDirection,
+ $limit,
+ $offset,
+ );
+ }
+
+ /**
+ * Sum a metric's value with optional filters and date range.
+ *
+ * @param array $filters
+ */
+ public function sumEvents(
+ string $metric,
+ array $filters = [],
+ ?string $startDate = null,
+ ?string $endDate = null,
+ ): int|float {
+ return $this->adapter->sumEvents($metric, $filters, $startDate, $endDate);
+ }
+
+ /**
+ * Count events matching filters.
+ *
+ * @param array $filters
+ */
+ public function countEvents(
+ array $filters = [],
+ ?string $startDate = null,
+ ?string $endDate = null,
+ ): int {
+ return $this->adapter->countEvents($filters, $startDate, $endDate);
+ }
+
+ // βββ Gauges ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Set gauge values directly (no buffering).
+ *
+ * @param array> $gauges
+ */
+ public function setGauges(array $gauges, int $batchSize = 1000): bool
+ {
+ return $this->adapter->setGauges($gauges, $batchSize);
+ }
+
+ /**
+ * Collect a gauge value into the in-memory buffer (last-write-wins per key).
+ *
+ * @param array $gauge
+ */
+ public function collectGauge(array $gauge): self
+ {
+ $key = ($gauge['metric'] ?? '') . ':' . ($gauge['resource'] ?? '')
+ . ':' . ($gauge['resourceId'] ?? '') . ':' . ($gauge['period'] ?? '')
+ . ':' . ($gauge['time'] ?? '') . ':' . ($gauge['$tenant'] ?? '');
+ $this->gaugeBuffer[$key] = $gauge;
+ $this->bufferCount++;
+ return $this;
+ }
+
+ /**
+ * @param array $filters
+ * @param array $groupBy
+ * @return array>
+ */
+ public function queryGauges(
+ array $filters = [],
+ string $period = '1d',
+ ?string $startDate = null,
+ ?string $endDate = null,
+ array $groupBy = [],
+ int $limit = 100,
+ int $offset = 0,
+ ): array {
+ return $this->adapter->queryGauges(
+ $filters,
+ $period,
+ $startDate,
+ $endDate,
+ $groupBy,
+ $limit,
+ $offset,
+ );
+ }
+
+ /**
+ * Get the latest gauge value for a metric.
+ *
+ * @param array $filters
+ */
+ public function getGauge(string $metric, array $filters = []): int|float
+ {
+ return $this->adapter->getGauge($metric, $filters);
+ }
+
+ /**
+ * Resolve a metric template with placeholder values.
+ *
+ * @param string $template Metric template with {placeholders}
+ * @param array $params Placeholder replacements
+ */
+ public static function resolveMetric(string $template, array $params = []): string
+ {
+ return Adapter::resolveMetric($template, $params);
+ }
+
+ // βββ High-level query helpers βββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Get time-series data for one or more metrics, with zero-filled gaps.
+ *
+ * Returns the format the Appwrite console expects:
+ * [
+ * 'metricName' => [
+ * 'total' => int,
+ * 'data' => [['value' => int, 'date' => 'Y-m-d H:i:s.v'], ...],
+ * ],
+ * ]
+ *
+ * @param array $metrics Metric names to fetch
+ * @param string $period '1h' or '1d'
+ * @param string $startDate ISO 8601
+ * @param string $endDate ISO 8601
+ * @param array $filters Additional filters (e.g. resource)
+ * @param bool $zeroFill Whether to fill gaps with zero-value entries (default: true)
+ * @return array}>
+ */
+ public function getMetrics(
+ array $metrics,
+ string $period,
+ string $startDate,
+ string $endDate,
+ array $filters = [],
+ bool $zeroFill = true,
+ ): array {
+ $result = [];
+ $timeBuckets = $zeroFill ? $this->generateTimeBuckets($period, $startDate, $endDate) : [];
+
+ foreach ($metrics as $metric) {
+ $rows = $this->adapter->queryEvents(
+ filters: array_merge(['metric' => $metric], $filters),
+ period: $period,
+ startDate: $startDate,
+ endDate: $endDate,
+ orderBy: 'time',
+ orderDirection: 'ASC',
+ limit: $zeroFill ? count($timeBuckets) + 1 : 10000,
+ );
+
+ if ($zeroFill) {
+ // Index rows by normalized time for fast lookup
+ $indexed = [];
+ foreach ($rows as $row) {
+ $key = $this->normalizeTimeKey($row['time'] ?? '');
+ $indexed[$key] = $row['value'] ?? 0;
+ }
+
+ // Zero-fill gaps
+ $data = [];
+ $total = 0;
+ foreach ($timeBuckets as $bucket) {
+ $value = $indexed[$this->normalizeTimeKey($bucket)] ?? 0;
+ $total += $value;
+ $data[] = ['value' => $value, 'date' => $bucket];
+ }
+ } else {
+ // Sparse: return only rows that have data
+ $data = [];
+ $total = 0;
+ foreach ($rows as $row) {
+ $value = $row['value'] ?? 0;
+ $total += $value;
+ $data[] = ['value' => $value, 'date' => $row['time'] ?? ''];
+ }
+ }
+
+ $result[$metric] = [
+ 'total' => $total,
+ 'data' => $data,
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get totals for multiple metrics in one call.
+ *
+ * Returns: ['metricName' => int|float, ...]
+ *
+ * @param array $metrics Metric names
+ * @param array $filters Filters (e.g. resource)
+ * @param string|null $startDate
+ * @param string|null $endDate
+ * @return array
+ */
+ public function getMetricsTotals(
+ array $metrics,
+ array $filters = [],
+ ?string $startDate = null,
+ ?string $endDate = null,
+ ): array {
+ $result = [];
+ foreach ($metrics as $metric) {
+ $result[$metric] = $this->adapter->sumEvents($metric, $filters, $startDate, $endDate);
+ }
+ return $result;
+ }
+
+ /**
+ * Get per-resource breakdown for a metric.
+ *
+ * Returns the format for MetricBreakdown:
+ * [['resourceId' => string, 'value' => int|float], ...]
+ *
+ * @param string $metric Metric name
+ * @param array $filters Filters (e.g. resource)
+ * @param string|null $startDate
+ * @param string|null $endDate
+ * @param int $limit Top-N resources
+ * @param string $dimension Dimension to break down by (default: 'resource')
+ * @return array
+ */
+ public function getBreakdown(
+ string $metric,
+ array $filters = [],
+ ?string $startDate = null,
+ ?string $endDate = null,
+ int $limit = 10,
+ string $dimension = 'resource',
+ ): array {
+ $rows = $this->adapter->queryEvents(
+ filters: array_merge(['metric' => $metric], $filters),
+ period: 'inf',
+ startDate: $startDate,
+ endDate: $endDate,
+ groupBy: [$dimension],
+ aggregate: 'sum',
+ orderBy: 'value',
+ orderDirection: 'DESC',
+ limit: $limit,
+ );
+
+ $result = [];
+ foreach ($rows as $row) {
+ $result[] = [
+ 'resourceId' => $row[$dimension] ?? '',
+ 'value' => $row['value'] ?? 0,
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Normalize a time string for consistent matching between ClickHouse output and generated buckets.
+ * Removes fractional seconds (.000) that may or may not be present.
+ */
+ private function normalizeTimeKey(string $time): string
+ {
+ return preg_replace('/\.\d+$/', '', $time) ?? $time;
+ }
+
+ /**
+ * Generate zero-fill time buckets between start and end dates.
+ *
+ * @return array Formatted datetime strings
+ */
+ private function generateTimeBuckets(string $period, string $startDate, string $endDate): array
+ {
+ $start = new \DateTime($startDate);
+ $end = new \DateTime($endDate);
+ $buckets = [];
+
+ if ($period === '1h') {
+ $start->setTime((int) $start->format('H'), 0, 0, 0);
+ $interval = new \DateInterval('PT1H');
+ $format = 'Y-m-d H:i:s';
+ } elseif ($period === '1d') {
+ $start->setTime(0, 0, 0, 0);
+ $interval = new \DateInterval('P1D');
+ $format = 'Y-m-d H:i:s';
+ } else {
+ return [];
+ }
+
+ while ($start <= $end) {
+ $buckets[] = $start->format($format);
+ $start->add($interval);
+ }
+
+ return $buckets;
+ }
+
+ // βββ Purge βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * @param array $filters
+ */
+ public function purgeEvents(array $filters = []): bool
+ {
+ return $this->adapter->purgeEvents($filters);
+ }
+
+ /**
+ * @param array $filters
+ */
+ public function purgeGauges(array $filters = []): bool
+ {
+ return $this->adapter->purgeGauges($filters);
+ }
+
+ /**
+ * Purge all data (events + gauges).
+ */
+ public function purge(): bool
+ {
+ return $this->purgeEvents() && $this->purgeGauges();
+ }
+
+ // βββ Buffer management βββββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Flush buffered events and gauges to storage.
+ */
+ public function flush(): bool
+ {
+ if (empty($this->eventBuffer) && empty($this->gaugeBuffer)) {
+ $this->lastFlushTime = microtime(true);
+ return true;
+ }
+
+ $result = true;
+
+ if (!empty($this->eventBuffer)) {
+ $result = $this->adapter->addEvents($this->eventBuffer);
+ }
+
+ if ($result && !empty($this->gaugeBuffer)) {
+ $result = $this->adapter->setGauges(array_values($this->gaugeBuffer));
+ }
+
+ if ($result) {
+ $this->eventBuffer = [];
+ $this->gaugeBuffer = [];
+ $this->bufferCount = 0;
+ }
+
+ $this->lastFlushTime = microtime(true);
+
+ return $result;
+ }
+
+ public function shouldFlush(): bool
+ {
+ if ($this->bufferCount >= $this->flushThreshold) {
+ return true;
+ }
+
+ return (microtime(true) - $this->lastFlushTime) >= $this->flushInterval;
+ }
+
+ public function getBufferCount(): int
+ {
+ return $this->bufferCount;
+ }
+
+ public function getBufferSize(): int
+ {
+ return count($this->eventBuffer) + count($this->gaugeBuffer);
+ }
+
+ public function setFlushThreshold(int $threshold): self
+ {
+ if ($threshold < 1) {
+ throw new \InvalidArgumentException('Flush threshold must be at least 1');
+ }
+ $this->flushThreshold = $threshold;
+ return $this;
+ }
+
+ 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;
+ }
+
+ public function getFlushThreshold(): int
+ {
+ return $this->flushThreshold;
+ }
+
+ public function getFlushInterval(): int
+ {
+ return $this->flushInterval;
+ }
+
+ // βββ Multi-tenancy pass-through ββββββββββββββββββββββββββββββββββ
+
+ public function setNamespace(string $namespace): self
+ {
+ $this->flush();
+ $this->adapter->setNamespace($namespace);
+ return $this;
+ }
+
+ public function setTenant(int|string|null $tenant): self
+ {
+ $this->flush();
+ $this->adapter->setTenant($tenant);
+ return $this;
+ }
+
+ public function setSharedTables(bool $sharedTables): self
+ {
+ $this->flush();
+ $this->adapter->setSharedTables($sharedTables);
+ return $this;
+ }
+}
diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php
new file mode 100644
index 0000000..f423661
--- /dev/null
+++ b/tests/Usage/Adapter/ClickHouseTest.php
@@ -0,0 +1,1101 @@
+adapter = new ClickHouseAdapter($host, $username, $password, $port);
+ $this->adapter->setNamespace('utopia_test');
+ $this->adapter->setTenant(1);
+
+ if ($database = getenv('CLICKHOUSE_DATABASE')) {
+ $this->adapter->setDatabase($database);
+ }
+
+ $this->usage = new Usage($this->adapter);
+ $this->usage->setup();
+ $this->usage->purge();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->usage->purge();
+ }
+
+ // βββ Health Check ββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function testHealthCheck(): void
+ {
+ $health = $this->adapter->healthCheck();
+
+ $this->assertIsArray($health);
+ $this->assertTrue($health['healthy']);
+ $this->assertArrayHasKey('host', $health);
+ $this->assertArrayHasKey('port', $health);
+ $this->assertArrayHasKey('version', $health);
+ $this->assertArrayHasKey('response_time', $health);
+ $this->assertIsFloat($health['response_time']);
+ }
+
+ public function testHealthCheckWithBadHost(): void
+ {
+ $adapter = new ClickHouseAdapter('invalid-host-xyz', 'default', '', 8123);
+ $health = $adapter->healthCheck();
+
+ $this->assertFalse($health['healthy']);
+ $this->assertArrayHasKey('error', $health);
+ }
+
+ // βββ Events: Basic Insert & Query ββββββββββββββββββββββββββββββββ
+
+ public function testAddAndQueryEvents(): void
+ {
+ $this->assertTrue($this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/databases', 'method' => 'GET', 'status' => 200],
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/databases', 'method' => 'POST', 'status' => 201],
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/functions', 'method' => 'GET', 'status' => 200],
+ ['metric' => 'bandwidth', 'value' => 5000, 'path' => '/v1/storage/files', 'method' => 'GET', 'status' => 200],
+ ]));
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'api.request'],
+ period: '1h',
+ );
+
+ $this->assertNotEmpty($results);
+ }
+
+ public function testQueryEventsFilterByMetric(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1],
+ ['metric' => 'bandwidth', 'value' => 1024],
+ ['metric' => 'executions', 'value' => 5],
+ ]);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'bandwidth'],
+ period: '1h',
+ );
+
+ $this->assertCount(1, $results);
+ $this->assertEquals(1024, $results[0]['value']);
+ }
+
+ public function testQueryEventsFilterByMultipleValues(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'GET'],
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'POST'],
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'DELETE'],
+ ]);
+
+ $results = $this->usage->queryEvents(
+ filters: ['method' => ['GET', 'POST']],
+ period: '1h',
+ );
+
+ $totalValue = array_sum(array_column($results, 'value'));
+ $this->assertEquals(2, $totalValue);
+ }
+
+ public function testQueryEventsGroupByDimension(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'GET', 'path' => '/v1/databases'],
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'GET', 'path' => '/v1/databases'],
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'POST', 'path' => '/v1/databases'],
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'GET', 'path' => '/v1/functions'],
+ ]);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'api.request'],
+ period: '1h',
+ groupBy: ['method'],
+ );
+
+ $this->assertGreaterThanOrEqual(2, count($results));
+
+ $methods = array_column($results, 'method');
+ $this->assertContains('GET', $methods);
+ $this->assertContains('POST', $methods);
+
+ foreach ($results as $row) {
+ if ($row['method'] === 'GET') {
+ $this->assertEquals(3, $row['value']);
+ }
+ if ($row['method'] === 'POST') {
+ $this->assertEquals(1, $row['value']);
+ }
+ }
+ }
+
+ public function testQueryEventsGroupByMultipleDimensions(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'GET', 'status' => 200],
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'GET', 'status' => 200],
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'GET', 'status' => 404],
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'POST', 'status' => 201],
+ ]);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'api.request'],
+ period: '1h',
+ groupBy: ['method', 'status'],
+ );
+
+ $this->assertGreaterThanOrEqual(3, count($results));
+ }
+
+ public function testQueryEventsFilterByStatus(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'status' => 200],
+ ['metric' => 'api.request', 'value' => 1, 'status' => 200],
+ ['metric' => 'api.request', 'value' => 1, 'status' => 404],
+ ['metric' => 'api.request', 'value' => 1, 'status' => 500],
+ ]);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'api.request', 'status' => 200],
+ period: '1h',
+ );
+
+ $totalValue = array_sum(array_column($results, 'value'));
+ $this->assertEquals(2, $totalValue);
+ }
+
+ public function testQueryEventsFilterByPath(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/databases'],
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/databases'],
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/functions'],
+ ]);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'api.request', 'path' => '/v1/databases'],
+ period: '1h',
+ );
+
+ $totalValue = array_sum(array_column($results, 'value'));
+ $this->assertEquals(2, $totalValue);
+ }
+
+ // βββ Events: Aggregation βββββββββββββββββββββββββββββββββββββββββ
+
+ public function testSumEvents(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'bandwidth.proj-a', 'value' => 1000],
+ ['metric' => 'bandwidth.proj-a', 'value' => 2000],
+ ['metric' => 'bandwidth.proj-b', 'value' => 3000],
+ ]);
+
+ $total = $this->usage->sumEvents('bandwidth.proj-a');
+ $this->assertEquals(3000, $total);
+
+ $total = $this->usage->sumEvents('bandwidth.proj-b');
+ $this->assertEquals(3000, $total);
+ }
+
+ public function testSumEventsWithDateRange(): void
+ {
+ $now = new \DateTime();
+ $oneHourAgo = (clone $now)->modify('-1 hour');
+
+ $this->usage->addEvents([
+ ['metric' => 'bandwidth', 'value' => 5000],
+ ]);
+
+ $total = $this->usage->sumEvents(
+ 'bandwidth',
+ [],
+ $oneHourAgo->format('Y-m-d H:i:s'),
+ $now->format('Y-m-d H:i:s'),
+ );
+
+ $this->assertEquals(5000, $total);
+ }
+
+ public function testCountEvents(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1],
+ ['metric' => 'api.request', 'value' => 1],
+ ['metric' => 'bandwidth', 'value' => 1024],
+ ]);
+
+ $count = $this->usage->countEvents(['metric' => 'api.request']);
+ $this->assertEquals(2, $count);
+
+ $totalCount = $this->usage->countEvents();
+ $this->assertEquals(3, $totalCount);
+ }
+
+ public function testQueryEventsWithCountAggregate(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'GET'],
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'GET'],
+ ['metric' => 'api.request', 'value' => 1, 'method' => 'POST'],
+ ]);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'api.request'],
+ period: '1h',
+ groupBy: ['method'],
+ aggregate: 'count',
+ );
+
+ $this->assertNotEmpty($results);
+ }
+
+ // βββ Events: Ordering & Pagination βββββββββββββββββββββββββββββββ
+
+ public function testQueryEventsOrderAndLimit(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 10, 'path' => '/a'],
+ ['metric' => 'api.request', 'value' => 20, 'path' => '/b'],
+ ['metric' => 'api.request', 'value' => 30, 'path' => '/c'],
+ ]);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'api.request'],
+ period: '1h',
+ groupBy: ['path'],
+ orderBy: 'value',
+ orderDirection: 'DESC',
+ limit: 2,
+ );
+
+ $this->assertCount(2, $results);
+ $this->assertGreaterThanOrEqual($results[1]['value'], $results[0]['value']);
+ }
+
+ // βββ Events: Batch sizes βββββββββββββββββββββββββββββββββββββββββ
+
+ public function testAddEventsWithSmallBatch(): void
+ {
+ $events = [];
+ for ($i = 0; $i < 50; $i++) {
+ $events[] = ['metric' => 'batch.test', 'value' => 1];
+ }
+
+ $this->assertTrue($this->usage->addEvents($events, 10));
+
+ $total = $this->usage->sumEvents('batch.test');
+ $this->assertEquals(50, $total);
+ }
+
+ public function testAddEventsEmpty(): void
+ {
+ $this->assertTrue($this->usage->addEvents([]));
+ }
+
+ // βββ Gauges: Basic βββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function testSetAndGetGauge(): void
+ {
+ $this->assertTrue($this->usage->setGauges([
+ ['metric' => 'projects.count', 'value' => 42],
+ ]));
+
+ $value = $this->usage->getGauge('projects.count');
+ $this->assertEquals(42, $value);
+ }
+
+ public function testSetGaugeReplaces(): void
+ {
+ $this->usage->setGauges([
+ ['metric' => 'storage.bytes', 'value' => 1000, 'period' => '1d'],
+ ]);
+
+ $this->usage->setGauges([
+ ['metric' => 'storage.bytes', 'value' => 2000, 'period' => '1d'],
+ ]);
+
+ $value = $this->usage->getGauge('storage.bytes');
+ $this->assertEquals(2000, $value);
+ }
+
+ public function testQueryGauges(): void
+ {
+ $this->usage->setGauges([
+ ['metric' => 'projects.count', 'value' => 10, 'period' => '1d'],
+ ['metric' => 'databases.count', 'value' => 5, 'period' => '1d'],
+ ['metric' => 'functions.count', 'value' => 3, 'period' => '1d'],
+ ]);
+
+ $results = $this->usage->queryGauges(
+ period: '1d',
+ );
+
+ $this->assertCount(3, $results);
+ }
+
+ public function testQueryGaugesGroupByMetric(): void
+ {
+ $this->usage->setGauges([
+ ['metric' => 'projects.count', 'value' => 10, 'period' => '1d'],
+ ['metric' => 'databases.count', 'value' => 5, 'period' => '1d'],
+ ]);
+
+ $results = $this->usage->queryGauges(
+ period: '1d',
+ groupBy: ['metric'],
+ );
+
+ $this->assertGreaterThanOrEqual(2, count($results));
+ }
+
+ public function testGaugeWithResource(): void
+ {
+ $this->usage->setGauges([
+ ['metric' => 'storage.bytes', 'value' => 1000, 'resource' => 'bucket-1', 'period' => '1d'],
+ ['metric' => 'storage.bytes', 'value' => 2000, 'resource' => 'bucket-2', 'period' => '1d'],
+ ]);
+
+ $value = $this->usage->getGauge('storage.bytes', ['resource' => 'bucket-1']);
+ $this->assertEquals(1000, $value);
+ }
+
+ public function testGaugeHourlyAndDaily(): void
+ {
+ $this->usage->setGauges([
+ ['metric' => 'active.users', 'value' => 100, 'period' => '1h'],
+ ['metric' => 'active.users', 'value' => 500, 'period' => '1d'],
+ ]);
+
+ $hourlyResults = $this->usage->queryGauges(
+ filters: ['metric' => 'active.users'],
+ period: '1h',
+ );
+ $dailyResults = $this->usage->queryGauges(
+ filters: ['metric' => 'active.users'],
+ period: '1d',
+ );
+
+ $this->assertCount(1, $hourlyResults);
+ $this->assertCount(1, $dailyResults);
+ $this->assertEquals(100, $hourlyResults[0]['value']);
+ $this->assertEquals(500, $dailyResults[0]['value']);
+ }
+
+ public function testSetGaugesEmpty(): void
+ {
+ $this->assertTrue($this->usage->setGauges([]));
+ }
+
+ // βββ Purge βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function testPurgeEvents(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1],
+ ['metric' => 'bandwidth', 'value' => 1024],
+ ]);
+
+ $this->assertTrue($this->usage->purgeEvents());
+
+ $count = $this->usage->countEvents();
+ $this->assertEquals(0, $count);
+ }
+
+ public function testPurgeEventsWithFilter(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1],
+ ['metric' => 'bandwidth', 'value' => 1024],
+ ]);
+
+ $this->assertTrue($this->usage->purgeEvents(['metric' => 'api.request']));
+
+ $count = $this->usage->countEvents(['metric' => 'api.request']);
+ $this->assertEquals(0, $count);
+
+ $count = $this->usage->countEvents(['metric' => 'bandwidth']);
+ $this->assertEquals(1, $count);
+ }
+
+ public function testPurgeGauges(): void
+ {
+ $this->usage->setGauges([
+ ['metric' => 'projects.count', 'value' => 10, 'period' => '1d'],
+ ]);
+
+ $this->assertTrue($this->usage->purgeGauges());
+
+ $value = $this->usage->getGauge('projects.count');
+ $this->assertEquals(0, $value);
+ }
+
+ public function testPurgeAll(): void
+ {
+ $this->usage->addEvents([['metric' => 'test', 'value' => 1]]);
+ $this->usage->setGauges([['metric' => 'test.gauge', 'value' => 1, 'period' => '1d']]);
+
+ $this->assertTrue($this->usage->purge());
+
+ $this->assertEquals(0, $this->usage->countEvents());
+ $this->assertEquals(0, $this->usage->getGauge('test.gauge'));
+ }
+
+ // βββ Buffering βββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function testCollectEventAndFlush(): void
+ {
+ $this->usage->collectEvent(['metric' => 'api.request', 'value' => 1]);
+ $this->usage->collectEvent(['metric' => 'api.request', 'value' => 1]);
+ $this->usage->collectEvent(['metric' => 'bandwidth', 'value' => 5000]);
+
+ $this->assertEquals(3, $this->usage->getBufferCount());
+ $this->assertEquals(3, $this->usage->getBufferSize());
+
+ $this->assertEquals(0, $this->usage->countEvents());
+
+ $this->assertTrue($this->usage->flush());
+
+ $this->assertEquals(0, $this->usage->getBufferCount());
+ $this->assertEquals(2, $this->usage->countEvents(['metric' => 'api.request']));
+ }
+
+ public function testCollectGaugeAndFlush(): void
+ {
+ $this->usage->collectGauge(['metric' => 'projects.count', 'value' => 10, 'period' => '1d']);
+ $this->usage->collectGauge(['metric' => 'projects.count', 'value' => 20, 'period' => '1d']);
+
+ $this->assertEquals(2, $this->usage->getBufferCount());
+ $this->assertEquals(1, $this->usage->getBufferSize());
+
+ $this->assertTrue($this->usage->flush());
+
+ $value = $this->usage->getGauge('projects.count');
+ $this->assertEquals(20, $value);
+ }
+
+ public function testMixedCollectAndFlush(): void
+ {
+ $this->usage->collectEvent(['metric' => 'api.request', 'value' => 1]);
+ $this->usage->collectGauge(['metric' => 'projects.count', 'value' => 5, 'period' => '1d']);
+
+ $this->assertEquals(2, $this->usage->getBufferSize());
+
+ $this->assertTrue($this->usage->flush());
+
+ $this->assertEquals(1, $this->usage->countEvents());
+ $this->assertEquals(5, $this->usage->getGauge('projects.count'));
+ }
+
+ public function testFlushEmptyBuffer(): void
+ {
+ $this->assertTrue($this->usage->flush());
+ $this->assertEquals(0, $this->usage->getBufferCount());
+ }
+
+ public function testShouldFlushByThreshold(): void
+ {
+ $this->usage->setFlushThreshold(3);
+
+ $this->assertFalse($this->usage->shouldFlush());
+ $this->usage->collectEvent(['metric' => 'test', 'value' => 1]);
+ $this->usage->collectEvent(['metric' => 'test', 'value' => 1]);
+ $this->assertFalse($this->usage->shouldFlush());
+
+ $this->usage->collectEvent(['metric' => 'test', 'value' => 1]);
+ $this->assertTrue($this->usage->shouldFlush());
+
+ $this->usage->flush();
+ $this->usage->setFlushThreshold(10_000);
+ }
+
+ public function testShouldFlushByInterval(): void
+ {
+ $this->usage->setFlushInterval(1);
+ $this->usage->collectEvent(['metric' => 'test', 'value' => 1]);
+
+ $this->assertFalse($this->usage->shouldFlush());
+
+ sleep(2);
+
+ $this->assertTrue($this->usage->shouldFlush());
+
+ $this->usage->flush();
+ $this->usage->setFlushInterval(20);
+ }
+
+ public function testFlushThresholdValidation(): void
+ {
+ $this->usage->setFlushThreshold(500);
+ $this->assertEquals(500, $this->usage->getFlushThreshold());
+
+ $this->usage->setFlushInterval(30);
+ $this->assertEquals(30, $this->usage->getFlushInterval());
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->usage->setFlushThreshold(0);
+ }
+
+ // βββ Metered Billing Scenarios βββββββββββββββββββββββββββββββββββ
+
+ public function testBillingBandwidthByMetric(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'bandwidth.proj-a', 'value' => 1000],
+ ['metric' => 'bandwidth.proj-a', 'value' => 2000],
+ ['metric' => 'bandwidth.proj-b', 'value' => 500],
+ ]);
+
+ $projA = $this->usage->sumEvents('bandwidth.proj-a');
+ $projB = $this->usage->sumEvents('bandwidth.proj-b');
+
+ $this->assertEquals(3000, $projA);
+ $this->assertEquals(500, $projB);
+ }
+
+ public function testBillingExecutionComputeHours(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'executions.compute', 'value' => 3600, 'resource' => 'function-1'],
+ ['metric' => 'executions.compute', 'value' => 7200, 'resource' => 'function-2'],
+ ['metric' => 'executions.compute', 'value' => 1800, 'resource' => 'function-1'],
+ ]);
+
+ $total = $this->usage->sumEvents('executions.compute');
+ $this->assertEquals(12600, $total);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'executions.compute'],
+ period: '1h',
+ groupBy: ['resource'],
+ );
+
+ $this->assertGreaterThanOrEqual(2, count($results));
+ }
+
+ public function testBillingRequestCountByEndpoint(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/databases', 'method' => 'GET'],
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/databases', 'method' => 'GET'],
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/databases', 'method' => 'POST'],
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/storage/files', 'method' => 'GET'],
+ ]);
+
+ $totalRequests = $this->usage->sumEvents('api.request');
+ $this->assertEquals(4, $totalRequests);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'api.request'],
+ period: '1h',
+ groupBy: ['path'],
+ );
+
+ $this->assertGreaterThanOrEqual(2, count($results));
+ }
+
+ // βββ Cloudflare-style Analytics ββββββββββββββββββββββββββββββββββ
+
+ public function testAnalyticsBreakdownByStatusCode(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'status' => 200],
+ ['metric' => 'api.request', 'value' => 1, 'status' => 200],
+ ['metric' => 'api.request', 'value' => 1, 'status' => 404],
+ ['metric' => 'api.request', 'value' => 1, 'status' => 500],
+ ]);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'api.request'],
+ period: '1h',
+ groupBy: ['status'],
+ );
+
+ $this->assertGreaterThanOrEqual(3, count($results));
+ }
+
+ public function testAnalyticsTopPaths(): void
+ {
+ for ($i = 0; $i < 10; $i++) {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/databases'],
+ ]);
+ }
+ for ($i = 0; $i < 5; $i++) {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/functions'],
+ ]);
+ }
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'api.request'],
+ period: '1h',
+ groupBy: ['path'],
+ orderBy: 'value',
+ orderDirection: 'DESC',
+ limit: 5,
+ );
+
+ $this->assertNotEmpty($results);
+ $this->assertEquals('/v1/databases', $results[0]['path']);
+ }
+
+ // βββ Resource Gauge Scenarios ββββββββββββββββββββββββββββββββββββ
+
+ public function testGaugeResourceInventory(): void
+ {
+ $this->usage->setGauges([
+ ['metric' => 'projects.count', 'value' => 15, 'period' => '1d'],
+ ['metric' => 'databases.count', 'value' => 42, 'period' => '1d'],
+ ['metric' => 'functions.count', 'value' => 8, 'period' => '1d'],
+ ['metric' => 'storage.bytes', 'value' => 1073741824, 'period' => '1d'],
+ ]);
+
+ $this->assertEquals(15, $this->usage->getGauge('projects.count'));
+ $this->assertEquals(42, $this->usage->getGauge('databases.count'));
+ $this->assertEquals(8, $this->usage->getGauge('functions.count'));
+ $this->assertEquals(1073741824, $this->usage->getGauge('storage.bytes'));
+ }
+
+ public function testGaugePerResourceCounts(): void
+ {
+ $this->usage->setGauges([
+ ['metric' => 'databases.count', 'value' => 3, 'resource' => 'proj-a', 'period' => '1d'],
+ ['metric' => 'databases.count', 'value' => 7, 'resource' => 'proj-b', 'period' => '1d'],
+ ]);
+
+ $projA = $this->usage->getGauge('databases.count', ['resource' => 'proj-a']);
+ $projB = $this->usage->getGauge('databases.count', ['resource' => 'proj-b']);
+
+ $this->assertEquals(3, $projA);
+ $this->assertEquals(7, $projB);
+ }
+
+ // βββ Multi-tenancy βββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function testSharedTablesIsolation(): 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->setNamespace('utopia_shared');
+ $adapter->setSharedTables(true);
+ $adapter->setTenant(1);
+
+ if ($database = getenv('CLICKHOUSE_DATABASE')) {
+ $adapter->setDatabase($database);
+ }
+
+ $usage = new Usage($adapter);
+ $usage->setup();
+ $usage->purge();
+
+ $usage->addEvents([
+ ['metric' => 'api.request', 'value' => 10],
+ ]);
+
+ $adapter->setTenant(2);
+ $usage->addEvents([
+ ['metric' => 'api.request', 'value' => 20],
+ ]);
+
+ $sum = $usage->sumEvents('api.request');
+ $this->assertEquals(20, $sum);
+
+ $adapter->setTenant(1);
+ $sum = $usage->sumEvents('api.request');
+ $this->assertEquals(10, $sum);
+
+ $usage->purge();
+ $adapter->setTenant(2);
+ $usage->purge();
+ }
+
+ // βββ Infinite Period (lifetime totals) βββββββββββββββββββββββββ
+
+ public function testGaugeInfinitePeriod(): void
+ {
+ $this->usage->setGauges([
+ ['metric' => 'databases', 'value' => 5, 'period' => 'inf'],
+ ['metric' => 'collections', 'value' => 20, 'period' => 'inf'],
+ ]);
+
+ $results = $this->usage->queryGauges(
+ period: 'inf',
+ );
+
+ $this->assertCount(2, $results);
+ }
+
+ public function testGaugeInfinitePeriodReplace(): void
+ {
+ $this->usage->setGauges([
+ ['metric' => 'databases', 'value' => 5, 'period' => 'inf'],
+ ]);
+ $this->usage->setGauges([
+ ['metric' => 'databases', 'value' => 8, 'period' => 'inf'],
+ ]);
+
+ $value = $this->usage->getGauge('databases');
+ $this->assertEquals(8, $value);
+ }
+
+ public function testQueryEventsInfinitePeriod(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'network.requests', 'value' => 100],
+ ['metric' => 'network.requests', 'value' => 200],
+ ]);
+
+ $results = $this->usage->queryEvents(
+ filters: ['metric' => 'network.requests'],
+ period: 'inf',
+ );
+
+ $this->assertCount(1, $results);
+ $this->assertEquals(300, $results[0]['value']);
+ }
+
+ // βββ Metric Template Resolution βββββββββββββββββββββββββββββββββ
+
+ public function testResolvedMetricInEvents(): void
+ {
+ $metric = \Utopia\Usage\Adapter::resolveMetric(
+ '{databaseInternalId}.collections',
+ ['databaseInternalId' => 'db_abc123']
+ );
+ $this->assertEquals('db_abc123.collections', $metric);
+
+ $this->usage->addEvents([
+ ['metric' => $metric, 'value' => 5],
+ ]);
+
+ $total = $this->usage->sumEvents($metric);
+ $this->assertEquals(5, $total);
+ }
+
+ public function testResolvedMetricWithMultiplePlaceholders(): void
+ {
+ $metric = \Utopia\Usage\Adapter::resolveMetric(
+ '{databaseInternalId}.{collectionInternalId}.documents',
+ ['databaseInternalId' => 'db_1', 'collectionInternalId' => 'col_2']
+ );
+ $this->assertEquals('db_1.col_2.documents', $metric);
+
+ $this->usage->addEvents([
+ ['metric' => $metric, 'value' => 15],
+ ]);
+
+ $total = $this->usage->sumEvents($metric);
+ $this->assertEquals(15, $total);
+ }
+
+ // βββ Appwrite Resource Hierarchy Scenarios ββββββββββββββββββββββ
+
+ public function testDatabaseCollectionDocumentHierarchy(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'databases', 'value' => 1],
+ ['metric' => 'collections', 'value' => 2],
+ ['metric' => 'documents', 'value' => 100],
+ ['metric' => 'db_1.collections', 'value' => 2],
+ ['metric' => 'db_1.documents', 'value' => 100],
+ ['metric' => 'db_1.col_a.documents', 'value' => 60],
+ ['metric' => 'db_1.col_b.documents', 'value' => 40],
+ ]);
+
+ $this->assertEquals(1, $this->usage->sumEvents('databases'));
+ $this->assertEquals(2, $this->usage->sumEvents('collections'));
+ $this->assertEquals(100, $this->usage->sumEvents('documents'));
+ $this->assertEquals(2, $this->usage->sumEvents('db_1.collections'));
+ $this->assertEquals(100, $this->usage->sumEvents('db_1.documents'));
+ $this->assertEquals(60, $this->usage->sumEvents('db_1.col_a.documents'));
+ $this->assertEquals(40, $this->usage->sumEvents('db_1.col_b.documents'));
+ }
+
+ public function testBucketFileHierarchy(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'files', 'value' => 50],
+ ['metric' => 'files.storage', 'value' => 50000],
+ ['metric' => 'bucket_1.files', 'value' => 30],
+ ['metric' => 'bucket_1.files.storage', 'value' => 30000],
+ ['metric' => 'bucket_2.files', 'value' => 20],
+ ['metric' => 'bucket_2.files.storage', 'value' => 20000],
+ ]);
+
+ $this->assertEquals(50, $this->usage->sumEvents('files'));
+ $this->assertEquals(50000, $this->usage->sumEvents('files.storage'));
+ $this->assertEquals(30, $this->usage->sumEvents('bucket_1.files'));
+ $this->assertEquals(20000, $this->usage->sumEvents('bucket_2.files.storage'));
+ }
+
+ // βββ High-level: getMetrics() ββββββββββββββββββββββββββββββββββ
+
+ public function testGetMetricsReturnsZeroFilledTimeSeries(): void
+ {
+ $now = new \DateTime();
+ $threeHoursAgo = (clone $now)->modify('-3 hours');
+
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 5],
+ ['metric' => 'bandwidth', 'value' => 2000],
+ ]);
+
+ $result = $this->usage->getMetrics(
+ metrics: ['api.request', 'bandwidth'],
+ period: '1h',
+ startDate: $threeHoursAgo->format('Y-m-d H:i:s'),
+ endDate: $now->format('Y-m-d H:i:s'),
+ );
+
+ $this->assertArrayHasKey('api.request', $result);
+ $this->assertArrayHasKey('bandwidth', $result);
+ $this->assertArrayHasKey('total', $result['api.request']);
+ $this->assertArrayHasKey('data', $result['api.request']);
+ $this->assertEquals(5, $result['api.request']['total']);
+ $this->assertEquals(2000, $result['bandwidth']['total']);
+ $this->assertGreaterThanOrEqual(3, count($result['api.request']['data']));
+
+ foreach ($result['api.request']['data'] as $point) {
+ $this->assertArrayHasKey('value', $point);
+ $this->assertArrayHasKey('date', $point);
+ $this->assertIsInt($point['value']);
+ }
+ }
+
+ public function testGetMetricsDailyPeriod(): void
+ {
+ $now = new \DateTime();
+ $sevenDaysAgo = (clone $now)->modify('-7 days');
+
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 100],
+ ]);
+
+ $result = $this->usage->getMetrics(
+ metrics: ['api.request'],
+ period: '1d',
+ startDate: $sevenDaysAgo->format('Y-m-d H:i:s'),
+ endDate: $now->format('Y-m-d H:i:s'),
+ );
+
+ $this->assertEquals(100, $result['api.request']['total']);
+ $this->assertGreaterThanOrEqual(7, count($result['api.request']['data']));
+ }
+
+ public function testGetMetricsEmptyResult(): void
+ {
+ $now = new \DateTime();
+ $oneHourAgo = (clone $now)->modify('-1 hour');
+
+ $result = $this->usage->getMetrics(
+ metrics: ['nonexistent.metric'],
+ period: '1h',
+ startDate: $oneHourAgo->format('Y-m-d H:i:s'),
+ endDate: $now->format('Y-m-d H:i:s'),
+ );
+
+ $this->assertArrayHasKey('nonexistent.metric', $result);
+ $this->assertEquals(0, $result['nonexistent.metric']['total']);
+
+ foreach ($result['nonexistent.metric']['data'] as $point) {
+ $this->assertEquals(0, $point['value']);
+ }
+ }
+
+ public function testGetMetricsSparseWithoutZeroFill(): void
+ {
+ $now = new \DateTime();
+ $threeHoursAgo = (clone $now)->modify('-3 hours');
+
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 5],
+ ]);
+
+ $result = $this->usage->getMetrics(
+ metrics: ['api.request'],
+ period: '1h',
+ startDate: $threeHoursAgo->format('Y-m-d H:i:s'),
+ endDate: $now->format('Y-m-d H:i:s'),
+ zeroFill: false,
+ );
+
+ $this->assertArrayHasKey('api.request', $result);
+ $this->assertEquals(5, $result['api.request']['total']);
+
+ foreach ($result['api.request']['data'] as $point) {
+ $this->assertGreaterThan(0, $point['value']);
+ $this->assertArrayHasKey('date', $point);
+ }
+ }
+
+ public function testGetMetricsSparseEmptyReturnsNoData(): void
+ {
+ $now = new \DateTime();
+ $oneHourAgo = (clone $now)->modify('-1 hour');
+
+ $result = $this->usage->getMetrics(
+ metrics: ['nonexistent.metric'],
+ period: '1h',
+ startDate: $oneHourAgo->format('Y-m-d H:i:s'),
+ endDate: $now->format('Y-m-d H:i:s'),
+ zeroFill: false,
+ );
+
+ $this->assertArrayHasKey('nonexistent.metric', $result);
+ $this->assertEquals(0, $result['nonexistent.metric']['total']);
+ $this->assertEmpty($result['nonexistent.metric']['data']);
+ }
+
+ // βββ High-level: getMetricsTotals() βββββββββββββββββββββββββββββ
+
+ public function testGetMetricsTotals(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'bandwidth', 'value' => 1000],
+ ['metric' => 'bandwidth', 'value' => 2000],
+ ['metric' => 'executions', 'value' => 50],
+ ['metric' => 'db.reads', 'value' => 300],
+ ]);
+
+ $totals = $this->usage->getMetricsTotals(
+ metrics: ['bandwidth', 'executions', 'db.reads'],
+ );
+
+ $this->assertEquals(3000, $totals['bandwidth']);
+ $this->assertEquals(50, $totals['executions']);
+ $this->assertEquals(300, $totals['db.reads']);
+ }
+
+ public function testGetMetricsTotalsWithDateRange(): void
+ {
+ $now = new \DateTime();
+ $oneHourAgo = (clone $now)->modify('-1 hour');
+
+ $this->usage->addEvents([
+ ['metric' => 'bandwidth', 'value' => 5000],
+ ]);
+
+ $totals = $this->usage->getMetricsTotals(
+ metrics: ['bandwidth'],
+ startDate: $oneHourAgo->format('Y-m-d H:i:s'),
+ endDate: $now->format('Y-m-d H:i:s'),
+ );
+
+ $this->assertEquals(5000, $totals['bandwidth']);
+ }
+
+ public function testGetMetricsTotalsEmptyReturnsZeros(): void
+ {
+ $totals = $this->usage->getMetricsTotals(
+ metrics: ['nonexistent.a', 'nonexistent.b'],
+ );
+
+ $this->assertEquals(0, $totals['nonexistent.a']);
+ $this->assertEquals(0, $totals['nonexistent.b']);
+ }
+
+ // βββ High-level: getBreakdown() βββββββββββββββββββββββββββββββββ
+
+ public function testGetBreakdownByResource(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'executions.compute', 'value' => 500, 'resource' => 'func-1'],
+ ['metric' => 'executions.compute', 'value' => 300, 'resource' => 'func-1'],
+ ['metric' => 'executions.compute', 'value' => 200, 'resource' => 'func-2'],
+ ['metric' => 'executions.compute', 'value' => 100, 'resource' => 'func-3'],
+ ]);
+
+ $breakdown = $this->usage->getBreakdown(
+ metric: 'executions.compute',
+ limit: 10,
+ );
+
+ $this->assertCount(3, $breakdown);
+
+ $this->assertEquals('func-1', $breakdown[0]['resourceId']);
+ $this->assertEquals(800, $breakdown[0]['value']);
+
+ $this->assertEquals('func-2', $breakdown[1]['resourceId']);
+ $this->assertEquals(200, $breakdown[1]['value']);
+
+ $this->assertEquals('func-3', $breakdown[2]['resourceId']);
+ $this->assertEquals(100, $breakdown[2]['value']);
+ }
+
+ public function testGetBreakdownWithLimit(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'storage', 'value' => 1000, 'resource' => 'bucket-a'],
+ ['metric' => 'storage', 'value' => 500, 'resource' => 'bucket-b'],
+ ['metric' => 'storage', 'value' => 200, 'resource' => 'bucket-c'],
+ ]);
+
+ $breakdown = $this->usage->getBreakdown(
+ metric: 'storage',
+ limit: 2,
+ );
+
+ $this->assertCount(2, $breakdown);
+ $this->assertEquals('bucket-a', $breakdown[0]['resourceId']);
+ $this->assertEquals('bucket-b', $breakdown[1]['resourceId']);
+ }
+
+ public function testGetBreakdownByCustomDimension(): void
+ {
+ $this->usage->addEvents([
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/databases'],
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/databases'],
+ ['metric' => 'api.request', 'value' => 1, 'path' => '/v1/functions'],
+ ]);
+
+ $breakdown = $this->usage->getBreakdown(
+ metric: 'api.request',
+ dimension: 'path',
+ );
+
+ $this->assertCount(2, $breakdown);
+ $this->assertEquals('/v1/databases', $breakdown[0]['resourceId']);
+ $this->assertEquals(2, $breakdown[0]['value']);
+ }
+
+ // βββ Validation ββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public function testInvalidPeriodThrows(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->usage->queryEvents(period: 'invalid');
+ }
+
+ public function testInvalidAggregateThrows(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->usage->queryEvents(aggregate: 'invalid');
+ }
+
+ public function testInvalidGroupByDimensionThrows(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->usage->queryEvents(groupBy: ['nonexistent_column']);
+ }
+}
diff --git a/tests/Usage/MetricTest.php b/tests/Usage/MetricTest.php
new file mode 100644
index 0000000..e197479
--- /dev/null
+++ b/tests/Usage/MetricTest.php
@@ -0,0 +1,126 @@
+ 'abc-123',
+ 'metric' => 'api.request',
+ 'value' => 42,
+ 'time' => '2025-01-01 12:00:00',
+ 'path' => '/v1/databases',
+ 'method' => 'GET',
+ 'status' => 200,
+ 'userAgent' => 'Mozilla/5.0',
+ 'resource' => 'database',
+ 'resourceId' => 'db-123',
+ 'tenant' => 1,
+ ]);
+
+ $this->assertEquals('abc-123', $metric->getId());
+ $this->assertEquals('api.request', $metric->getMetric());
+ $this->assertEquals(42, $metric->getValue());
+ $this->assertEquals('2025-01-01 12:00:00', $metric->getTime());
+ $this->assertEquals('database', $metric->getResource());
+ $this->assertEquals('db-123', $metric->getResourceId());
+ $this->assertEquals(1, $metric->getTenant());
+
+ // HTTP-specific fields accessible via getAttribute
+ $this->assertEquals('/v1/databases', $metric->getAttribute('path'));
+ $this->assertEquals('GET', $metric->getAttribute('method'));
+ $this->assertEquals(200, $metric->getAttribute('status'));
+ $this->assertEquals('Mozilla/5.0', $metric->getAttribute('userAgent'));
+ }
+
+ public function testDefaults(): void
+ {
+ $metric = new Metric([]);
+
+ $this->assertEquals('', $metric->getId());
+ $this->assertEquals('', $metric->getMetric());
+ $this->assertEquals(0, $metric->getValue());
+ $this->assertNull($metric->getTime());
+ $this->assertEquals('', $metric->getResource());
+ $this->assertEquals('', $metric->getResourceId());
+ $this->assertNull($metric->getTenant());
+ }
+
+ public function testSetAttribute(): void
+ {
+ $metric = new Metric([]);
+ $result = $metric->setAttribute('custom', 'value');
+
+ $this->assertSame($metric, $result);
+ $this->assertEquals('value', $metric->getAttribute('custom'));
+ $this->assertTrue($metric->hasAttribute('custom'));
+ }
+
+ public function testToArray(): void
+ {
+ $data = ['metric' => 'test', 'value' => 10];
+ $metric = new Metric($data);
+
+ $this->assertEquals($data, $metric->toArray());
+ }
+
+ public function testIdFallback(): void
+ {
+ $metric = new Metric(['id' => 'fallback-id']);
+ $this->assertEquals('fallback-id', $metric->getId());
+ }
+
+ public function testNumericTenantConversion(): void
+ {
+ $metric = new Metric(['tenant' => '123']);
+ $this->assertEquals(123, $metric->getTenant());
+ $this->assertIsInt($metric->getTenant());
+ }
+
+ public function testStringTenant(): void
+ {
+ $metric = new Metric(['tenant' => 'org-abc']);
+ $this->assertSame('org-abc', $metric->getTenant());
+ }
+
+ public function testIntTenantPreserved(): void
+ {
+ $metric = new Metric(['tenant' => 42]);
+ $this->assertSame(42, $metric->getTenant());
+ $this->assertIsInt($metric->getTenant());
+ }
+
+ public function testNullTenantDefault(): void
+ {
+ $metric = new Metric([]);
+ $this->assertNull($metric->getTenant());
+ }
+
+ public function testGetValueWithFloat(): void
+ {
+ $metric = new Metric(['value' => 1.75]);
+ $this->assertSame(1.75, $metric->getValue());
+ $this->assertIsFloat($metric->getValue());
+ }
+
+ public function testGetValueWithNumericString(): void
+ {
+ $metric = new Metric(['value' => '42']);
+ $this->assertSame(42, $metric->getValue());
+
+ $metric = new Metric(['value' => '3.14']);
+ $this->assertSame(3.14, $metric->getValue());
+ }
+
+ public function testGetValueWithNonNumericStringReturnsDefault(): void
+ {
+ $metric = new Metric(['value' => 'abc']);
+ $this->assertSame(0, $metric->getValue());
+ $this->assertSame(99, $metric->getValue(99));
+ }
+}
diff --git a/tests/Usage/UsageUnitTest.php b/tests/Usage/UsageUnitTest.php
new file mode 100644
index 0000000..703651f
--- /dev/null
+++ b/tests/Usage/UsageUnitTest.php
@@ -0,0 +1,242 @@
+assertArrayHasKey('1h', Adapter::PERIODS);
+ $this->assertArrayHasKey('1d', Adapter::PERIODS);
+ $this->assertArrayHasKey('inf', Adapter::PERIODS);
+ $this->assertEquals('toStartOfHour', Adapter::PERIODS['1h']);
+ $this->assertEquals('toStartOfDay', Adapter::PERIODS['1d']);
+ $this->assertNull(Adapter::PERIODS['inf']);
+ }
+
+ public function testAggregateConstants(): void
+ {
+ $this->assertContains('sum', Adapter::AGGREGATES);
+ $this->assertContains('count', Adapter::AGGREGATES);
+ $this->assertContains('avg', Adapter::AGGREGATES);
+ $this->assertContains('min', Adapter::AGGREGATES);
+ $this->assertContains('max', Adapter::AGGREGATES);
+ }
+
+ public function testEventDimensions(): void
+ {
+ $this->assertContains('metric', Adapter::EVENT_DIMENSIONS);
+ $this->assertContains('path', Adapter::EVENT_DIMENSIONS);
+ $this->assertContains('method', Adapter::EVENT_DIMENSIONS);
+ $this->assertContains('status', Adapter::EVENT_DIMENSIONS);
+ $this->assertContains('userAgent', Adapter::EVENT_DIMENSIONS);
+ $this->assertContains('resource', Adapter::EVENT_DIMENSIONS);
+ $this->assertContains('resourceId', Adapter::EVENT_DIMENSIONS);
+ }
+
+ public function testGaugeDimensions(): void
+ {
+ $this->assertContains('metric', Adapter::GAUGE_DIMENSIONS);
+ $this->assertContains('resource', Adapter::GAUGE_DIMENSIONS);
+ $this->assertContains('resourceId', Adapter::GAUGE_DIMENSIONS);
+ }
+
+ // βββ ClickHouse adapter validation βββββββββββββββββββββββββββββββ
+
+ public function testClickHouseEmptyHostThrows(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('cannot be empty');
+ new ClickHouse('');
+ }
+
+ public function testClickHouseAdapterName(): void
+ {
+ $adapter = new ClickHouse('localhost');
+ $this->assertEquals('ClickHouse', $adapter->getName());
+ }
+
+ public function testClickHouseSetters(): void
+ {
+ $adapter = new ClickHouse('localhost');
+
+ $result = $adapter->setDatabase('testdb');
+ $this->assertSame($adapter, $result);
+
+ $result = $adapter->setNamespace('ns');
+ $this->assertSame($adapter, $result);
+
+ $result = $adapter->setTenant(42);
+ $this->assertSame($adapter, $result);
+
+ $result = $adapter->setSharedTables(true);
+ $this->assertSame($adapter, $result);
+
+ $result = $adapter->setMaxRetries(5);
+ $this->assertSame($adapter, $result);
+
+ $result = $adapter->setRetryDelay(500);
+ $this->assertSame($adapter, $result);
+
+ $result = $adapter->setTimeout(5000);
+ $this->assertSame($adapter, $result);
+
+ $result = $adapter->setUseFinal(false);
+ $this->assertSame($adapter, $result);
+ }
+
+ // βββ Usage class buffer management (no adapter calls) ββββββββββββ
+
+ public function testBufferCollectAndSize(): void
+ {
+ $adapter = $this->createMockAdapter();
+ $usage = new Usage($adapter);
+
+ $this->assertEquals(0, $usage->getBufferCount());
+ $this->assertEquals(0, $usage->getBufferSize());
+
+ $usage->collectEvent(['metric' => 'test', 'value' => 1]);
+ $usage->collectEvent(['metric' => 'test', 'value' => 2]);
+
+ $this->assertEquals(2, $usage->getBufferCount());
+ $this->assertEquals(2, $usage->getBufferSize());
+ }
+
+ public function testGaugeBufferLastWriteWins(): void
+ {
+ $adapter = $this->createMockAdapter();
+ $usage = new Usage($adapter);
+
+ $usage->collectGauge(['metric' => 'projects.count', 'value' => 10]);
+ $usage->collectGauge(['metric' => 'projects.count', 'value' => 20]);
+
+ // Same key, so buffer size should be 1
+ $this->assertEquals(1, $usage->getBufferSize());
+ $this->assertEquals(2, $usage->getBufferCount());
+ }
+
+ public function testFlushThresholdConfiguration(): void
+ {
+ $adapter = $this->createMockAdapter();
+ $usage = new Usage($adapter);
+
+ $usage->setFlushThreshold(100);
+ $this->assertEquals(100, $usage->getFlushThreshold());
+
+ $usage->setFlushInterval(60);
+ $this->assertEquals(60, $usage->getFlushInterval());
+ }
+
+ public function testFlushThresholdInvalidThrows(): void
+ {
+ $adapter = $this->createMockAdapter();
+ $usage = new Usage($adapter);
+
+ $this->expectException(\InvalidArgumentException::class);
+ $usage->setFlushThreshold(0);
+ }
+
+ public function testFlushIntervalInvalidThrows(): void
+ {
+ $adapter = $this->createMockAdapter();
+ $usage = new Usage($adapter);
+
+ $this->expectException(\InvalidArgumentException::class);
+ $usage->setFlushInterval(0);
+ }
+
+ public function testShouldFlushByThreshold(): void
+ {
+ $adapter = $this->createMockAdapter();
+ $usage = new Usage($adapter);
+ $usage->setFlushThreshold(2);
+
+ $this->assertFalse($usage->shouldFlush());
+
+ $usage->collectEvent(['metric' => 'test', 'value' => 1]);
+ $this->assertFalse($usage->shouldFlush());
+
+ $usage->collectEvent(['metric' => 'test', 'value' => 1]);
+ $this->assertTrue($usage->shouldFlush());
+ }
+
+ public function testFlushEmptyBufferReturnsTrue(): void
+ {
+ $adapter = $this->createMockAdapter();
+ $usage = new Usage($adapter);
+
+ $this->assertTrue($usage->flush());
+ $this->assertEquals(0, $usage->getBufferCount());
+ $this->assertEquals(0, $usage->getBufferSize());
+ }
+
+ public function testFlushResetsBuffer(): void
+ {
+ $adapter = $this->createMock(Adapter::class);
+ $adapter->method('addEvents')->willReturn(true);
+ $adapter->method('setGauges')->willReturn(true);
+
+ $usage = new Usage($adapter);
+
+ $usage->collectEvent(['metric' => 'test', 'value' => 1]);
+ $usage->collectGauge(['metric' => 'gauge', 'value' => 5]);
+
+ $this->assertTrue($usage->flush());
+ $this->assertEquals(0, $usage->getBufferCount());
+ $this->assertEquals(0, $usage->getBufferSize());
+ }
+
+ // βββ Metric template resolution βββββββββββββββββββββββββββββββββ
+
+ public function testResolveMetricSinglePlaceholder(): void
+ {
+ $result = Adapter::resolveMetric('{databaseInternalId}.collections', [
+ 'databaseInternalId' => 'db_abc123',
+ ]);
+ $this->assertEquals('db_abc123.collections', $result);
+ }
+
+ public function testResolveMetricMultiplePlaceholders(): void
+ {
+ $result = Adapter::resolveMetric('{databaseInternalId}.{collectionInternalId}.documents', [
+ 'databaseInternalId' => 'db_abc',
+ 'collectionInternalId' => 'col_xyz',
+ ]);
+ $this->assertEquals('db_abc.col_xyz.documents', $result);
+ }
+
+ public function testResolveMetricNoPlaceholders(): void
+ {
+ $result = Adapter::resolveMetric('databases', ['foo' => 'bar']);
+ $this->assertEquals('databases', $result);
+ }
+
+ public function testResolveMetricViaUsageFacade(): void
+ {
+ $result = Usage::resolveMetric('{bucketInternalId}.files', [
+ 'bucketInternalId' => 'bucket_123',
+ ]);
+ $this->assertEquals('bucket_123.files', $result);
+ }
+
+ public function testResolveMetricResourceType(): void
+ {
+ $result = Adapter::resolveMetric('{resourceType}.{resourceInternalId}.executions', [
+ 'resourceType' => 'functions',
+ 'resourceInternalId' => 'fn_abc',
+ ]);
+ $this->assertEquals('functions.fn_abc.executions', $result);
+ }
+
+ private function createMockAdapter(): Adapter
+ {
+ return $this->createMock(Adapter::class);
+ }
+}