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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ So to add a field: add the public property, map it in `getMapping()`, and regist

**`Parameters` subclasses:** `Event` (the aggregate root — holds `User $userData`, `Custom $customData`, a list of `Pixel`, plus `metadata` for app-internal use that is never sent), `User` (customer matching data), `Custom` (event-specific data like value/currency/contents), `Content` (a single item in `Custom::$contents`). `Event` auto-generates `eventId` (random, for [deduplication](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event#event-id)) and `eventTime` in its constructor. `Event` is intentionally **not** `final` so consumers can subclass it into domain-specific events; the other data objects are `final`.

**`Client` (`src/Client/Client.php`)** — `sendEvent()` delegates to `sendPreparedEvent($event->prepare())`. `Event::prepare()` returns a `PreparedEvent` (`src/Event/PreparedEvent.php`): the `getPayload()` array plus the delivery information (event name and id, pixels, test event code), made of scalars/arrays/`Pixel` only so consumers can hash at capture time and queue it. The pixels are cloned so it is a snapshot, and `withoutAccessTokens()`/`withAccessTokens()` (immutable) keep the tokens out of the queue and restore them by pixel id before sending. `sendPreparedEvent()` first rejects pixels without an access token with a `ClientException`, before any request, so an event is never delivered to only some of its pixels (Meta's own error for a missing token does not mention the token). It then POSTs the payload (form-encoded) to `graph.facebook.com/v{ApiConfig::APIVersion}/{pixelId}/events` once per pixel (each pixel carries its own access token). Non-200 responses throw `ClientException` built from `ErrorResponse`. HTTP is fully PSR-based: PSR-18 client and PSR-17 factories are auto-discovered via `php-http/discovery` but can be injected with `setHttpClient()` / `setRequestFactory()` / etc. The client is `LoggerAware` and defaults to `NullLogger`.
**`Client` (`src/Client/Client.php`)** — `sendEvent()` delegates to `sendPreparedEvent($event->prepare())`. `Event::prepare()` returns a `PreparedEvent` (`src/Event/PreparedEvent.php`): the `getPayload()` array plus the delivery information (event name and id, pixels, test event code), made of scalars/arrays/`Pixel` only so consumers can hash at capture time and queue it. The pixels are cloned so it is a snapshot, and `withoutAccessTokens()`/`withAccessTokens()` (immutable) keep the tokens out of the queue and restore them by pixel id before sending. `sendPreparedEvent()` first rejects pixels without an access token with an `InvalidArgumentException`, before any request, so an event is never delivered to only some of its pixels (Meta's own error for a missing token does not mention the token). It then POSTs the payload (form-encoded) to `graph.facebook.com/v{ApiConfig::APIVersion}/{pixelId}/events` once per pixel (each pixel carries its own access token). A failure of the HTTP client is wrapped in a `TransportException`, and a non-200 response throws a `ResponseException` carrying the status code, the raw body and, when the body is in Meta's error format, the parsed `ErrorResponse`. HTTP is fully PSR-based: PSR-18 client and PSR-17 factories are auto-discovered via `php-http/discovery` but can be injected with `setHttpClient()` / `setRequestFactory()` / etc. The client is `LoggerAware` and defaults to `NullLogger`.

**Exceptions (`src/Exception/`)** — everything the SDK throws implements `ExceptionInterface`. There are three concrete classes, one per thing a caller can do: `InvalidArgumentException` (extends SPL's; the caller's fault, never retry: bad cookie values, invalid event data, pixels without an access token, an unencodable payload), `TransportException` (no response; retry) and `ResponseException` (non-200; decide from `statusCode`/`errorResponse`). To keep that promise, never throw SPL exceptions or use `Webmozart\Assert\Assert` directly in `src/`: use `Setono\MetaConversionsApi\Assert`, an internal subclass whose failures throw the SDK's `InvalidArgumentException`, and wrap third-party exceptions (the Facebook `Normalizer`, PSR-18, `\JsonException`). `FbqGenerator` is the one place that does not throw: its output goes straight into a page, so it logs and returns an empty string when the data cannot be encoded.

**`FbqGenerator` (`src/Generator/FbqGenerator.php`)** — the client-side counterpart. Generates the `fbq('init', ...)` / `fbq('track', ...)` JavaScript snippets, using the browser-context payload and reusing the same `eventId` so server and browser events deduplicate. `Event::isCustom()` decides between `track` and `trackCustom`.

Expand Down
43 changes: 34 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,42 @@ $event->testEventCode = 'TEST12345';

### Error handling

`sendEvent()` and `sendPreparedEvent()` throw a `ClientException` if Meta returns a non-2xx response. The message
contains Meta's error message, code, trace id and the raw response (including the user-facing explanation when Meta
provides one). They also throw it, without making any request, if one of the pixels has no access token:
Everything the SDK throws implements `Setono\MetaConversionsApi\Exception\ExceptionInterface`, so you can catch it all in
one place. There are three concrete exceptions, one for each thing you can do about a failure:

| Exception | Thrown when | What to do |
|---|---|---|
| `InvalidArgumentException` | The SDK is given something it cannot work with: a pixel without an access token, event data Meta does not accept, a payload that cannot be encoded, a cookie value in the wrong format. Always thrown before any request is made | Fix the input. Retrying will not help |
| `TransportException` | The request never got a response, e.g. a network error or a timeout. The exception from your HTTP client is the previous exception | Retry |
| `ResponseException` | Meta, or a proxy in between, answered with anything but a 200 | Decide from `$e->statusCode` and `$e->errorResponse` |

`ResponseException::$errorResponse` is the error Meta reported, with its `message`, `code`, `subcode`, `type`, `traceId`,
the `transient` flag and the user-facing texts. It is null when the body is not in Meta's error format, which typically
means the response came from a proxy. The raw body is always available as `$e->body`.

```php
use Setono\MetaConversionsApi\Exception\ClientException;
use Setono\MetaConversionsApi\Exception\ExceptionInterface;
use Setono\MetaConversionsApi\Exception\ResponseException;
use Setono\MetaConversionsApi\Exception\TransportException;

try {
$client->sendEvent($event);
} catch (ClientException $e) {
$logger->error('Could not send event to Meta', ['exception' => $e]);
} catch (TransportException $e) {
// no response at all: try again later
} catch (ResponseException $e) {
if ($e->statusCode >= 500 || true === $e->errorResponse?->transient) {
// try again later
}

$logger->error('Meta rejected the event', ['exception' => $e, 'trace_id' => $e->errorResponse?->traceId]);
} catch (ExceptionInterface $e) {
$logger->error('Could not send the event to Meta', ['exception' => $e]);
}
```

`InvalidArgumentException` extends PHP's own `\InvalidArgumentException`, so a plain `catch (\InvalidArgumentException $e)`
works too.

## 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
Expand All @@ -165,8 +187,8 @@ $client->sendPreparedEvent($preparedEvent->withAccessTokens([
`withAccessTokens()` takes the tokens indexed by pixel id and leaves pixels that are not in the list as they are. Both
return a new instance. If your queue is trusted with the access tokens, you can skip both calls.

If a pixel still has no access token when you send, the client throws a `ClientException` that names the pixel, before
any request is made. The event is therefore never delivered to only some of its pixels.
If a pixel still has no access token when you send, the client throws an `InvalidArgumentException` that names the
pixel, before any request is made. The event is therefore never delivered to only some of its pixels.

## Browser-side tracking with deduplication

Expand All @@ -191,7 +213,8 @@ echo $generator->generateTrack($event);
```

Both methods wrap the output in a `<script>` tag by default; pass `false` as the last argument to get the raw
JavaScript instead (e.g. to combine several calls into one tag).
JavaScript instead (e.g. to combine several calls into one tag). Because their output goes straight into a page, they
do not throw when the data cannot be encoded as JSON: they log an error and return an empty string.

## Custom events

Expand All @@ -217,6 +240,8 @@ $event->userData->fbc = Fbc::fromString($_COOKIE['_fbc']);
$event->userData->fbp = Fbp::fromString($_COOKIE['_fbp']);
```

`fromString()` throws an `InvalidArgumentException` if the value does not have the expected format.

## Using your own HTTP client

By default the client auto-discovers a PSR-18 client and PSR-17 factories. To inject your own (e.g. a preconfigured
Expand Down
49 changes: 39 additions & 10 deletions UPGRADE-2.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ The requirements are unchanged: PHP 8.1+ and the same dependencies as 1.x.

## `ClientInterface` has a second method, `sendPreparedEvent()`

This is the only backwards-compatibility break, and it only affects you if you implement `ClientInterface` yourself,
for instance in a decorator or a test double. Add:
This only affects you if you implement `ClientInterface` yourself, for instance in a decorator or a test double. Add:

```php
use Setono\MetaConversionsApi\Event\PreparedEvent;
Expand All @@ -16,20 +15,50 @@ public function sendPreparedEvent(PreparedEvent $preparedEvent): void;
It sends an event that was prepared earlier with `Event::prepare()`, i.e. with the payload already normalized and
hashed. `Client` implements it, and a decorator can simply forward the call.

## Behaviour change in `Client::sendEvent()`
## `ClientException` is gone; everything the SDK throws implements `ExceptionInterface`

`sendEvent()` now delegates to `sendPreparedEvent($event->prepare())`, so the payload is built before the client checks
whether the event has any pixels. The request that is sent is byte for byte the same as in 1.x. There is one observable
difference: an event without pixels whose data is invalid, an unknown `action_source` for instance, now throws when the
payload is built. In 1.x the client logged the missing pixels and returned without ever building the payload.
There are three concrete exceptions in `Setono\MetaConversionsApi\Exception`, one for each thing you can do about a
failure. See "Error handling" in the README for how to use them.

| In 1.x | In 2.0 |
|---|---|
| `ClientException`, when Meta answered with an error | `ResponseException`. The error is no longer only a message: `$e->statusCode`, `$e->body` and `$e->errorResponse` (code, subcode, type, trace id, the transient flag, user-facing texts) |
| `ClientException`, when the error response could not be parsed | `ResponseException` with `$e->errorResponse` being null |
| The PSR-18 `ClientExceptionInterface` of your HTTP client escaped from `sendEvent()` | `TransportException`, with the PSR-18 exception as the previous exception |
| A `\JsonException` escaped from `sendEvent()` when the payload could not be encoded | `InvalidArgumentException` |
| `\InvalidArgumentException` or `Webmozart\Assert\InvalidArgumentException` from `Fbc::fromString()`, `Fbp::fromString()`, the `with*()` methods and `getPayload()` | `Setono\MetaConversionsApi\Exception\InvalidArgumentException` |

What to change:

- Replace `catch (ClientException $e)` with `catch (ExceptionInterface $e)`, or with the specific classes. If you caught
the PSR-18 exception around `sendEvent()`, catch `TransportException` instead.
- The SDK's `InvalidArgumentException` extends PHP's `\InvalidArgumentException`, so existing
`catch (\InvalidArgumentException $e)` blocks keep working. A `catch` of `Webmozart\Assert\InvalidArgumentException`
does not.
- When event data is invalid, e.g. an unknown `action_source`, the message now names the field, and the exception from
`facebook/php-business-sdk` is the previous exception.
- `ErrorResponse` is no longer `@internal`. Its properties are now readonly, and `ErrorResponse::fromJson()` throws
`InvalidArgumentException` instead of `ClientException`.

## `FbqGenerator::generateTrack()` no longer throws a `\JsonException`

When the custom data cannot be encoded as JSON, it now logs an error and returns an empty string, which is what
`generateInit()` already did. The output of both goes straight into a page, where an exception would break the page.

## Pixels without an access token are rejected before any request is made

In 1.x the client sent the request anyway, and Meta answered with an error that does not mention the access token
("Unsupported post request. Object with ID ... does not exist, cannot be loaded due to missing permissions ..."). In 2.0
the client throws a `ClientException` naming the pixels instead. The exception class is the same as before, but with
several pixels there is a difference: all pixels are checked first, so the pixels listed before the one without an
access token no longer receive the event.
the client throws an `InvalidArgumentException` naming the pixels instead. With several pixels there is one more
difference: all pixels are checked first, so the pixels listed before the one without an access token no longer receive
the event.

## Behaviour change in `Client::sendEvent()`

`sendEvent()` now delegates to `sendPreparedEvent($event->prepare())`, so the payload is built before the client checks
whether the event has any pixels. The request that is sent is byte for byte the same as in 1.x. There is one observable
difference: an event without pixels whose data is invalid, an unknown `action_source` for instance, now throws when the
payload is built. In 1.x the client logged the missing pixels and returned without ever building the payload.

## New in 2.0

Expand Down
24 changes: 24 additions & 0 deletions src/Assert.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApi;

use Setono\MetaConversionsApi\Exception\InvalidArgumentException;

/**
* Makes a failed assertion throw the SDK's own exception, so that everything the SDK throws
* implements \Setono\MetaConversionsApi\Exception\ExceptionInterface
*
* @internal
*/
final class Assert extends \Webmozart\Assert\Assert
{
/**
* @param string $message
*/
protected static function reportInvalidArgument($message): never
{
throw new InvalidArgumentException($message);
}
}
35 changes: 30 additions & 5 deletions src/Client/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use FacebookAds\ApiConfig;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Discovery\Psr18ClientDiscovery;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface as HttpClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
Expand All @@ -15,7 +16,9 @@
use Psr\Log\NullLogger;
use Setono\MetaConversionsApi\Event\Event;
use Setono\MetaConversionsApi\Event\PreparedEvent;
use Setono\MetaConversionsApi\Exception\ClientException;
use Setono\MetaConversionsApi\Exception\InvalidArgumentException;
use Setono\MetaConversionsApi\Exception\ResponseException;
use Setono\MetaConversionsApi\Exception\TransportException;

final class Client implements ClientInterface, LoggerAwareInterface
{
Expand Down Expand Up @@ -55,13 +58,23 @@ public function sendPreparedEvent(PreparedEvent $preparedEvent): void
}

if ([] !== $pixelIdsWithoutAccessToken) {
throw ClientException::missingAccessToken($pixelIdsWithoutAccessToken);
throw new InvalidArgumentException(sprintf(
'The event was not sent to Meta/Facebook because these pixels have no access token: %s. If the access tokens were removed with PreparedEvent::withoutAccessTokens(), add them back with PreparedEvent::withAccessTokens() before sending',
implode(', ', $pixelIdsWithoutAccessToken),
));
}

$httpClient = $this->getHttpClient();
$requestFactory = $this->getRequestFactory();

$data = json_encode([$preparedEvent->payload], \JSON_THROW_ON_ERROR);
try {
$data = json_encode([$preparedEvent->payload], \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new InvalidArgumentException(sprintf(
'The event was not sent to Meta/Facebook because its payload cannot be encoded as JSON: %s',
$e->getMessage(),
), previous: $e);
}

foreach ($preparedEvent->pixels as $pixel) {
$body = [
Expand All @@ -81,10 +94,22 @@ public function sendPreparedEvent(PreparedEvent $preparedEvent): void
->withHeader('Accept', 'application/json')
->withBody($this->getStreamFactory()->createStream(http_build_query($body)));

$response = $httpClient->sendRequest($request);
try {
$response = $httpClient->sendRequest($request);
} catch (ClientExceptionInterface $e) {
throw new TransportException($e);
}

if ($response->getStatusCode() !== 200) {
throw ClientException::fromErrorResponse(ErrorResponse::fromJson((string) $response->getBody()));
$body = (string) $response->getBody();

try {
$errorResponse = ErrorResponse::fromJson($body);
} catch (\InvalidArgumentException $e) {
throw new ResponseException($response->getStatusCode(), $body, null, $e);
}

throw new ResponseException($response->getStatusCode(), $body, $errorResponse);
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/Client/ClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,23 @@

use Setono\MetaConversionsApi\Event\Event;
use Setono\MetaConversionsApi\Event\PreparedEvent;
use Setono\MetaConversionsApi\Exception\ClientException;
use Setono\MetaConversionsApi\Exception\ExceptionInterface;

/**
* Implement this interface in a client that is able to send the conversion api event to a Meta/Facebook endpoint
*/
interface ClientInterface
{
/**
* @throws ClientException if a pixel has no access token or the request failed in any way
* @throws ExceptionInterface if the event's data is invalid, a pixel has no access token, or the request failed in any way
*/
public function sendEvent(Event $event): void;

/**
* Sends an event that was prepared earlier with Event::prepare(). Use this when the personal data is hashed
* at capture time and the event is sent later, for instance through a queue
*
* @throws ClientException if a pixel has no access token or the request failed in any way
* @throws ExceptionInterface if a pixel has no access token, the payload cannot be encoded, or the request failed in any way
*/
public function sendPreparedEvent(PreparedEvent $preparedEvent): void;
}
Loading
Loading