Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,15 @@ setono_meta_conversions_api:
user_agent: []
```

### Route the command to a transport

Server side events are dispatched on your application's default Messenger bus. The bundle does not register a bus of
its own, so your bus configuration is left untouched. Point the bundle at another bus with the `server_side.message_bus`
option if you prefer.

Events are handled synchronously unless you route the command to a transport, which is the recommended setup:
**Route the command to an async transport.** Without it, Messenger handles the command synchronously, which means the
http call to Meta happens inside the visitor's request: their page waits for Meta's round trip, and Meta's
availability becomes your availability.

```yaml
# config/packages/messenger.yaml
Expand All @@ -118,6 +122,11 @@ framework:
'Setono\MetaConversionsApiBundle\Message\Command\SendEvent': async
```

With a transport, Messenger also retries a failed send and moves it to the failure transport when it keeps failing.

Either way, a send that fails is logged as an error and never propagates into the response, so an expired access
token or an outage at Meta cannot break the page.

## Usage

```php
Expand Down
10 changes: 10 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ anything to `framework.messenger`. `SendEvent` is dispatched on your application
`?ConsentContextInterface $consentContext` and `bool $consentEnabled` / `bool $clientSideEnabled` /
`bool $serverSideEnabled` arguments. Adapt subclasses, decorators and custom service definitions.

## Failures no longer propagate

`DispatchOnCommandBusSubscriber` catches and logs anything thrown while dispatching, at error level on the
`setono_meta_conversions_api` channel. Previously a synchronously handled command let a `ClientException` from the SDK
propagate out of `EventDispatcher::dispatch()` into the controller, so an expired access token or an outage at Meta
returned a 500 to the visitor.

This only affects the synchronous path and transport failures. Once the command is routed to a working transport, the
handler runs in the worker and Messenger's retry and failure handling is untouched.

## Event pipeline

`ConversionsApiEventRaised` now carries `PRIORITY_POPULATE`, `PRIORITY_FILTER`, `PRIORITY_ENRICH` and
Expand Down
19 changes: 18 additions & 1 deletion src/EventSubscriber/DispatchOnCommandBusSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ public function dispatch(ConversionsApiEventRaised $event): void
return;
}

$this->commandBus->dispatch(new SendEvent($event->event));
try {
$this->commandBus->dispatch(new SendEvent($event->event));
} catch (\Throwable $e) {
// Tracking must never take the page down. Two things can throw here:
//
// 1. The command is handled synchronously, i.e. it is not routed to a transport, and Meta answered with
// an error. An expired access token would otherwise break every page that raises an event.
// 2. The command is routed to a transport and the transport itself is unavailable.
//
// Neither is reachable once the command is routed to a working transport, so a routed setup keeps
// Messenger's retry and failure handling untouched
$this->logger->error('The event {event_name} ({event_id}) could not be sent to Meta: {message}', [
'event_name' => $event->event->eventName,
'event_id' => $event->event->eventId,
'message' => $e->getMessage(),
'exception' => $e,
]);
}
}
}
105 changes: 105 additions & 0 deletions tests/Unit/EventSubscriber/DispatchOnCommandBusSubscriberTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApiBundle\Tests\Unit\EventSubscriber;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Setono\MetaConversionsApi\Event\Event;
use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface;
use Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised;
use Setono\MetaConversionsApiBundle\EventSubscriber\DispatchOnCommandBusSubscriber;
use Setono\MetaConversionsApiBundle\Message\Command\SendEvent;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;

#[CoversClass(DispatchOnCommandBusSubscriber::class)]
final class DispatchOnCommandBusSubscriberTest extends TestCase
{
#[Test]
public function it_dispatches_the_command(): void
{
$metaEvent = new Event(Event::EVENT_VIEW_CONTENT);

$dispatched = [];
$bus = self::bus(static function (object $message) use (&$dispatched): void {
$dispatched[] = $message;
});

(new DispatchOnCommandBusSubscriber($bus, self::consentChecker(true)))
->dispatch(new ConversionsApiEventRaised($metaEvent));

self::assertCount(1, $dispatched);
self::assertInstanceOf(SendEvent::class, $dispatched[0]);
self::assertSame($metaEvent, $dispatched[0]->event);
}

#[Test]
public function it_does_not_dispatch_without_consent(): void
{
$dispatched = [];
$bus = self::bus(static function (object $message) use (&$dispatched): void {
$dispatched[] = $message;
});

(new DispatchOnCommandBusSubscriber($bus, self::consentChecker(false)))
->dispatch(new ConversionsApiEventRaised(new Event(Event::EVENT_VIEW_CONTENT)));

self::assertSame([], $dispatched);
}

/**
* With synchronous handling the http call to Meta happens inside the visitor's request. An expired access
* token must not take the page down
*/
#[Test]
public function it_does_not_let_a_failure_escape_into_the_request(): void
{
$bus = self::bus(static function (): void {
throw new \RuntimeException('Invalid OAuth access token');
});

$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())->method('error')->with(self::stringContains('could not be sent to Meta'));

(new DispatchOnCommandBusSubscriber($bus, self::consentChecker(true), $logger))
->dispatch(new ConversionsApiEventRaised(new Event(Event::EVENT_VIEW_CONTENT)));
}

private static function bus(callable $onDispatch): MessageBusInterface
{
return new class($onDispatch) implements MessageBusInterface {
/** @var callable */
private $onDispatch;

public function __construct(callable $onDispatch)
{
$this->onDispatch = $onDispatch;
}

public function dispatch(object $message, array $stamps = []): Envelope
{
($this->onDispatch)($message);

return new Envelope($message);
}
};
}

private static function consentChecker(bool $granted): ConsentCheckerInterface
{
return new class($granted) implements ConsentCheckerInterface {
public function __construct(private readonly bool $granted)
{
}

public function isGranted(): bool
{
return $this->granted;
}
};
}
}
Loading