seamward / php-collector
Framework-neutral PHP collector for Seamward integration evidence.
Requires
- php: ^8.3
- ext-mbstring: *
Requires (Dev)
- laravel/pint: ^1.0
- pestphp/pest: ^4.0 || ^5.0
- phpstan/phpstan: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-07 11:53:09 UTC
README
Framework-neutral PHP 8.3+ collector core for building strictly validated, redaction-safe Seamward observation envelopes, observing HTTP, webhook, queue, and scheduled-feed boundaries, buffering evidence in bounded memory, and delivering signed batches through an injected transport.
Status: alpha. This package is available under the MIT license.
Install
composer require seamward/php-collector:^0.1@alpha
Use
use Seamward\Collector\Collector; use Seamward\Collector\Configuration\Connection; use Seamward\Collector\Observation\Observation; use Seamward\Collector\Observation\Operation; $collector = Collector::connect( connection: Connection::fromCredentials($connectionKey, $ingestToken), transport: $transport, ); $collector->record(new Observation( operation: Operation::outboundHttp('POST', '/v1/candidates'), statusCode: 201, durationMs: 48, payload: (object) ['id' => 'local-value'], )); $collector->flush();
Queue publishes, queue consumers, and scheduled imports or exports have first-class wrappers. They preserve the callback return value and rethrow the callback's original exception:
$result = $collector->observeQueuePublish( queueName: 'candidate-events', message: $message, publisher: fn ($message) => $queue->publish('candidate-events', $message), ); $rows = $collector->observeScheduledFeed( feedName: 'nightly-candidate-import', direction: 'inbound', payload: $rows, handler: fn ($rows) => $importer->import($rows), );
Calls made through a provider SDK or any other client use observeCall. Define the operation once in your own code and reuse it at every call site:
use Seamward\Collector\Observation\CallOperation; // Your code: defined once. $retrievePaymentIntent = CallOperation::http( method: 'GET', routeTemplate: '/v1/payment_intents/{paymentIntentId}', businessObjectType: 'payment_intent', ); // Each call site. $intent = $collector->observeCall( $retrievePaymentIntent, fn () => $stripe->paymentIntents->retrieve($id), reference: $id, );
observeCall returns the callback's own result and rethrows its own exception. It records exactly one observation, and a wrapped SDK call counts as one attempt however many times the SDK retried internally.
The route template is a stable pattern, never a concrete URL. Named parameters are kept as a readable label (/v1/payment_intents/:paymentIntentId); the reference leaves your process only as a keyed hash.
The HTTP status is read from the common result and exception shapes: a status or statusCode value, getStatusCode(), getHttpStatus(), getResponse(), getLastResponse(), and @metadata. When your client reports it elsewhere, pass extractors:
$operation = CallOperation::http( method: 'GET', routeTemplate: '/v1/payment_intents/{paymentIntentId}', extract: fn ($result): array => [ 'statusCode' => $result->httpStatus, 'payload' => $result->body, ], extractError: fn (Throwable $exception): array => $exception instanceof ConnectionFailed ? ['noResponse' => true] : [], );
An extractor that returns null defers to the built-in reading, so it can handle one special case and leave the rest alone.
When no status can be established, the observation is still recorded with its status marked unknown; a status is never invented. An extractor that throws is counted in stats()['extractionErrors'] and never affects your call.
Pass deployment: ['service' => 'candidate-api'] to Collector::connect() to override release context. Otherwise, the core recognizes the same bounded Seamward and deployment-platform variables as the Node.js collector. A valid per-observation deployment takes precedence over the collector default.
Implement Seamward\Collector\Transport\Transport with the HTTP client already used by the host application. The request passed to the transport contains the signed JSON body and delivery headers.
The collector sends structural shapes, fingerprints, operation metadata, and keyed hashes. Raw payload values, headers, ingest tokens, and unhashed correlation identifiers are never placed in an envelope.
Value profiles
By default the collector also describes each field's value without sending it: a format label from a fixed list, power-of-ten bands for numbers, and length bands. This lets Seamward notice a change that keeps the shape and moves the meaning, such as an amount switching from minor to major units.
$collector = Collector::connect( connection: $connection, transport: $transport, policy: new RedactionPolicy( hashKey: $_ENV['SEAMWARD_HASH_KEY'], dropFields: ['ssn'], ), valueProfile: 'full', // 'off', 'structural' (the default), or 'full' valueProfileExcludeFields: ['tier'], );
structuralsends labels and bands only. A label or a band is shared by every value that fits it.fulladds keyed hashes of short free-form string values. A hash of a field with few possible values can be reversed by anyone who holds the key, sofullthrows unless the policy carries your ownhashKey.- A field named in
dropFieldsorhashFieldsis never described at any level. - A profile that cannot be built is left out and the observation is still sent;
stats()['profileErrors']counts those.
Without your own hashKey, keyed hashes use a key derived from the ingest token. Seamward issues that token and receives it with every request, so a derived key is one Seamward could work out. That is acceptable for identifier hashes, which cannot be guessed, and not for field values.
Redactor::payload() and Redactor::headers() provide Node-compatible helpers for separate local logging or storage pipelines. They are not needed to keep values out of Seamward envelopes because the envelope has no body or header field.
Deprecation notices
observeCall reads a provider's Deprecation, Sunset and API version
response headers and sends what they say as dates and one version label, so
Seamward can raise an advisory before the change lands. It finds them on PSR-7
and Laravel responses, on exceptions that carry a response, and on provider
SDK objects; an extractor may return them as headers. No header is sent, and
a value that does not parse to a date or a version label is discarded.
Rate limits and signature results
observeCall also reads a provider's rate-limit headers and sends the limit,
what remains and the seconds to reset as whole numbers. When you record a
webhook, set 'signatureValid' => true|false in its outcome to report the
result of your own signature check. No header, signature or secret is sent.
Account reference
Set 'accountRef' in an outcome, or pass accountRef: to observeCall, to
name the customer or account a record belongs to. It is hashed in your process
and only the hash is sent.
Development
composer install
composer test
composer analyse
composer format -- --test
The package-local fixtures in tests/fixtures are checked against the shared Seamward contract before every release. collector-conformance-v0.2.json verifies that PHP and Node produce the same shapes, fingerprints, keyed hashes, signatures, and envelope body. collector-conformance-v0.4.json verifies that both runtimes accept and reject the same envelope 0.4 cases. collector-conformance-v0.5.json verifies that both label string formats, profile payloads, and accept and reject envelope 0.5 cases identically. The collector emits envelope 0.5 when it sends a value profile and 0.4 otherwise, and requires a Seamward server that accepts both.
One ingest token for every Integration
Your application does not need a Connection key and token for each Integration. A workspace has one ingest token per mode. In Seamward, switch to the mode you are setting up, open Settings, then Ingest token, and select Issue token. Use each Integration's connection name, shown on its Collector setup tab:
use Seamward\Collector\Collector; use Seamward\Collector\Configuration\Connection; $paystack = Collector::connect( connection: Connection::forService( getenv('SEAMWARD_INGEST_TOKEN'), 'paystack-webhook', ), transport: $transport, );
The name is the same in Test and Live: a Test token records into the
Integration's Test connection and a Live token into its Live connection. A name
no Integration in the workspace has is rejected with 401.
Connection::isServiceToken() checks a token's format and checksum without a
network call. Connection keys with per-Integration tokens remain supported.
Monthly observation allowances
Quota-aware monthly pausing is an unreleased candidate. The current published
versions listed below discard each rejected batch and continue sending later
evidence. After the announced grace period, only Sandbox admission is limited
to 10,000 accepted observations per account per UTC month.
Live usage is informational and is not rejected. In the candidate, 403 observation_monthly_quota_exhausted rejects the
entire batch without persistence. The collector discards that batch and pending
telemetry, counts them in rejected and its quotaRejected subset, then rejects
new telemetry locally until the validated UTC reset. Application requests keep
running. quotaResetAtSeconds is the reset as Unix seconds, or 0 when inactive.
New observations resume after reset; discarded telemetry is not recovered.
Minute throttling remains 429, with bounded retry and no monthly pause.
Malformed or unrelated 403 responses do not install a quota pause. Previously released Node 0.1.0-alpha.28 and PHP/Laravel v0.1.0-alpha.1
collectors discard each rejected batch and keep sending later evidence without
waiting for monthly reset. Upgrade to the quota-aware release before quota
enforcement begins. Account administrators can inspect the separate allowances
at https://seamward.com/api/workspace#read-account-mode-usage.