[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:
Kamil Grygierzec 2026-03-18 20:18:56 +01:00 committed by GitHub
commit 3e449b0f38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 763 additions and 54 deletions

View file

@ -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

View file

@ -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()

View file

@ -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');

View file

@ -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>

View file

@ -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) {
}

View file

@ -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 = [];

View file

@ -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) {

View file

@ -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);

View file

@ -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,
],

View file

@ -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,

View file

@ -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

View file

@ -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

View 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();
}
}

View file

@ -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');
}
}

View file

@ -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',

View file

@ -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');

View file

@ -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();

View file

@ -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

View file

@ -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'];
});

View file

@ -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

View file

@ -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,
],

View file

@ -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

View file

@ -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

View file

@ -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);
}
}

View file

@ -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 */