diff --git a/README.md b/README.md index 88bb458..47b9856 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,33 @@ try { } ``` +## Sending events later, e.g. through a queue + +`User` holds the raw email addresses, phone numbers and names until the payload is built, so an `Event` should not be +queued as is. `Event::toPayload()` returns the wire-ready form instead: normalized, hashed, and made of scalars, arrays +and `Pixel` objects only, so it serializes with the PHP serializer or the Symfony serializer without any tricks. Hash at +capture time, send later: + +```php +use Setono\MetaConversionsApi\Client\PayloadClientInterface; + +// at capture time +$payload = $event->toPayload(); +$queue->push($payload); + +// at send time (the client implements PayloadClientInterface) +$client->sendPayload($payload); +``` + +`Pixel::$accessToken` is nullable, so you can queue a payload with token-less pixels and fill the tokens in at send +time: + +```php +foreach ($payload->pixels as $pixel) { + $pixel->accessToken = $accessTokens[$pixel->id]; +} +``` + ## Browser-side tracking with deduplication To get the best match quality Meta recommends sending events both server-side (this SDK) *and* from the browser, using diff --git a/src/Client/Client.php b/src/Client/Client.php index f563e08..ffd3487 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -14,9 +14,10 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Event\Payload; use Setono\MetaConversionsApi\Exception\ClientException; -final class Client implements ClientInterface, LoggerAwareInterface +final class Client implements ClientInterface, PayloadClientInterface, LoggerAwareInterface { private ?HttpClientInterface $httpClient = null; @@ -33,7 +34,12 @@ public function __construct() public function sendEvent(Event $event): void { - if (!$event->hasPixels()) { + $this->sendPayload($event->toPayload()); + } + + public function sendPayload(Payload $payload): void + { + if ([] === $payload->pixels) { $this->logger->error('You are trying to send events to Meta/Facebook, but you haven\'n associated any pixels with your event. This is most likely an error.'); return; @@ -42,16 +48,16 @@ public function sendEvent(Event $event): void $httpClient = $this->getHttpClient(); $requestFactory = $this->getRequestFactory(); - $data = json_encode([$event->getPayload()], \JSON_THROW_ON_ERROR); + $data = json_encode([$payload->data], \JSON_THROW_ON_ERROR); - foreach ($event->pixels as $pixel) { + foreach ($payload->pixels as $pixel) { $body = [ 'access_token' => $pixel->accessToken, 'data' => $data, ]; - if (null !== $event->testEventCode) { - $body['test_event_code'] = $event->testEventCode; + if (null !== $payload->testEventCode) { + $body['test_event_code'] = $payload->testEventCode; } $request = $requestFactory->createRequest( diff --git a/src/Client/PayloadClientInterface.php b/src/Client/PayloadClientInterface.php new file mode 100644 index 0000000..90fe88e --- /dev/null +++ b/src/Client/PayloadClientInterface.php @@ -0,0 +1,21 @@ +pixels; } + /** + * Returns the wire-ready form of this event, i.e. normalized and hashed, which is safe to store or queue + * and can be sent later with PayloadClientInterface::sendPayload() + */ + public function toPayload(): Payload + { + return new Payload($this->eventName, $this->eventId, $this->getPayload(), $this->pixels, $this->testEventCode); + } + /** * @return list */ diff --git a/src/Event/Payload.php b/src/Event/Payload.php new file mode 100644 index 0000000..3cf51aa --- /dev/null +++ b/src/Event/Payload.php @@ -0,0 +1,29 @@ + $data the result of Event::getPayload() + * @param list $pixels the pixels the payload should be sent to + */ + public function __construct( + public readonly string $eventName, + public readonly string $eventId, + public readonly array $data, + public readonly array $pixels, + public readonly ?string $testEventCode = null, + ) { + } +} diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index d69d353..d2fb623 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -18,6 +18,7 @@ use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Event\Payload; use Setono\MetaConversionsApi\Exception\ClientException; use Setono\MetaConversionsApi\Pixel\Pixel; use Setono\MetaConversionsApi\TestLogger; @@ -127,6 +128,84 @@ public function it_discovers_an_http_client_when_none_is_injected(): void self::assertCount(1, $httpClient->requests); } + /** + * @test + */ + public function it_sends_payload(): void + { + $httpClient = new TestHttpClient(); + + $client = new Client(); + $client->setHttpClient($httpClient); + + $payload = new Payload( + Event::EVENT_PURCHASE, + 'event_id', + ['event_name' => 'Purchase', 'event_time' => 1658743659123, 'event_id' => 'event_id', 'action_source' => 'website'], + [new Pixel('pixel_1', 'token_1'), new Pixel('pixel_2', 'token_2')], + 'TEST123', + ); + $client->sendPayload($payload); + + self::assertCount(2, $httpClient->requests); + + [$first, $second] = $httpClient->requests; + self::assertSame('POST', $first->getMethod()); + self::assertSame(sprintf('https://graph.facebook.com/v%s/pixel_1/events', ApiConfig::APIVersion), (string) $first->getUri()); + self::assertSame( + 'access_token=token_1&data=%5B%7B%22event_name%22%3A%22Purchase%22%2C%22event_time%22%3A1658743659123%2C%22event_id%22%3A%22event_id%22%2C%22action_source%22%3A%22website%22%7D%5D&test_event_code=TEST123', + (string) $first->getBody(), + ); + self::assertSame(sprintf('https://graph.facebook.com/v%s/pixel_2/events', ApiConfig::APIVersion), (string) $second->getUri()); + self::assertStringContainsString('access_token=token_2', (string) $second->getBody()); + } + + /** + * @test + */ + public function it_sends_the_same_request_for_an_event_and_its_payload(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventId = 'event_id'; + $event->eventTime = 1658743659123; + $event->testEventCode = 'TEST123'; + $event->pixels[] = new Pixel('pixel_id', 'access_token'); + $event->userData->email[] = 'johndoe@example.com'; + + $eventHttpClient = new TestHttpClient(); + $eventClient = new Client(); + $eventClient->setHttpClient($eventHttpClient); + $eventClient->sendEvent($event); + + $payloadHttpClient = new TestHttpClient(); + $payloadClient = new Client(); + $payloadClient->setHttpClient($payloadHttpClient); + $payloadClient->sendPayload($event->toPayload()); + + self::assertCount(1, $eventHttpClient->requests); + self::assertCount(1, $payloadHttpClient->requests); + self::assertSame((string) $eventHttpClient->requests[0]->getUri(), (string) $payloadHttpClient->requests[0]->getUri()); + self::assertSame((string) $eventHttpClient->requests[0]->getBody(), (string) $payloadHttpClient->requests[0]->getBody()); + } + + /** + * @test + */ + public function it_does_not_send_payload_when_it_has_no_pixels(): void + { + $httpClient = new TestHttpClient(); + $logger = new TestLogger(); + + $client = new Client(); + $client->setHttpClient($httpClient); + $client->setLogger($logger); + + $client->sendPayload(new Payload(Event::EVENT_PURCHASE, 'event_id', [], [])); + + self::assertCount(0, $httpClient->requests); + self::assertTrue($logger->hasMessageMatching('#you haven\'n associated any pixels#')); + } + /** * @test */ diff --git a/tests/Event/EventTest.php b/tests/Event/EventTest.php index 27eeb16..b47e141 100644 --- a/tests/Event/EventTest.php +++ b/tests/Event/EventTest.php @@ -223,4 +223,34 @@ public function it_rejects_an_invalid_action_source(): void $event->getPayload(); } + + /** + * @test + */ + public function it_converts_to_a_payload(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventId = 'event_id'; + $event->eventTime = 123; + $event->testEventCode = 'TEST123'; + $event->pixels[] = new Pixel('pixel_1', 'token_1'); + $event->pixels[] = new Pixel('pixel_2'); + $event->userData->email[] = 'johndoe@example.com'; + + $payload = $event->toPayload(); + + self::assertSame(Event::EVENT_PURCHASE, $payload->eventName); + self::assertSame('event_id', $payload->eventId); + self::assertSame('TEST123', $payload->testEventCode); + self::assertSame($event->pixels, $payload->pixels); + self::assertSame($event->getPayload(), $payload->data); + + // the name and id are duplicated from the data on purpose + self::assertSame($payload->eventName, $payload->data['event_name']); + self::assertSame($payload->eventId, $payload->data['event_id']); + + // the personal data is hashed, i.e. the payload is safe to store + self::assertSame(['em' => ['55e79200c1635b37ad31a378c39feb12f120f116625093a19bc32fff15041149']], $payload->data['user_data']); + self::assertStringNotContainsString('johndoe@example.com', serialize($payload)); + } } diff --git a/tests/Event/PayloadTest.php b/tests/Event/PayloadTest.php new file mode 100644 index 0000000..1ddeb59 --- /dev/null +++ b/tests/Event/PayloadTest.php @@ -0,0 +1,51 @@ + 'Purchase', 'event_id' => 'event_id', 'user_data' => ['em' => ['hashed']]], + [new Pixel('pixel_1', 'token_1'), new Pixel('pixel_2')], + 'TEST123', + ); + + $unserialized = unserialize(serialize($payload)); + + self::assertInstanceOf(Payload::class, $unserialized); + self::assertNotSame($payload, $unserialized); + self::assertEquals($payload, $unserialized); + self::assertSame('Purchase', $unserialized->eventName); + self::assertSame('event_id', $unserialized->eventId); + self::assertSame(['event_name' => 'Purchase', 'event_id' => 'event_id', 'user_data' => ['em' => ['hashed']]], $unserialized->data); + self::assertSame('TEST123', $unserialized->testEventCode); + self::assertCount(2, $unserialized->pixels); + self::assertSame('pixel_1', $unserialized->pixels[0]->id); + self::assertSame('token_1', $unserialized->pixels[0]->accessToken); + self::assertSame('pixel_2', $unserialized->pixels[1]->id); + self::assertNull($unserialized->pixels[1]->accessToken); + } + + /** + * @test + */ + public function it_has_no_test_event_code_by_default(): void + { + $payload = new Payload(Event::EVENT_PURCHASE, 'event_id', [], []); + + self::assertNull($payload->testEventCode); + self::assertSame([], $payload->pixels); + } +}