Skip to content
Open
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions src/Client/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions src/Client/PayloadClientInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApi\Client;

use Setono\MetaConversionsApi\Event\Payload;
use Setono\MetaConversionsApi\Exception\ClientException;

/**
* Implement this interface in a client that is able to send a prepared payload, i.e. the wire-ready form of an event,
* to a Meta/Facebook endpoint. Use it when the personal data is hashed at capture time and the event is sent later,
* for instance through a queue
*/
interface PayloadClientInterface
{
/**
* @throws ClientException if the request failed in any way
*/
public function sendPayload(Payload $payload): void;
}
9 changes: 9 additions & 0 deletions src/Event/Event.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ public function hasPixels(): bool
return [] !== $this->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<string>
*/
Expand Down
29 changes: 29 additions & 0 deletions src/Event/Payload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApi\Event;

use Setono\MetaConversionsApi\Pixel\Pixel;

/**
* The wire-ready form of an Event: normalized, hashed, and therefore safe to store or queue.
* Everything in here is a scalar, an array or a Pixel, so it serializes without any tricks
*
* @see Event::toPayload()
*/
final class Payload
{
/**
* @param array<string, mixed> $data the result of Event::getPayload()
* @param list<Pixel> $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,
) {
}
}
79 changes: 79 additions & 0 deletions tests/Client/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down
30 changes: 30 additions & 0 deletions tests/Event/EventTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
51 changes: 51 additions & 0 deletions tests/Event/PayloadTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApi\Event;

use PHPUnit\Framework\TestCase;
use Setono\MetaConversionsApi\Pixel\Pixel;

final class PayloadTest extends TestCase
{
/**
* @test
*/
public function it_round_trips_through_the_php_serializer(): void
{
$payload = new Payload(
Event::EVENT_PURCHASE,
'event_id',
['event_name' => '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);
}
}
Loading