From 865684cd0960f931b7bf7eb67b45afce5341eecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 7 Sep 2026 13:46:15 +0200 Subject: [PATCH] Do not let a failed send break the page With synchronous handling the http call to Meta happens inside the visitor's request, and the SDK throws for any non-200 response, so an expired access token returned a 500 for every page raising an event. Catch and log at error level when dispatching. Routed setups are unaffected, since the handler then runs in the worker. Fixes #16 --- README.md | 11 +- UPGRADE.md | 10 ++ .../DispatchOnCommandBusSubscriber.php | 19 +++- .../DispatchOnCommandBusSubscriberTest.php | 105 ++++++++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/EventSubscriber/DispatchOnCommandBusSubscriberTest.php diff --git a/README.md b/README.md index cf009fc..3c0ee7d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/UPGRADE.md b/UPGRADE.md index 1d56060..8cd08b8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -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 diff --git a/src/EventSubscriber/DispatchOnCommandBusSubscriber.php b/src/EventSubscriber/DispatchOnCommandBusSubscriber.php index 90cbdc9..5b56332 100644 --- a/src/EventSubscriber/DispatchOnCommandBusSubscriber.php +++ b/src/EventSubscriber/DispatchOnCommandBusSubscriber.php @@ -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, + ]); + } } } diff --git a/tests/Unit/EventSubscriber/DispatchOnCommandBusSubscriberTest.php b/tests/Unit/EventSubscriber/DispatchOnCommandBusSubscriberTest.php new file mode 100644 index 0000000..dc9803c --- /dev/null +++ b/tests/Unit/EventSubscriber/DispatchOnCommandBusSubscriberTest.php @@ -0,0 +1,105 @@ +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; + } + }; + } +}