diff --git a/README.md b/README.md index 5f93b48..f2ce4e2 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,23 @@ final class AddCustomerToConversionsApiEvent You can also replace a step instead of adding to it: alias `PixelProviderInterface`, `FbpContextInterface` or `FbcContextInterface` to your own service, or register a listener above the corresponding populate priority. +### Events that are not raised in a browser request + +The pipeline assumes the event belongs to the request being handled. `PopulateRequestPropertiesSubscriber` therefore +fills in the source url, client ip and user agent of the current request, and the bot and user agent filters only +apply to events whose `actionSource` is `website` (the default). + +For an event raised from a console command, a message handler or an incoming webhook, set another action source so +the filters leave it alone: + +```php +$event = new Event(Event::EVENT_PURCHASE, Event::ACTION_SOURCE_SYSTEM_GENERATED); +``` + +If such an event is raised while handling an HTTP request, for instance a webhook from your payment provider, the +request properties still describe *that* request, not the customer. Overwrite them in a listener above +`PRIORITY_POPULATE` when they matter. + ## Graph API version Events are posted to the Graph API version of the installed `facebook/php-business-sdk` package (the SDK reads diff --git a/src/EventSubscriber/FilterBotsSubscriber.php b/src/EventSubscriber/FilterBotsSubscriber.php index a407055..f3b629d 100644 --- a/src/EventSubscriber/FilterBotsSubscriber.php +++ b/src/EventSubscriber/FilterBotsSubscriber.php @@ -5,6 +5,7 @@ namespace Setono\MetaConversionsApiBundle\EventSubscriber; use Setono\BotDetectionBundle\BotDetector\BotDetectorInterface; +use Setono\MetaConversionsApi\Event\Event; use Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -23,6 +24,12 @@ public static function getSubscribedEvents(): array public function filter(ConversionsApiEventRaised $event): void { + // A bot check is about the visitor behind the current request, which says nothing about an event raised + // from a console command or a message handler + if (Event::ACTION_SOURCE_WEBSITE !== $event->event->actionSource) { + return; + } + if ($this->botDetector->isBotRequest()) { $event->stopPropagation(); } diff --git a/src/EventSubscriber/FilterEmptyUserAgentSubscriber.php b/src/EventSubscriber/FilterEmptyUserAgentSubscriber.php index 7abec51..6039dd1 100644 --- a/src/EventSubscriber/FilterEmptyUserAgentSubscriber.php +++ b/src/EventSubscriber/FilterEmptyUserAgentSubscriber.php @@ -4,6 +4,7 @@ namespace Setono\MetaConversionsApiBundle\EventSubscriber; +use Setono\MetaConversionsApi\Event\Event; use Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -18,7 +19,15 @@ public static function getSubscribedEvents(): array public function filter(ConversionsApiEventRaised $event): void { - if (null === $event->event->userData->clientUserAgent || '' === $event->event->userData->clientUserAgent) { + // Meta only expects a client user agent for website events. An event raised from a console command, a + // message handler or a webhook legitimately has none, and dropping those would make the other action + // sources the SDK supports unusable + if (Event::ACTION_SOURCE_WEBSITE !== $event->event->actionSource) { + return; + } + + $userAgent = $event->event->userData->clientUserAgent; + if (null === $userAgent || '' === $userAgent) { $event->stopPropagation(); } } diff --git a/tests/Unit/EventSubscriber/FilterBotsSubscriberTest.php b/tests/Unit/EventSubscriber/FilterBotsSubscriberTest.php index 53e9c68..1b7ce24 100644 --- a/tests/Unit/EventSubscriber/FilterBotsSubscriberTest.php +++ b/tests/Unit/EventSubscriber/FilterBotsSubscriberTest.php @@ -61,6 +61,20 @@ static function () use (&$enriched): void { self::assertFalse($enriched); } + /** + * An event raised from a console command or a message handler is not a request, so the bot check does not + * apply to it + */ + #[Test] + public function it_does_not_stop_a_non_website_event(): void + { + $event = new ConversionsApiEventRaised(new Event(Event::EVENT_PURCHASE, Event::ACTION_SOURCE_SYSTEM_GENERATED)); + + (new FilterBotsSubscriber(self::botDetector(true)))->filter($event); + + self::assertFalse($event->isPropagationStopped()); + } + private static function botDetector(bool $isBot): BotDetectorInterface { return new class($isBot) implements BotDetectorInterface { diff --git a/tests/Unit/EventSubscriber/FilterEmptyUserAgentSubscriberTest.php b/tests/Unit/EventSubscriber/FilterEmptyUserAgentSubscriberTest.php new file mode 100644 index 0000000..8fb9921 --- /dev/null +++ b/tests/Unit/EventSubscriber/FilterEmptyUserAgentSubscriberTest.php @@ -0,0 +1,80 @@ +filter($event); + + self::assertTrue($event->isPropagationStopped()); + } + + #[Test] + public function it_stops_a_website_event_with_an_empty_user_agent(): void + { + $metaEvent = new Event(Event::EVENT_VIEW_CONTENT); + $metaEvent->userData->clientUserAgent = ''; + + $event = new ConversionsApiEventRaised($metaEvent); + + (new FilterEmptyUserAgentSubscriber())->filter($event); + + self::assertTrue($event->isPropagationStopped()); + } + + #[Test] + public function it_does_not_stop_a_website_event_with_a_user_agent(): void + { + $metaEvent = new Event(Event::EVENT_VIEW_CONTENT); + $metaEvent->userData->clientUserAgent = 'Chrome'; + + $event = new ConversionsApiEventRaised($metaEvent); + + (new FilterEmptyUserAgentSubscriber())->filter($event); + + self::assertFalse($event->isPropagationStopped()); + } + + /** + * Events raised from a console command, a message handler or a webhook have no user agent by definition + */ + #[Test] + #[DataProvider('nonWebsiteActionSources')] + public function it_does_not_stop_a_non_website_event(string $actionSource): void + { + $event = new ConversionsApiEventRaised(new Event(Event::EVENT_PURCHASE, $actionSource)); + + (new FilterEmptyUserAgentSubscriber())->filter($event); + + self::assertFalse($event->isPropagationStopped()); + } + + /** + * @return iterable + */ + public static function nonWebsiteActionSources(): iterable + { + yield 'system generated' => [Event::ACTION_SOURCE_SYSTEM_GENERATED]; + yield 'physical store' => [Event::ACTION_SOURCE_PHYSICAL_STORE]; + yield 'email' => [Event::ACTION_SOURCE_EMAIL]; + yield 'phone call' => [Event::ACTION_SOURCE_PHONE_CALL]; + yield 'chat' => [Event::ACTION_SOURCE_CHAT]; + yield 'other' => [Event::ACTION_SOURCE_OTHER]; + } +}