diff --git a/CLAUDE.md b/CLAUDE.md index 8a798e9..22795c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/README.md b/README.md index 9f5e9c7..4decaed 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -191,7 +213,8 @@ echo $generator->generateTrack($event); ``` Both methods wrap the output in a `