mirror of
https://github.com/Sylius/Sylius.git
synced 2026-07-14 17:10:53 +00:00
[UPMERGE] 2.1 -> 2.2 (#18923)
This PR has been generated automatically. For more details see [upmerge_pr.yaml](/Sylius/Sylius/blob/2.3/.github/workflows/upmerge_pr.yaml). **Remember!** The upmerge should always be merged with using `Merge pull request` button. In case of conflicts, please resolve them manually with usign the following commands: ``` git fetch upstream gh pr checkout <this-pr-number> git merge upstream/2.2 -m "Resolve conflicts between 2.1 and 2.2" ``` If you use other name for the upstream remote, please replace `upstream` with the name of your remote pointing to the `Sylius/Sylius` repository. Once the conflicts are resolved, please run `git merge --continue` and push the changes to this PR.
This commit is contained in:
commit
3e449b0f38
25 changed files with 763 additions and 54 deletions
304
UPGRADE-2.1.md
304
UPGRADE-2.1.md
|
|
@ -1,25 +1,311 @@
|
|||
# UPGRADE FROM `2.1.9` TO `2.1.10`
|
||||
# UPGRADE FROM `2.1.12` TO `2.1.13`
|
||||
|
||||
## Redirect behavior changes
|
||||
### Telemetry improvements
|
||||
|
||||
`CurrencySwitchController`, `ImpersonateUserController` and `StorageBasedLocaleSwitcher` no longer use the
|
||||
HTTP `Referer` header for redirects. Instead, they use the `RouterInterface` to generate a redirect URL based on
|
||||
the `_sylius.redirect` route attribute (defaulting to `sylius_shop_homepage`).
|
||||
Telemetry has been improved with per-query database timeouts to prevent slow queries from blocking admin panel
|
||||
requests. Timeouts are applied automatically using platform-specific mechanisms (MySQL, MariaDB, PostgreSQL).
|
||||
|
||||
If your application relied on the Referer-based redirect behavior (e.g., to return the user to the page they came from
|
||||
after switching currency or locale), you can customize the redirect target by overriding the route definition:
|
||||
The global query timeout (in milliseconds, default: 60000, minimum: 1000) is **configurable** via environment variable:
|
||||
|
||||
```dotenv
|
||||
SYLIUS_TELEMETRY_QUERY_TIMEOUT=30000
|
||||
```
|
||||
|
||||
Additionally, telemetry collection is now rate-limited — it will be skipped if it was already triggered within
|
||||
the last hour, preventing redundant data collection on rapid admin page loads.
|
||||
|
||||
# UPGRADE FROM `2.1.11` TO `2.1.12`
|
||||
|
||||
This is a **security release** addressing multiple vulnerabilities. Updating is strongly recommended.
|
||||
|
||||
## Security fixes
|
||||
|
||||
### [API] DQL Injection via API Order Filters (Critical)
|
||||
|
||||
An unauthenticated DQL injection vulnerability has been fixed in the following API order filters:
|
||||
|
||||
- `Sylius\Bundle\ApiBundle\Filter\Doctrine\ProductPriceOrderFilter`
|
||||
- `Sylius\Bundle\ApiBundle\Filter\Doctrine\TranslationOrderNameAndLocaleFilter`
|
||||
|
||||
Previously, user-supplied sort direction values (e.g. `order[price]`, `order[translation.name]`) were passed directly
|
||||
into DQL `ORDER BY` clauses without validation, allowing an attacker to inject arbitrary DQL expressions.
|
||||
|
||||
Both filters now define an `ALLOWED_DIRECTIONS` whitelist (`['asc', 'desc']`) and validate the input against it
|
||||
before applying it to the query. Invalid values are silently ignored.
|
||||
|
||||
**Changes in `ProductPriceOrderFilter`:**
|
||||
|
||||
```diff
|
||||
final class ProductPriceOrderFilter extends AbstractContextAwareFilter
|
||||
{
|
||||
+ private const ALLOWED_DIRECTIONS = ['asc', 'desc'];
|
||||
+
|
||||
protected function filterProperty(/* ... */)
|
||||
{
|
||||
// ...
|
||||
+ $direction = strtolower($value['price']);
|
||||
+ if (!in_array($direction, self::ALLOWED_DIRECTIONS, true)) {
|
||||
+ return;
|
||||
+ }
|
||||
// ...
|
||||
- ->orderBy('channelPricing.price', $value['price'])
|
||||
+ ->orderBy('channelPricing.price', $direction)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Changes in `TranslationOrderNameAndLocaleFilter`:**
|
||||
|
||||
```diff
|
||||
final class TranslationOrderNameAndLocaleFilter extends AbstractContextAwareFilter
|
||||
{
|
||||
+ private const ALLOWED_DIRECTIONS = ['asc', 'desc'];
|
||||
+
|
||||
protected function filterProperty(/* ... */)
|
||||
{
|
||||
// ...
|
||||
- $direction = $value['translation.name'];
|
||||
+ $direction = strtolower($value['translation.name']);
|
||||
+ if (!in_array($direction, self::ALLOWED_DIRECTIONS, true)) {
|
||||
+ return;
|
||||
+ }
|
||||
// ...
|
||||
- ->orderBy('translation.name', $value['translation.name'])
|
||||
+ ->orderBy('translation.name', $direction)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**No action required** — this fix is applied automatically upon updating. If you have extended or overridden either
|
||||
of these filters, verify that your custom implementation also validates the sort direction.
|
||||
|
||||
### [Promotion] Race condition on promotion usage limit (High)
|
||||
|
||||
A race condition has been fixed where concurrent orders could exceed a promotion's usage limit. When multiple checkouts
|
||||
completed simultaneously, the non-atomic read-then-write of `Promotion::$used` allowed the counter to be incremented
|
||||
beyond `usageLimit`.
|
||||
|
||||
1. A new class has been introduced:
|
||||
|
||||
`Sylius\Bundle\CoreBundle\Doctrine\ORM\Promotion\Modifier\AtomicOrderPromotionsUsageModifier`
|
||||
|
||||
This class implements `Sylius\Component\Core\Promotion\Modifier\OrderPromotionsUsageModifierInterface` and uses
|
||||
atomic SQL statements (`UPDATE ... WHERE used < usage_limit` and `SELECT ... FOR UPDATE`) to enforce promotion
|
||||
and coupon usage limits at the database level, preventing race conditions.
|
||||
|
||||
Its constructor accepts a `Doctrine\DBAL\Connection`:
|
||||
|
||||
```php
|
||||
public function __construct(Connection $connection)
|
||||
```
|
||||
|
||||
2. The new service **decorates** the existing `sylius.promotion_usage_modifier` service:
|
||||
|
||||
```xml
|
||||
<service
|
||||
id="sylius.promotion_usage_modifier.atomic"
|
||||
class="Sylius\Bundle\CoreBundle\Doctrine\ORM\Promotion\Modifier\AtomicOrderPromotionsUsageModifier"
|
||||
decorates="sylius.promotion_usage_modifier"
|
||||
>
|
||||
<argument type="service" id="doctrine.dbal.default_connection" />
|
||||
</service>
|
||||
```
|
||||
|
||||
If you have overridden or decorated the `sylius.promotion_usage_modifier` service, review your customizations
|
||||
to ensure compatibility with the new decorator chain.
|
||||
|
||||
3. A new exception class has been introduced:
|
||||
|
||||
`Sylius\Component\Core\Promotion\Exception\PromotionUsageLimitReachedException`
|
||||
|
||||
This exception extends `Doctrine\ORM\OptimisticLockException` and is thrown when a promotion or coupon usage
|
||||
limit has been reached during checkout. It provides two named constructors:
|
||||
|
||||
```php
|
||||
PromotionUsageLimitReachedException::withPromotionCode(string $code): self
|
||||
PromotionUsageLimitReachedException::withCouponCode(string $code): self
|
||||
```
|
||||
|
||||
If you have custom error handling around the checkout completion workflow (e.g. in state machine callbacks
|
||||
or event listeners), you may want to catch this exception to display an appropriate message to the customer.
|
||||
|
||||
### [Admin] XSS in taxon tree and autocompletes (High)
|
||||
|
||||
A stored XSS vulnerability has been fixed in the admin panel's taxon tree and autocomplete components. Taxon names
|
||||
containing malicious HTML/JavaScript were rendered without escaping.
|
||||
|
||||
**Affected files:**
|
||||
|
||||
- `Sylius\Bundle\AdminBundle\Resources\assets\controllers\ProductTaxonTreeController.js` — taxon name is now escaped
|
||||
via `textContent` before insertion into the DOM template.
|
||||
- `Sylius\Bundle\AdminBundle\Resources\assets\controllers\TaxonTreeController.js` — uses `textContent` assignment
|
||||
instead of string interpolation with `replaceAll('__TAXON_NAME__', name)`.
|
||||
- New file `Sylius\Bundle\AdminBundle\Resources\assets\scripts\autocomplete-xss-protection.js` — intercepts
|
||||
`autocomplete:pre-connect` events and escapes label fields before rendering.
|
||||
|
||||
**No action required** — this fix is applied automatically upon updating. If you have custom JavaScript that renders
|
||||
taxon names or autocomplete labels using `innerHTML` or string templates, review your code for similar XSS vectors.
|
||||
|
||||
### [Shop] XSS in taxon breadcrumbs (High)
|
||||
|
||||
A stored XSS vulnerability has been fixed in the shop breadcrumbs template. Breadcrumb labels were rendered using
|
||||
Twig's `|raw` filter, allowing injected HTML in taxon or order names to execute in the browser.
|
||||
|
||||
**Changes in `shared/breadcrumbs.html.twig`:**
|
||||
|
||||
```diff
|
||||
- <a class="link-reset" href="{{ item.path }}">{{ item.label|raw }}</a>
|
||||
+ <a class="link-reset" href="{{ item.path }}">{{ item.label }}</a>
|
||||
...
|
||||
- <span class="text-body-tertiary text-break">{{ item.label|raw }}</span>
|
||||
+ <span class="text-body-tertiary text-break">{{ item.label }}</span>
|
||||
```
|
||||
|
||||
If you have overridden the `shared/breadcrumbs.html.twig` template, ensure you are not using the `|raw` filter
|
||||
on user-controllable label values.
|
||||
|
||||
### [Shop] XSS in ApiLoginController (Medium)
|
||||
|
||||
A DOM-based XSS vulnerability has been fixed in `ApiLoginController.js`. The server error message was inserted
|
||||
using `innerHTML`, allowing malicious content in the response to execute JavaScript.
|
||||
|
||||
```diff
|
||||
- errorElement.innerHTML = response.message;
|
||||
+ errorElement.textContent = response.message;
|
||||
```
|
||||
|
||||
**No action required** — this fix is applied automatically upon updating.
|
||||
|
||||
### [Shop] IDOR in checkout address LiveComponent (High)
|
||||
|
||||
An Insecure Direct Object Reference (IDOR) vulnerability has been fixed in the checkout address LiveComponent.
|
||||
The `addressFieldUpdated()` method in `Checkout\Address\FormComponent` used `$this->addressRepository->find($addressId)`,
|
||||
allowing any authenticated customer to load another customer's address by manipulating the `#[LiveArg]` value.
|
||||
|
||||
The fix replaces `find()` with `findOneByCustomer()` to scope the lookup to the current customer:
|
||||
|
||||
```diff
|
||||
- $address = $this->addressRepository->find($addressId);
|
||||
+ $customer = $this->customerContext->getCustomer();
|
||||
+ if (!$customer instanceof CustomerInterface) {
|
||||
+ return;
|
||||
+ }
|
||||
+ $address = $this->addressRepository->findOneByCustomer((string) $addressId, $customer);
|
||||
+ if (null === $address) {
|
||||
+ return;
|
||||
+ }
|
||||
```
|
||||
|
||||
Additionally, `SummaryComponent::refreshCart()` and `WidgetComponent::refreshCart()` no longer accept an external
|
||||
`$cartId` argument — they use the internally held cart reference instead, preventing cart ID manipulation.
|
||||
|
||||
**No action required** — this fix is applied automatically upon updating. If you have extended or overridden
|
||||
`Checkout\Address\FormComponent`, `Cart\SummaryComponent`, or `Cart\WidgetComponent`, review your customizations
|
||||
to ensure they do not expose similar IDOR vectors.
|
||||
|
||||
### [API] Cart authorization bypass (High)
|
||||
|
||||
An authorization bypass has been fixed in `AddItemToCartHandler`. Previously, any authenticated user could add items
|
||||
to another user's cart by providing a different cart token in the API request.
|
||||
|
||||
The handler now validates that the current user has access to the cart before modifying it:
|
||||
|
||||
```diff
|
||||
final readonly class AddItemToCartHandler
|
||||
{
|
||||
public function __construct(
|
||||
// ...
|
||||
+ private ?UserContextInterface $userContext = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(AddItemToCart $addItemToCart): OrderInterface
|
||||
{
|
||||
// ...
|
||||
+ $this->assertCartAccessible($cart);
|
||||
// ...
|
||||
}
|
||||
+
|
||||
+ private function assertCartAccessible(OrderInterface $cart): void
|
||||
+ {
|
||||
+ // Validates current user matches cart owner
|
||||
+ // Guest carts remain accessible
|
||||
+ // Throws NotFoundHttpException if access denied
|
||||
+ }
|
||||
}
|
||||
```
|
||||
|
||||
The `UserContextInterface` service is now injected into the handler via `command_handlers.xml`.
|
||||
|
||||
**No action required** — this fix is applied automatically upon updating. If you have overridden the
|
||||
`AddItemToCartHandler` or its service definition, ensure the new `UserContextInterface` argument is included.
|
||||
|
||||
### Open Redirect vulnerability (Medium)
|
||||
|
||||
An open redirect vulnerability has been fixed in `CurrencySwitchController`, `ImpersonateUserController` and
|
||||
`StorageBasedLocaleSwitcher`. These controllers no longer use the HTTP `Referer` header for redirects. Instead,
|
||||
they use the `RouterInterface` to generate a redirect URL based on the `_sylius.redirect` route attribute
|
||||
(defaulting to `sylius_shop_homepage`).
|
||||
|
||||
A new trait has been added: `Sylius\Bundle\ShopBundle\Controller\RedirectTrait`.
|
||||
|
||||
**Affected classes:**
|
||||
|
||||
- `Sylius\Bundle\ShopBundle\Controller\CurrencySwitchController`
|
||||
- `Sylius\Bundle\AdminBundle\Controller\ImpersonateUserController`
|
||||
- `Sylius\Bundle\ShopBundle\Locale\StorageBasedLocaleSwitcher`
|
||||
|
||||
If your application relied on the Referer-based redirect behavior, you can customize the redirect target
|
||||
by overriding the route definition:
|
||||
|
||||
```yaml
|
||||
# config/routes/sylius_shop.yaml
|
||||
sylius_shop_switch_currency:
|
||||
path: /{_locale}/switch-currency/{code}
|
||||
methods: [GET]
|
||||
defaults:
|
||||
_controller: sylius.controller.shop.currency_switch:switchAction
|
||||
_sylius:
|
||||
redirect: sylius_shop_homepage # or any route name you prefer
|
||||
redirect: sylius_shop_homepage
|
||||
```
|
||||
|
||||
## Other changes
|
||||
|
||||
### Fix too frequent/long requests to GUS
|
||||
|
||||
The `Sylius\Bundle\AdminBundle\Controller\NotificationController` has been updated to reduce the frequency and
|
||||
duration of outbound requests to GUS.
|
||||
|
||||
1. The constructor of `NotificationController` has been modified:
|
||||
|
||||
```diff
|
||||
public function __construct(
|
||||
private ClientInterface $client,
|
||||
private MessageFactory $messageFactory,
|
||||
string $hubUri,
|
||||
private string $environment,
|
||||
+ private CacheItemPoolInterface $cache,
|
||||
)
|
||||
```
|
||||
|
||||
The `cache.app` service is injected as the new argument.
|
||||
|
||||
2. Responses are now **cached for 24 hours** (`TTL = 86400s`) using PSR-6 `CacheItemPoolInterface`.
|
||||
Subsequent calls to `getVersionAction()` return the cached result without making an HTTP request.
|
||||
|
||||
3. HTTP request timeouts have been added:
|
||||
|
||||
```diff
|
||||
-$hubResponse = $this->client->send($hubRequest, ['verify' => false]);
|
||||
+$hubResponse = $this->client->send($hubRequest, [
|
||||
+ 'verify' => false,
|
||||
+ 'timeout' => 2,
|
||||
+ 'connect_timeout' => 1,
|
||||
+]);
|
||||
```
|
||||
|
||||
If you have overridden the `sylius.controller.admin.notification` service or its arguments, update your
|
||||
configuration to include the new `CacheItemPoolInterface` argument.
|
||||
|
||||
# UPGRADE FROM `2.1.8` TO `2.1.9`
|
||||
|
||||
## Telemetry
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ final class Configuration implements ConfigurationInterface
|
|||
->booleanNode('plugins')->defaultTrue()->end()
|
||||
->scalarNode('salt')->defaultNull()->end()
|
||||
->scalarNode('url')->defaultValue('https://prism.sylius.com/telemetry')->end()
|
||||
->integerNode('query_timeout')->defaultValue(60000)->min(1000)->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ final class SyliusCoreExtension extends AbstractResourceExtension implements Pre
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array{enabled: bool, business: bool, technical: bool, plugins: bool, salt: string|null, url: string} $telemetryConfig
|
||||
* @param array{enabled: bool, business: bool, technical: bool, plugins: bool, salt: string|null, url: string, query_timeout: int} $telemetryConfig
|
||||
*/
|
||||
private function configureTelemetry(array $telemetryConfig, ContainerBuilder $container, XmlFileLoader $loader): void
|
||||
{
|
||||
|
|
@ -228,6 +228,12 @@ final class SyliusCoreExtension extends AbstractResourceExtension implements Pre
|
|||
$container->setParameter('sylius_core.telemetry.enabled', $telemetryConfig['enabled']);
|
||||
$container->setParameter('sylius_core.telemetry.salt', $telemetrySalt);
|
||||
$container->setParameter('sylius_core.telemetry.url', $telemetryConfig['url']);
|
||||
$queryTimeout = $telemetryConfig['query_timeout'];
|
||||
$envQueryTimeout = $_ENV['SYLIUS_TELEMETRY_QUERY_TIMEOUT'] ?? $_SERVER['SYLIUS_TELEMETRY_QUERY_TIMEOUT'] ?? getenv('SYLIUS_TELEMETRY_QUERY_TIMEOUT');
|
||||
if ($envQueryTimeout !== false) {
|
||||
$queryTimeout = max(1000, (int) $envQueryTimeout);
|
||||
}
|
||||
$container->setParameter('sylius_core.telemetry.query_timeout', $queryTimeout);
|
||||
|
||||
$businessEnabled = $this->isTelemetryCategoryEnabled($telemetryConfig['business'], 'SYLIUS_TELEMETRY_BUSINESS');
|
||||
$technicalEnabled = $this->isTelemetryCategoryEnabled($telemetryConfig['technical'], 'SYLIUS_TELEMETRY_TECHNICAL');
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@
|
|||
|
||||
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
|
||||
<services>
|
||||
<service
|
||||
id="sylius.telemetry.query.timeout_runner"
|
||||
class="Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner"
|
||||
>
|
||||
<argument>%sylius_core.telemetry.query_timeout%</argument>
|
||||
</service>
|
||||
|
||||
<service
|
||||
id="sylius.telemetry.installation_id_generator"
|
||||
class="Sylius\Component\Core\Telemetry\Generator\InstallationIdGenerator"
|
||||
|
|
@ -82,6 +89,7 @@
|
|||
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
|
||||
>
|
||||
<argument type="service" id="doctrine.dbal.default_connection" />
|
||||
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
|
||||
<argument type="string">%locale%</argument>
|
||||
<tag name="sylius.telemetry.business_data_provider" />
|
||||
</service>
|
||||
|
|
@ -92,6 +100,7 @@
|
|||
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
|
||||
>
|
||||
<argument type="service" id="doctrine.dbal.default_connection" />
|
||||
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
|
||||
<tag name="sylius.telemetry.business_data_provider" />
|
||||
</service>
|
||||
|
||||
|
|
@ -101,6 +110,7 @@
|
|||
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
|
||||
>
|
||||
<argument type="service" id="doctrine.dbal.default_connection" />
|
||||
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
|
||||
<tag name="sylius.telemetry.business_data_provider" />
|
||||
</service>
|
||||
|
||||
|
|
@ -110,6 +120,7 @@
|
|||
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
|
||||
>
|
||||
<argument type="service" id="doctrine.dbal.default_connection" />
|
||||
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
|
||||
<tag name="sylius.telemetry.business_data_provider" />
|
||||
</service>
|
||||
|
||||
|
|
@ -119,6 +130,7 @@
|
|||
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
|
||||
>
|
||||
<argument type="service" id="doctrine.dbal.default_connection" />
|
||||
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
|
||||
<tag name="sylius.telemetry.business_data_provider" />
|
||||
</service>
|
||||
|
||||
|
|
@ -128,6 +140,7 @@
|
|||
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
|
||||
>
|
||||
<argument type="service" id="doctrine.dbal.default_connection" />
|
||||
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
|
||||
<tag name="sylius.telemetry.business_data_provider" />
|
||||
</service>
|
||||
|
||||
|
|
@ -137,6 +150,7 @@
|
|||
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
|
||||
>
|
||||
<argument type="service" id="doctrine.dbal.default_connection" />
|
||||
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
|
||||
<tag name="sylius.telemetry.business_data_provider" />
|
||||
</service>
|
||||
|
||||
|
|
@ -183,6 +197,7 @@
|
|||
lazy="true"
|
||||
>
|
||||
<argument type="service" id="sylius.telemetry.send_manager" />
|
||||
<argument type="service" id="sylius.telemetry.cache" />
|
||||
<argument>%sylius.security.api_admin_route%</argument>
|
||||
<tag name="kernel.event_listener" event="kernel.terminate" method="onAdminAccess" priority="-1024" />
|
||||
</service>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Sylius\Bundle\CoreBundle\Telemetry\EventListener;
|
||||
|
||||
use Sylius\Component\Core\Telemetry\Cache\TelemetryCacheInterface;
|
||||
use Sylius\Component\Core\Telemetry\TelemetrySendManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ class TelemetryListener
|
|||
{
|
||||
public function __construct(
|
||||
private TelemetrySendManagerInterface $telemetrySendManager,
|
||||
private TelemetryCacheInterface $telemetryCache,
|
||||
private string $adminApiPrefix,
|
||||
) {
|
||||
}
|
||||
|
|
@ -33,7 +35,12 @@ class TelemetryListener
|
|||
return;
|
||||
}
|
||||
|
||||
if ($this->telemetryCache->wasRecentlyTriggered()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->telemetryCache->markAsRecentlyTriggered();
|
||||
$this->telemetrySendManager->sendIfNeeded($request);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,29 +15,34 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
|||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\CountriesData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
|
||||
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
|
||||
|
||||
/** @internal */
|
||||
final class CountriesDataProvider implements DataProviderInterface
|
||||
{
|
||||
public function __construct(private Connection $connection)
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TimeoutRunner $queryTimeoutRunner,
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(): TelemetryDataInterface
|
||||
{
|
||||
try {
|
||||
$channelsWithCountries = $this->connection->fetchAllAssociative(
|
||||
$channelsWithCountries = $this->queryTimeoutRunner->fetchAllAssociative(
|
||||
$this->connection,
|
||||
'SELECT c.id as channel_id, co.code as country_code
|
||||
FROM sylius_channel c
|
||||
LEFT JOIN sylius_channel_countries cc ON cc.channel_id = c.id
|
||||
LEFT JOIN sylius_country co ON co.id = cc.country_id AND co.enabled = 1
|
||||
WHERE c.enabled = 1',
|
||||
LEFT JOIN sylius_country co ON co.id = cc.country_id AND co.enabled = true
|
||||
WHERE c.enabled = true',
|
||||
);
|
||||
|
||||
$allCountries = $this->connection->fetchFirstColumn(
|
||||
'SELECT code FROM sylius_country WHERE enabled = 1',
|
||||
$allCountries = $this->queryTimeoutRunner->fetchFirstColumn(
|
||||
$this->connection,
|
||||
'SELECT code FROM sylius_country WHERE enabled = true',
|
||||
);
|
||||
|
||||
$countriesByChannel = [];
|
||||
|
|
|
|||
|
|
@ -15,20 +15,26 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
|||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\CurrenciesData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
|
||||
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
|
||||
|
||||
/** @internal */
|
||||
final class CurrenciesDataProvider implements DataProviderInterface
|
||||
{
|
||||
public function __construct(private Connection $connection)
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TimeoutRunner $queryTimeoutRunner,
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(): TelemetryDataInterface
|
||||
{
|
||||
try {
|
||||
$currencies = $this->connection->fetchFirstColumn('SELECT code FROM sylius_currency');
|
||||
$currencies = $this->queryTimeoutRunner->fetchFirstColumn(
|
||||
$this->connection,
|
||||
'SELECT code FROM sylius_currency',
|
||||
);
|
||||
|
||||
return new CurrenciesData($currencies);
|
||||
} catch (\Throwable) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
|||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\LocalesData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
|
||||
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ final class LocalesDataProvider implements DataProviderInterface
|
|||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TimeoutRunner $queryTimeoutRunner,
|
||||
private string $defaultLocale,
|
||||
) {
|
||||
}
|
||||
|
|
@ -30,11 +32,15 @@ final class LocalesDataProvider implements DataProviderInterface
|
|||
public function provide(): TelemetryDataInterface
|
||||
{
|
||||
try {
|
||||
$locales = $this->connection->fetchFirstColumn('SELECT code FROM sylius_locale');
|
||||
$channelDefaultLocales = $this->connection->fetchFirstColumn(
|
||||
$locales = $this->queryTimeoutRunner->fetchFirstColumn(
|
||||
$this->connection,
|
||||
'SELECT code FROM sylius_locale',
|
||||
);
|
||||
$channelDefaultLocales = $this->queryTimeoutRunner->fetchFirstColumn(
|
||||
$this->connection,
|
||||
'SELECT DISTINCT l.code FROM sylius_locale l
|
||||
INNER JOIN sylius_channel c ON c.default_locale_id = l.id
|
||||
WHERE c.enabled = 1',
|
||||
WHERE c.enabled = true',
|
||||
);
|
||||
|
||||
return new LocalesData($locales, $channelDefaultLocales, $this->defaultLocale);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
|||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\MetricsCountsData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
use Sylius\Component\Core\OrderCheckoutStates;
|
||||
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
|
||||
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
|
||||
|
|
@ -23,21 +24,24 @@ use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
|
|||
/** @internal */
|
||||
final class MetricsCountsDataProvider implements DataProviderInterface
|
||||
{
|
||||
public function __construct(private Connection $connection)
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TimeoutRunner $queryTimeoutRunner,
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(): TelemetryDataInterface
|
||||
{
|
||||
try {
|
||||
$counts = $this->connection->fetchAssociative(
|
||||
$counts = $this->queryTimeoutRunner->fetchAssociative(
|
||||
$this->connection,
|
||||
'SELECT
|
||||
(SELECT COUNT(id) FROM sylius_customer) as customers_count,
|
||||
(SELECT COUNT(id) FROM sylius_product) as products_count,
|
||||
(SELECT COUNT(id) FROM sylius_product_variant) as product_variants_count,
|
||||
(SELECT COUNT(id) FROM sylius_product_variant WHERE shipping_required = 0) as virtual_product_variants_count,
|
||||
(SELECT COUNT(id) FROM sylius_product_variant WHERE shipping_required = false) as virtual_product_variants_count,
|
||||
(SELECT COUNT(id) FROM sylius_order WHERE checkout_state = :completedState) as orders_count,
|
||||
(SELECT COUNT(id) FROM sylius_channel WHERE enabled = 1) as channels_count',
|
||||
(SELECT COUNT(id) FROM sylius_channel WHERE enabled = true) as channels_count',
|
||||
[
|
||||
'completedState' => OrderCheckoutStates::STATE_COMPLETED,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
|||
use Doctrine\DBAL\Connection;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\OrderMetricsData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\OrdersBusinessData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
use Sylius\Component\Core\OrderCheckoutStates;
|
||||
use Sylius\Component\Core\OrderPaymentStates;
|
||||
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
|
||||
|
|
@ -25,8 +26,10 @@ use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
|
|||
/** @internal */
|
||||
final class OrdersBusinessDataProvider implements DataProviderInterface
|
||||
{
|
||||
public function __construct(private Connection $connection)
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TimeoutRunner $queryTimeoutRunner,
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(): TelemetryDataInterface
|
||||
|
|
@ -46,7 +49,8 @@ final class OrdersBusinessDataProvider implements DataProviderInterface
|
|||
{
|
||||
$oneMonthAgo = (new \DateTimeImmutable('-1 month'))->format('Y-m-d H:i:s');
|
||||
|
||||
return $this->connection->fetchAllAssociative(
|
||||
return $this->queryTimeoutRunner->fetchAllAssociative(
|
||||
$this->connection,
|
||||
'SELECT
|
||||
o.currency_code,
|
||||
COUNT(o.id) as order_count,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
|||
use Doctrine\DBAL\Connection;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\PaymentMethodsData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\PaymentProviderData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
|
||||
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
|
||||
use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
|
||||
|
|
@ -23,14 +24,17 @@ use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
|
|||
/** @internal */
|
||||
final class PaymentMethodsDataProvider implements DataProviderInterface
|
||||
{
|
||||
public function __construct(private Connection $connection)
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TimeoutRunner $queryTimeoutRunner,
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(): TelemetryDataInterface
|
||||
{
|
||||
try {
|
||||
$results = $this->connection->fetchAllAssociative(
|
||||
$results = $this->queryTimeoutRunner->fetchAllAssociative(
|
||||
$this->connection,
|
||||
'SELECT pm.code, gc.factory_name, pm.is_enabled, COUNT(p.id) as payments_count
|
||||
FROM sylius_payment_method pm
|
||||
JOIN sylius_gateway_config gc ON pm.gateway_config_id = gc.id
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
|||
use Doctrine\DBAL\Connection;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\ShippingMethodsData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\ShippingProviderData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
|
||||
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
|
||||
use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
|
||||
|
|
@ -23,14 +24,17 @@ use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
|
|||
/** @internal */
|
||||
final class ShippingMethodsDataProvider implements DataProviderInterface
|
||||
{
|
||||
public function __construct(private Connection $connection)
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TimeoutRunner $queryTimeoutRunner,
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(): TelemetryDataInterface
|
||||
{
|
||||
try {
|
||||
$results = $this->connection->fetchAllAssociative(
|
||||
$results = $this->queryTimeoutRunner->fetchAllAssociative(
|
||||
$this->connection,
|
||||
'SELECT sm.code, sm.calculator, sm.is_enabled, COUNT(s.id) as shipments_count
|
||||
FROM sylius_shipping_method sm
|
||||
LEFT JOIN sylius_shipment s ON s.method_id = sm.id
|
||||
|
|
|
|||
122
src/Sylius/Bundle/CoreBundle/Telemetry/Query/TimeoutRunner.php
Normal file
122
src/Sylius/Bundle/CoreBundle/Telemetry/Query/TimeoutRunner.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Sylius package.
|
||||
*
|
||||
* (c) Sylius Sp. z o.o.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Sylius\Bundle\CoreBundle\Telemetry\Query;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\MariaDBPlatform;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
|
||||
/** @internal */
|
||||
final class TimeoutRunner
|
||||
{
|
||||
public function __construct(
|
||||
private int $timeoutMs = 60000,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function fetchAllAssociative(Connection $connection, string $sql, array $params = []): array
|
||||
{
|
||||
$sql = $this->applyTimeout($connection, $sql);
|
||||
|
||||
return $this->executeInTimeoutContext($connection, fn () => $connection->fetchAllAssociative($sql, $params));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*
|
||||
* @return array<string, mixed>|false
|
||||
*/
|
||||
public function fetchAssociative(Connection $connection, string $sql, array $params = [])
|
||||
{
|
||||
$sql = $this->applyTimeout($connection, $sql);
|
||||
|
||||
return $this->executeInTimeoutContext($connection, fn () => $connection->fetchAssociative($sql, $params));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*
|
||||
* @return list<mixed>
|
||||
*/
|
||||
public function fetchFirstColumn(Connection $connection, string $sql, array $params = []): array
|
||||
{
|
||||
$sql = $this->applyTimeout($connection, $sql);
|
||||
|
||||
return $this->executeInTimeoutContext($connection, fn () => $connection->fetchFirstColumn($sql, $params));
|
||||
}
|
||||
|
||||
private function applyTimeout(Connection $connection, string $sql): string
|
||||
{
|
||||
$platform = $connection->getDatabasePlatform();
|
||||
|
||||
// MariaDB: SET STATEMENT max_statement_time=X FOR SELECT ... (per-statement, seconds)
|
||||
if ($platform instanceof MariaDBPlatform) {
|
||||
$timeoutSeconds = $this->timeoutMs / 1000;
|
||||
|
||||
return sprintf('SET STATEMENT max_statement_time=%s FOR %s', $timeoutSeconds, $sql);
|
||||
}
|
||||
|
||||
// MySQL: SELECT /*+ MAX_EXECUTION_TIME(X) */ ... (optimizer hint, milliseconds)
|
||||
if ($platform instanceof AbstractMySQLPlatform) {
|
||||
return preg_replace(
|
||||
'/^\s*SELECT\b/i',
|
||||
sprintf('SELECT /*+ MAX_EXECUTION_TIME(%d) */', $this->timeoutMs),
|
||||
$sql,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// PostgreSQL: no per-query mechanism, use SET LOCAL in transaction
|
||||
// Handled in executeInTimeoutContext()
|
||||
return $sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
*
|
||||
* @param callable(): T $callback
|
||||
*
|
||||
* @return T
|
||||
*/
|
||||
private function executeInTimeoutContext(Connection $connection, callable $callback)
|
||||
{
|
||||
$platform = $connection->getDatabasePlatform();
|
||||
|
||||
// PostgreSQL: SET LOCAL only works within a transaction and resets automatically on COMMIT/ROLLBACK
|
||||
if ($platform instanceof PostgreSQLPlatform) {
|
||||
$connection->beginTransaction();
|
||||
|
||||
try {
|
||||
$connection->executeStatement(sprintf("SET LOCAL statement_timeout = '%d'", $this->timeoutMs));
|
||||
$result = $callback();
|
||||
$connection->commit();
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
$connection->rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// MySQL/MariaDB: timeout is embedded in the SQL itself, just execute
|
||||
return $callback();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Sylius package.
|
||||
*
|
||||
* (c) Sylius Sp. z o.o.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Sylius\Bundle\CoreBundle\Tests\Telemetry\Query;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\MariaDBPlatform;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
|
||||
final class TimeoutRunnerTest extends TestCase
|
||||
{
|
||||
public function test_mysql_applies_optimizer_hint(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$connection->expects(self::once())
|
||||
->method('fetchAllAssociative')
|
||||
->with(
|
||||
self::matchesRegularExpression('/SELECT\s+\/\*\+\s+MAX_EXECUTION_TIME\(5000\)\s+\*\//'),
|
||||
[],
|
||||
)
|
||||
->willReturn([['id' => 1]]);
|
||||
|
||||
$runner = new TimeoutRunner(5000);
|
||||
$result = $runner->fetchAllAssociative($connection, 'SELECT id FROM sylius_order');
|
||||
|
||||
self::assertSame([['id' => 1]], $result);
|
||||
}
|
||||
|
||||
public function test_mariadb_applies_set_statement(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('getDatabasePlatform')->willReturn($this->createMock(MariaDBPlatform::class));
|
||||
$connection->expects(self::once())
|
||||
->method('fetchAllAssociative')
|
||||
->with(
|
||||
'SET STATEMENT max_statement_time=5 FOR SELECT id FROM sylius_order',
|
||||
[],
|
||||
)
|
||||
->willReturn([['id' => 1]]);
|
||||
|
||||
$runner = new TimeoutRunner(5000);
|
||||
$result = $runner->fetchAllAssociative($connection, 'SELECT id FROM sylius_order');
|
||||
|
||||
self::assertSame([['id' => 1]], $result);
|
||||
}
|
||||
|
||||
public function test_postgresql_uses_transaction_with_set_local(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('getDatabasePlatform')->willReturn($this->createMock(PostgreSQLPlatform::class));
|
||||
$connection->expects(self::once())->method('beginTransaction');
|
||||
$connection->expects(self::once())
|
||||
->method('executeStatement')
|
||||
->with("SET LOCAL statement_timeout = '5000'");
|
||||
$connection->expects(self::once())
|
||||
->method('fetchAllAssociative')
|
||||
->with('SELECT id FROM sylius_order', [])
|
||||
->willReturn([['id' => 1]]);
|
||||
$connection->expects(self::once())->method('commit');
|
||||
$connection->expects(self::never())->method('rollBack');
|
||||
|
||||
$runner = new TimeoutRunner(5000);
|
||||
$result = $runner->fetchAllAssociative($connection, 'SELECT id FROM sylius_order');
|
||||
|
||||
self::assertSame([['id' => 1]], $result);
|
||||
}
|
||||
|
||||
public function test_postgresql_rolls_back_on_exception(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('getDatabasePlatform')->willReturn($this->createMock(PostgreSQLPlatform::class));
|
||||
$connection->expects(self::once())->method('beginTransaction');
|
||||
$connection->expects(self::once())->method('executeStatement');
|
||||
$connection->expects(self::once())
|
||||
->method('fetchAllAssociative')
|
||||
->willThrowException(new \RuntimeException('query timeout'));
|
||||
$connection->expects(self::never())->method('commit');
|
||||
$connection->expects(self::once())->method('rollBack');
|
||||
|
||||
$runner = new TimeoutRunner(5000);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('query timeout');
|
||||
|
||||
$runner->fetchAllAssociative($connection, 'SELECT id FROM sylius_order');
|
||||
}
|
||||
|
||||
public function test_default_timeout_is_60_seconds(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$connection->expects(self::once())
|
||||
->method('fetchAllAssociative')
|
||||
->with(
|
||||
self::matchesRegularExpression('/MAX_EXECUTION_TIME\(60000\)/'),
|
||||
[],
|
||||
)
|
||||
->willReturn([]);
|
||||
|
||||
$runner = new TimeoutRunner();
|
||||
$runner->fetchAllAssociative($connection, 'SELECT id FROM sylius_order');
|
||||
}
|
||||
|
||||
public function test_fetch_associative_applies_timeout(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$connection->expects(self::once())
|
||||
->method('fetchAssociative')
|
||||
->with(
|
||||
self::matchesRegularExpression('/MAX_EXECUTION_TIME\(3000\)/'),
|
||||
[],
|
||||
)
|
||||
->willReturn(['count' => 42]);
|
||||
|
||||
$runner = new TimeoutRunner(3000);
|
||||
$result = $runner->fetchAssociative($connection, 'SELECT COUNT(*) as count FROM sylius_order');
|
||||
|
||||
self::assertSame(['count' => 42], $result);
|
||||
}
|
||||
|
||||
public function test_fetch_first_column_applies_timeout(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$connection->expects(self::once())
|
||||
->method('fetchFirstColumn')
|
||||
->with(
|
||||
self::matchesRegularExpression('/MAX_EXECUTION_TIME\(3000\)/'),
|
||||
[],
|
||||
)
|
||||
->willReturn(['USD', 'EUR']);
|
||||
|
||||
$runner = new TimeoutRunner(3000);
|
||||
$result = $runner->fetchFirstColumn($connection, 'SELECT code FROM sylius_currency');
|
||||
|
||||
self::assertSame(['USD', 'EUR'], $result);
|
||||
}
|
||||
|
||||
public function test_mariadb_timeout_converts_to_seconds(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('getDatabasePlatform')->willReturn($this->createMock(MariaDBPlatform::class));
|
||||
$connection->expects(self::once())
|
||||
->method('fetchAllAssociative')
|
||||
->with(
|
||||
'SET STATEMENT max_statement_time=2.5 FOR SELECT 1',
|
||||
[],
|
||||
)
|
||||
->willReturn([]);
|
||||
|
||||
$runner = new TimeoutRunner(2500);
|
||||
$runner->fetchAllAssociative($connection, 'SELECT 1');
|
||||
}
|
||||
}
|
||||
|
|
@ -248,6 +248,7 @@ final class ConfigurationTest extends TestCase
|
|||
'technical' => true,
|
||||
'plugins' => true,
|
||||
'url' => 'https://prism.sylius.com/telemetry',
|
||||
'query_timeout' => 60000,
|
||||
],
|
||||
],
|
||||
'telemetry',
|
||||
|
|
@ -272,6 +273,7 @@ final class ConfigurationTest extends TestCase
|
|||
'technical' => true,
|
||||
'plugins' => true,
|
||||
'url' => 'https://prism.sylius.com/telemetry',
|
||||
'query_timeout' => 60000,
|
||||
],
|
||||
],
|
||||
'telemetry',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\EventListener;
|
|||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\EventListener\TelemetryListener;
|
||||
use Sylius\Component\Core\Telemetry\Cache\TelemetryCacheInterface;
|
||||
use Sylius\Component\Core\Telemetry\TelemetrySendManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
@ -25,16 +26,20 @@ final class TelemetryListenerTest extends TestCase
|
|||
{
|
||||
private TelemetrySendManagerInterface $telemetrySendManager;
|
||||
|
||||
private TelemetryCacheInterface $telemetryCache;
|
||||
|
||||
private TelemetryListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->telemetrySendManager = $this->createMock(TelemetrySendManagerInterface::class);
|
||||
$this->listener = new TelemetryListener($this->telemetrySendManager, '/api/v2/admin');
|
||||
$this->telemetryCache = $this->createMock(TelemetryCacheInterface::class);
|
||||
$this->listener = new TelemetryListener($this->telemetrySendManager, $this->telemetryCache, '/api/v2/admin');
|
||||
}
|
||||
|
||||
public function test_it_does_nothing_when_not_admin_route(): void
|
||||
{
|
||||
$this->telemetryCache->expects($this->never())->method('wasRecentlyTriggered');
|
||||
$this->telemetrySendManager->expects($this->never())->method('sendIfNeeded');
|
||||
|
||||
$event = $this->createEvent('sylius_shop_homepage', '/');
|
||||
|
|
@ -44,6 +49,7 @@ final class TelemetryListenerTest extends TestCase
|
|||
|
||||
public function test_it_does_nothing_when_route_is_null_and_path_not_admin(): void
|
||||
{
|
||||
$this->telemetryCache->expects($this->never())->method('wasRecentlyTriggered');
|
||||
$this->telemetrySendManager->expects($this->never())->method('sendIfNeeded');
|
||||
|
||||
$event = $this->createEvent(null, '/shop/products');
|
||||
|
|
@ -53,6 +59,8 @@ final class TelemetryListenerTest extends TestCase
|
|||
|
||||
public function test_it_calls_telemetry_send_manager_on_admin_dashboard_visit(): void
|
||||
{
|
||||
$this->telemetryCache->expects($this->once())->method('wasRecentlyTriggered')->willReturn(false);
|
||||
$this->telemetryCache->expects($this->once())->method('markAsRecentlyTriggered');
|
||||
$this->telemetrySendManager->expects($this->once())->method('sendIfNeeded');
|
||||
|
||||
$event = $this->createEvent('sylius_admin_dashboard', '/admin');
|
||||
|
|
@ -62,6 +70,8 @@ final class TelemetryListenerTest extends TestCase
|
|||
|
||||
public function test_it_calls_telemetry_send_manager_on_admin_order_index(): void
|
||||
{
|
||||
$this->telemetryCache->expects($this->once())->method('wasRecentlyTriggered')->willReturn(false);
|
||||
$this->telemetryCache->expects($this->once())->method('markAsRecentlyTriggered');
|
||||
$this->telemetrySendManager->expects($this->once())->method('sendIfNeeded');
|
||||
|
||||
$event = $this->createEvent('sylius_admin_order_index', '/admin/orders');
|
||||
|
|
@ -71,6 +81,8 @@ final class TelemetryListenerTest extends TestCase
|
|||
|
||||
public function test_it_calls_telemetry_send_manager_on_admin_api_route(): void
|
||||
{
|
||||
$this->telemetryCache->expects($this->once())->method('wasRecentlyTriggered')->willReturn(false);
|
||||
$this->telemetryCache->expects($this->once())->method('markAsRecentlyTriggered');
|
||||
$this->telemetrySendManager->expects($this->once())->method('sendIfNeeded');
|
||||
|
||||
$event = $this->createEvent('api_orders_admin_get_collection', '/api/v2/admin/orders');
|
||||
|
|
@ -80,6 +92,7 @@ final class TelemetryListenerTest extends TestCase
|
|||
|
||||
public function test_it_does_not_trigger_on_shop_api_route(): void
|
||||
{
|
||||
$this->telemetryCache->expects($this->never())->method('wasRecentlyTriggered');
|
||||
$this->telemetrySendManager->expects($this->never())->method('sendIfNeeded');
|
||||
|
||||
$event = $this->createEvent('api_orders_shop_get_collection', '/api/v2/shop/orders');
|
||||
|
|
@ -87,8 +100,21 @@ final class TelemetryListenerTest extends TestCase
|
|||
$this->listener->onAdminAccess($event);
|
||||
}
|
||||
|
||||
public function test_it_skips_when_telemetry_was_recently_triggered(): void
|
||||
{
|
||||
$this->telemetryCache->expects($this->once())->method('wasRecentlyTriggered')->willReturn(true);
|
||||
$this->telemetryCache->expects($this->never())->method('markAsRecentlyTriggered');
|
||||
$this->telemetrySendManager->expects($this->never())->method('sendIfNeeded');
|
||||
|
||||
$event = $this->createEvent('sylius_admin_dashboard', '/admin');
|
||||
|
||||
$this->listener->onAdminAccess($event);
|
||||
}
|
||||
|
||||
public function test_it_handles_exceptions_silently(): void
|
||||
{
|
||||
$this->telemetryCache->expects($this->once())->method('wasRecentlyTriggered')->willReturn(false);
|
||||
$this->telemetryCache->expects($this->once())->method('markAsRecentlyTriggered');
|
||||
$this->telemetrySendManager->method('sendIfNeeded')->willThrowException(new \RuntimeException('Test exception'));
|
||||
|
||||
$event = $this->createEvent('sylius_admin_dashboard', '/admin');
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ declare(strict_types=1);
|
|||
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\CountriesData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\CountriesDataProvider;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
|
||||
final class CountriesDataProviderTest extends TestCase
|
||||
{
|
||||
|
|
@ -27,7 +29,8 @@ final class CountriesDataProviderTest extends TestCase
|
|||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->provider = new CountriesDataProvider($this->connection);
|
||||
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$this->provider = new CountriesDataProvider($this->connection, new TimeoutRunner());
|
||||
}
|
||||
|
||||
public function test_it_provides_countries_from_enabled_channels_with_enabled_countries(): void
|
||||
|
|
@ -35,8 +38,8 @@ final class CountriesDataProviderTest extends TestCase
|
|||
$this->connection->expects(self::once())
|
||||
->method('fetchAllAssociative')
|
||||
->with(self::logicalAnd(
|
||||
self::stringContains('co.enabled = 1'),
|
||||
self::stringContains('c.enabled = 1'),
|
||||
self::stringContains('co.enabled = true'),
|
||||
self::stringContains('c.enabled = true'),
|
||||
))
|
||||
->willReturn([
|
||||
['channel_id' => 1, 'country_code' => 'US'],
|
||||
|
|
@ -46,7 +49,7 @@ final class CountriesDataProviderTest extends TestCase
|
|||
]);
|
||||
$this->connection->expects(self::once())
|
||||
->method('fetchFirstColumn')
|
||||
->with(self::stringContains('WHERE enabled = 1'))
|
||||
->with(self::stringContains('WHERE enabled = true'))
|
||||
->willReturn(['US', 'CA', 'DE', 'FR', 'PL', 'GB']);
|
||||
|
||||
$data = $this->provider->provide();
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ declare(strict_types=1);
|
|||
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\CurrenciesData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\CurrenciesDataProvider;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
|
||||
final class CurrenciesDataProviderTest extends TestCase
|
||||
{
|
||||
|
|
@ -27,7 +29,8 @@ final class CurrenciesDataProviderTest extends TestCase
|
|||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->provider = new CurrenciesDataProvider($this->connection);
|
||||
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$this->provider = new CurrenciesDataProvider($this->connection, new TimeoutRunner());
|
||||
}
|
||||
|
||||
public function test_it_provides_currency_codes(): void
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ declare(strict_types=1);
|
|||
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\LocalesData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\LocalesDataProvider;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
|
||||
final class LocalesDataProviderTest extends TestCase
|
||||
{
|
||||
|
|
@ -29,7 +31,8 @@ final class LocalesDataProviderTest extends TestCase
|
|||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->provider = new LocalesDataProvider($this->connection, self::DEFAULT_LOCALE);
|
||||
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$this->provider = new LocalesDataProvider($this->connection, new TimeoutRunner(), self::DEFAULT_LOCALE);
|
||||
}
|
||||
|
||||
public function test_it_provides_locales_channel_defaults_from_enabled_channels(): void
|
||||
|
|
@ -40,7 +43,7 @@ final class LocalesDataProviderTest extends TestCase
|
|||
if (str_contains($sql, 'sylius_locale') && !str_contains($sql, 'sylius_channel')) {
|
||||
return ['en_US', 'pl_PL', 'de_DE'];
|
||||
}
|
||||
self::assertStringContainsString('c.enabled = 1', $sql);
|
||||
self::assertStringContainsString('c.enabled = true', $sql);
|
||||
|
||||
return ['en_US', 'de_DE'];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ declare(strict_types=1);
|
|||
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\MetricsCountsData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\MetricsCountsDataProvider;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
|
||||
final class MetricsCountsDataProviderTest extends TestCase
|
||||
{
|
||||
|
|
@ -27,7 +29,8 @@ final class MetricsCountsDataProviderTest extends TestCase
|
|||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->provider = new MetricsCountsDataProvider($this->connection);
|
||||
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$this->provider = new MetricsCountsDataProvider($this->connection, new TimeoutRunner());
|
||||
}
|
||||
|
||||
public function test_it_provides_all_counts(): void
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ declare(strict_types=1);
|
|||
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\OrdersBusinessData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\OrdersBusinessDataProvider;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
|
||||
final class OrdersBusinessDataProviderTest extends TestCase
|
||||
{
|
||||
|
|
@ -27,7 +29,8 @@ final class OrdersBusinessDataProviderTest extends TestCase
|
|||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->provider = new OrdersBusinessDataProvider($this->connection);
|
||||
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$this->provider = new OrdersBusinessDataProvider($this->connection, new TimeoutRunner());
|
||||
}
|
||||
|
||||
public function test_it_provides_all_order_metrics_for_single_currency(): void
|
||||
|
|
@ -55,19 +58,18 @@ final class OrdersBusinessDataProviderTest extends TestCase
|
|||
|
||||
public function test_it_provides_all_order_metrics_for_multiple_currencies(): void
|
||||
{
|
||||
// Values in minor units
|
||||
$this->connection->method('fetchAllAssociative')->willReturn([
|
||||
[
|
||||
'currency_code' => 'USD',
|
||||
'order_count' => 100,
|
||||
'gmv' => 3000000, // 30,000 in minor units, AOV = 300
|
||||
'gmv' => 3000000,
|
||||
'avg_items' => 3.0,
|
||||
'avg_units' => 4.5,
|
||||
],
|
||||
[
|
||||
'currency_code' => 'EUR',
|
||||
'order_count' => 50,
|
||||
'gmv' => 1500000, // 15,000 in minor units, AOV = 300
|
||||
'gmv' => 1500000,
|
||||
'avg_items' => 2.5,
|
||||
'avg_units' => 3.5,
|
||||
],
|
||||
|
|
@ -114,12 +116,11 @@ final class OrdersBusinessDataProviderTest extends TestCase
|
|||
|
||||
public function test_it_rounds_averages_correctly(): void
|
||||
{
|
||||
// Values in minor units - 1,500 GMV, 10 orders = 150 AOV
|
||||
$this->connection->method('fetchAllAssociative')->willReturn([
|
||||
[
|
||||
'currency_code' => 'USD',
|
||||
'order_count' => 10,
|
||||
'gmv' => 150000, // 1,500 in minor units
|
||||
'gmv' => 150000,
|
||||
'avg_items' => 2.4,
|
||||
'avg_units' => 3.6,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ declare(strict_types=1);
|
|||
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\PaymentMethodsData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\PaymentMethodsDataProvider;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
|
||||
final class PaymentMethodsDataProviderTest extends TestCase
|
||||
{
|
||||
|
|
@ -27,7 +29,8 @@ final class PaymentMethodsDataProviderTest extends TestCase
|
|||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->provider = new PaymentMethodsDataProvider($this->connection);
|
||||
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$this->provider = new PaymentMethodsDataProvider($this->connection, new TimeoutRunner());
|
||||
}
|
||||
|
||||
public function test_it_provides_payment_providers_assigned_to_channel(): void
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ declare(strict_types=1);
|
|||
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\ShippingMethodsData;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\ShippingMethodsDataProvider;
|
||||
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
|
||||
|
||||
final class ShippingMethodsDataProviderTest extends TestCase
|
||||
{
|
||||
|
|
@ -27,7 +29,8 @@ final class ShippingMethodsDataProviderTest extends TestCase
|
|||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->provider = new ShippingMethodsDataProvider($this->connection);
|
||||
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
|
||||
$this->provider = new ShippingMethodsDataProvider($this->connection, new TimeoutRunner());
|
||||
}
|
||||
|
||||
public function test_it_provides_non_archived_shipping_providers_assigned_to_channel(): void
|
||||
|
|
|
|||
|
|
@ -20,10 +20,14 @@ final class TelemetryCache implements TelemetryCacheInterface
|
|||
{
|
||||
private const CACHE_KEY = 'sylius_telemetry';
|
||||
|
||||
private const TRIGGER_CACHE_KEY = 'sylius_telemetry_recently_triggered';
|
||||
|
||||
private const CACHE_TTL_SUCCESS = 604800; // 7 days
|
||||
|
||||
private const CACHE_TTL_FAILURE = 259200; // 3 days
|
||||
|
||||
private const CACHE_TTL_RECENT_TRIGGER = 3600; // 1 hour
|
||||
|
||||
private const RETRY_DELAY = 86400; // 24 hours between retries
|
||||
|
||||
private const MAX_ATTEMPTS = 3;
|
||||
|
|
@ -89,6 +93,20 @@ final class TelemetryCache implements TelemetryCacheInterface
|
|||
return $data['telemetry_data'] ?? null;
|
||||
}
|
||||
|
||||
public function wasRecentlyTriggered(): bool
|
||||
{
|
||||
return $this->cache->getItem(self::TRIGGER_CACHE_KEY)->isHit();
|
||||
}
|
||||
|
||||
public function markAsRecentlyTriggered(): void
|
||||
{
|
||||
$item = $this->cache->getItem(self::TRIGGER_CACHE_KEY);
|
||||
$item->set(true);
|
||||
$item->expiresAfter(self::CACHE_TTL_RECENT_TRIGGER);
|
||||
|
||||
$this->cache->save($item);
|
||||
}
|
||||
|
||||
public function storeSuccess(string $installationId): void
|
||||
{
|
||||
$item = $this->cache->getItem(self::CACHE_KEY);
|
||||
|
|
@ -127,5 +145,6 @@ final class TelemetryCache implements TelemetryCacheInterface
|
|||
public function clear(): void
|
||||
{
|
||||
$this->cache->deleteItem(self::CACHE_KEY);
|
||||
$this->cache->deleteItem(self::TRIGGER_CACHE_KEY);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ interface TelemetryCacheInterface
|
|||
/** @return array<string, mixed>|null */
|
||||
public function getCachedTelemetryData(): ?array;
|
||||
|
||||
public function wasRecentlyTriggered(): bool;
|
||||
|
||||
public function markAsRecentlyTriggered(): void;
|
||||
|
||||
public function storeSuccess(string $installationId): void;
|
||||
|
||||
/** @param array<string, mixed> $telemetryData */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue