PHP collector API

seamward/php-collector is the framework-neutral implementation shared by PHP adapters. It targets PHP 8.3 or later, has no framework dependency, and is available from Packagist as an alpha package.

Connection and policy

Connection::fromCredentials($connectionKey, $ingestToken, $endpoint) validates both Integration-owned values, derives the bound Integration id, and requires HTTPS except for localhost development.

One ingest token for every Integration

Connection::forService($ingestToken, $connectionName, $endpoint) reports an Integration with the workspace ingest token and the Integration's connection name, in place of a Connection key and token per Integration. See One token for every Integration.

Code example

use Seamward\Collector\Collector;use Seamward\Collector\Configuration\Connection;
$paystack = Collector::connect(    connection: Connection::forService(        getenv('SEAMWARD_INGEST_TOKEN'),        'paystack-webhook',    ),    transport: $transport,);

It throws InvalidArgumentException when the token is malformed or the name is not 2 to 64 lowercase letters, digits and single hyphens. Connection::isServiceToken($token) checks a token's format and checksum without a network call. The token decides the mode, so the same name works in Test and Live.

RedactionPolicy accepts:

ArgumentDefaultPurpose
versionshape-only-v1Audit label stored with every payload shape
hashKeycollector-derivedLocal key used before identifiers enter an envelope
hashNamespacedeterministic local derivationNon-secret generation label for keyed hashes
dropFields[]Fields removed by the optional local payload redaction helper
hashFields[]Fields hashed by the optional local payload redaction helper

The default hash key is derived locally from the ingest token, and the token itself is never serialized into an envelope. Seamward issues that token and receives it with every request, so a derived key is one Seamward could work out; pass your own hashKey for a key it cannot. See The hash key.

Observations

Create an Observation with an Operation, status code, duration, and JSON-compatible payload. Convenience factories cover inbound webhooks and outbound HTTP APIs:

Code example

Operation::inboundWebhook('/webhooks/candidates');Operation::outboundHttp('POST', '/v1/candidates');Operation::queuePublish('candidate-events');Operation::queueConsume('candidate-events');Operation::scheduledFeed('nightly-candidate-import', 'inbound');

HTTP route templates must be bounded absolute paths without a query string. Surrounding whitespace is trimmed, and :id or {id} segments are normalized to {}. Queue and scheduled-feed targets use bounded stable labels. Use placeholders rather than concrete identifiers. JsonValue::decode() preserves the difference between JSON objects and arrays, including empty {}, [], and numeric-string object keys.

Optional observation metadata includes attempt, eventType, declaredContractVersion, deployment, correlation, and outcome. Raw correlation identifiers and business object ids are replaced by keyed hashes.

Outcome amount and state

An outcome may also report two facts about your own record, which reconciliation rules read: amount for amount-match and state for allowed-sequence.

Code example

Observation::fromArray([    // ...operation, status, duration, payload    'eventType' => 'payout.paid',    'outcome' => [        'accepted' => true,        'businessObjectType' => 'payout',        'businessObjectId' => $payout->id,        'amount' => ['value' => $payout->amount_minor, 'unit' => 'GBP_minor'],        'state' => $payout->status,    ],]);

amount.value is an int or float between -1e15 and 1e15; a numeric string is not accepted. amount.unit (up to 32 characters) and state (up to 64) are labels of letters, digits, ., _ or -. These are values you choose to send and are stored as you report them, so report a total and a state name, never an identifier or a personal value. One that does not meet the format is left out and the observation is still sent. An observation that reports either is sent as envelope 0.6.

Account reference

Name the customer or account a record belongs to and an incident can tell you how many of your accounts it affects. Set 'accountRef' in an observation's outcome, or pass accountRef: to observeCall:

Code example

$collector->observeCall(    $operation,    fn () => $client->applications->retrieve($applicationId),    reference: $applicationId,    accountRef: $employerId,);

It is your own identifier, up to 512 characters, hashed in your process with your hash key. Only the hash is sent, as outcome.accountRefHash, in an envelope 0.9. Seamward never learns the identifier or a name.

Deprecation notices

Providers often announce a change in their response headers before it lands. observeCall reads the Deprecation, Sunset and API version headers of the response and sends what they say as dates and one version label, which Seamward turns into an advisory.

It finds the headers where common clients keep them: getHeaders() or headers() on the result (PSR-7 and Laravel responses), a headers property, the same on a response or getResponse(), getHttpHeaders() on an exception, and getLastResponse()->headers on a provider SDK object. For a client that keeps them elsewhere, return them from your extractor:

Code example

$operation = CallOperation::http(    method: 'GET',    routeTemplate: '/v1/charges/{chargeId}',    extract: fn (ProviderResult $result): array => [        'statusCode' => $result->httpStatus,        'payload' => $result->body,        'headers' => $result->responseHeaders,    ],);

With record(), pass them as the observation's responseHeaders.

No header is sent. Only those headers are looked at, and a value is kept only when it parses to a date between 2000 and 2100 or to a version label of up to 64 letters, digits, ., _ or -. Anything else is discarded in your process. An observation that carries a notice is sent as envelope 0.7; see the envelope reference for the exact rules, which the PHP and Node.js collectors share.

Rate limits and signature results

observeCall reads a provider's rate-limit headers from the same places it reads deprecation notices and sends three whole numbers: the limit, what remains, and the seconds until it resets. The headers read are RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, the same names prefixed X-RateLimit- and X-Rate-Limit-, the combined RateLimit header, and Retry-After in seconds. A value that is not a whole number in range is discarded. No header is sent.

When you record a webhook yourself, report the result of your own signature check in the outcome:

Code example

$collector->record(Observation::fromArray([    'direction' => 'inbound',    'protocol' => 'http-webhook',    'method' => 'POST',    'routeTemplate' => '/webhooks/payments',    'payloadLocation' => 'request',    'statusCode' => $valid ? 202 : 401,    'durationMs' => $elapsedMs,    'payload' => $payload,    'outcome' => ['accepted' => $valid, 'signatureValid' => $valid],]));

signatureValid is a bool; any other value is left out. Never pass the signature or the secret. An observation that carries a rate limit or a signature result is sent as envelope 0.8. Seamward uses both for connection health.

The completed envelope is validated strictly before enqueue. Unknown fields, invalid nested values, malformed deployment labels, noncanonical keyed hashes, missing operation identity, invalid status combinations, and malformed shapes are rejected locally. record() suppresses that telemetry failure and increments buildErrors.

Collector lifecycle

Collector::connect() accepts a validated connection, a transport, and optional buffer, policy, envelope builder, and batch size.

  • record(Observation $observation): void builds and buffers an envelope. It never throws.
  • observeQueuePublish(...) wraps an outbound queue publisher and projects success, rejection, or error to 202, 422, or 500.
  • observeQueueConsumer(...) wraps an inbound queue consumer with the same status projection.
  • observeScheduledFeed(...) wraps an inbound import or outbound export and projects completion, rejection, or error to 200, 422, or 500.
  • observeCall(CallOperation $operation, callable $call, ?string $reference = null, mixed $requestPayload = null, int $attempt = 1, array $correlation = []): mixed observes one outgoing call made through any client and returns the callback's result; see below.
  • flush(): void ships the current queue. It never throws.
  • stats(): array returns delivery and build counters, including extractionErrors, and profileErrors for value profiles that could not be built.

Collector::connect also takes two value-profile arguments:

ArgumentDefaultPurpose
valueProfile'structural''off', 'structural', or 'full'; see Value profiles
valueProfileExcludeFields[]With 'full': field names whose values are never hashed

'full' throws InvalidArgumentException unless the RedactionPolicy carries your own hashKey. An unrecognised level is treated as 'off'. Fields named in the policy's dropFields and hashFields are never described.

The default in-memory buffer holds 1,000 envelopes and drops the oldest entry on overflow. The default batch size is 50.

Collector::connect() also accepts deployment. Explicit valid values win over supported environment variables. A valid deployment attached directly to one Observation wins over the collector default. The core remains manual-flush by design and performs no destructor or timer-based network I/O.

observeCall and CallOperation

CallOperation::http() builds the reusable definition:

ArgumentTypeDefaultNotes
methodstringrequiredThe provider's HTTP method
routeTemplatestringrequiredStable route pattern with named parameters; never a concrete URL
payloadLocationstring'response''request' or 'response'
businessObjectType?stringnullType of record the operation concerns
eventType?stringnullLow-cardinality operation label
extract?ClosurenullReads evidence from a result
extractError?ClosurenullReads evidence from a thrown exception

An extractor returns null to use the built-in reading, or an array with any of these keys:

KeyTypeNotes
statusCodeintThe actual HTTP status. Values outside 100 to 599 are treated as unknown
noResponsebooltrue only when no HTTP response was received. Exceptions only
payloadmixedResponse body for shape-only fingerprinting. Present with null means an inspected JSON null
acceptedboolOverrides the acceptance derived from the status

Observation gains two matching constructor arguments in the same release: statusState ('unknown' or 'no-response' with status 0) and payloadInspected (false when no payload was examined). The envelope then carries payload.schemaInspected: false, so an unexamined body is never mistaken for a JSON null.

The PHP collector guide has the full walkthrough.

Local redaction helpers

Redactor::payload($value, $policy) and Redactor::headers($headers, $policy) mirror the Node.js helpers for separate local logging or storage pipelines. Credential headers are always dropped. These helpers do not provide the envelope privacy boundary; payload values and headers cannot enter an envelope regardless.

Transport contract

Implement Seamward\Collector\Transport\Transport:

Code example

interface Transport{    public function send(ShippingRequest $request): ShippingResponse;}

The request contains endpoint, headers, and the exact JSON body covered by X-Seamward-Signature. Return the HTTP status and body as a ShippingResponse. Transport exceptions are caught by the collector.

Failure behavior

ResultQueue behaviorCounter
2xx except 207Remove the batchshipped
Valid 207Remove the batch and account per resultshipped and rejected
Non-retryable 4xxRemove the batchrejected
409, 429, 5xx, transport error, malformed 207Retain the batchfailedBatches
Buffer overflowDrop oldestdropped
Invalid observationDo not enqueuebuildErrors

Next steps