partner-api/logger
Partner API Logger SDK for PHP
Sends structured logs and metrics to the Partner API ingest service, or
writes the logs to stdout for your own log pipeline to deliver
(Pipeline delivery). It mirrors
@partner-api/logger 3.3.0; the cross-language contract is
packages/logger-spec/spec.md. Changes by version are in
CHANGELOG.md.
Requirements
- PHP 8.1 or later.
- No PHP extension beyond json and pcre, which every PHP 8.1 build has. The
official
php:*-cliimages work as they are. guzzlehttp/guzzle^7.0 andramsey/uuid^4.0, installed by Composer. Guzzle uses ext-curl when it is installed, and PHP streams otherwise, which needallow_url_fopen.
Install
composer require partner-api/logger
Quick start
The tenant token is one secret for your whole tenant, set once when the
Logger is constructed. $apiKey, the first argument of every log and
metric call, belongs to the partner app: each app sends its own key in the
x-api-key header of its requests to your API. Pass that value through; it is
what tells one partner's traffic from another's.
Laravel
The package auto-discovers its service provider and the PartnerLogger
facade. Publish the config file and add your token to .env:
php artisan vendor:publish --tag=partner-logger-config
PARTNER_API_TENANT_TOKEN=tenant_live_xxxxxxxxxxxx
Then log through the facade:
use PartnerApi\Logger\Laravel\Facades\PartnerLogger;
$apiKey = (string) $request->header('x-api-key'); // sent by the partner app
PartnerLogger::info($apiKey, 'Order created', ['orderId' => $order->id]);
Type-hint PartnerApi\Logger\Logger to inject the same singleton. A missing
key is not an exception: the entry is dropped and reported to onError.
Standalone
use PartnerApi\Logger\Logger;
$logger = new Logger(tenantToken: getenv('PARTNER_API_TENANT_TOKEN'));
$logger->info($apiKey, 'Order created', ['orderId' => 42]);
Log calls buffer and return at once; the buffer is delivered after your response has been sent (Delivery and errors).
Logging and context
$logger->info($apiKey, 'Something happened', ['key' => 'value']);
$logger->warn($apiKey, 'Watch out');
$logger->error($apiKey, 'Something broke', ['error' => 'Insufficient funds']);
$logger->debug($apiKey, 'Debug details');
setContext() merges fields into every later entry:
$logger->setContext([
'partnerId' => 'partner-123',
'requestId' => 'req_abc123',
]);
Recognised keys are partnerId, requestId, correlationId, path,
method, statusCode and duration, plus direction ('inbound' or
'outbound'), upstreamIntegration and upstreamBaseUrl for calls your own
code makes to a service it uses (an upstream integration), and to
('partner' or 'service') for an outbound call that went to the partner
itself; see Logging webhooks. An outbound line is never
shown to a partner unless it carries to: 'partner'. from is reserved.
Context is copied into an entry when it is buffered.
Outside a request scope it is logger-wide, which is right for PHP-FPM and the
CLI.
Request scopes
When one logger serves many requests or jobs from one process (an Octane worker with the logger warmed, RoadRunner, Swoole, a queue worker), logger-wide context and upstream calls carry from one into the next. Give each request or job its own scope, opened once at its edge. PHP-FPM and the CLI need none. For draining on these servers, see Delivery and errors.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use PartnerApi\Logger\Logger;
class ScopePartnerLogs
{
public function __construct(private Logger $logger)
{
}
public function handle(Request $request, Closure $next)
{
return $this->logger->runWithContext(
['requestId' => $request->header('x-request-id')],
fn () => $next($request),
);
}
}
runWithContext($context, $fn)starts a scope as a copy of the current context merged with$context, runs$fnand returns its result. While$fnruns, the shared logger resolves to that scope:setContext(), the fieldslogRequest()andlogResponse()set, and recorded upstream calls stay in it. Scopes nest, and the previous one comes back when$fnreturns or throws.$fnalso receives a logger bound to the scope.child($context)returns aLoggerbound to a new scope that shares the parent's buffer, transport, counters andonError. Use it where requests interleave in one process (Swoole coroutines, fibers):runWithContext()holds only for the synchronous duration of$fn.- Log the response inside the scope. A
logResponse()from a terminate hook that runs after$fnreturns lands outside it. onErrorand the end-of-request drain run outside every scope. The facade resolves the same singleton, soPartnerLogger::runWithContext()works too.
Request and response logging
// Returns the correlation ID that pairs the request with its response.
$correlationId = $logger->logRequest($apiKey, [
'method' => 'POST',
'path' => '/api/v1/orders',
'headers' => ['x-request-id' => 'req_abc123', 'authorization' => 'Bearer secret'],
'body' => ['sku' => 'A-100', 'quantity' => 2],
]);
$logger->logResponse($apiKey, [
'statusCode' => 201,
'headers' => ['content-type' => 'application/json'],
'body' => ['id' => 'ord_123', 'status' => 'created'],
'duration' => 120, // milliseconds
'correlationId' => $correlationId,
]);
- The correlation ID is the
x-correlation-idheader when you pass one, otherwise a new UUID. headersmaps a lowercase name to a string (Laravel's$request->headers->all()gives lists; join them first).authorization,cookie,set-cookie,x-api-key,x-tenant-tokenandproxy-authorizationbecome[REDACTED], whatever their case.- Log bodies to get entity views. Entity View is built from these
bodyvalues. Pass the decoded array ($request->json()->all()): only a JSON object contributes fields, and an empty PHP array encodes as[]. Ingest redacts personal data in bodies and replaces a log line over 250 KB with a marker that has no body (docs/features/entity-view.md§ Projection fidelity limits).
Logging webhooks
Incoming webhooks, a partner calling a webhook endpoint of yours, are
ordinary inbound traffic: log them with logRequest() / logResponse() like
any other partner request.
What differs is how you know the partner. A webhook is usually authenticated by a signature rather than by the partner's app key. Once your handler has verified the signature and knows which partner sent it, pass that partner's app key to the log calls. Do not leave a webhook out of your logs because it carries no key, and do not log it under another partner's key: a partner sees only the lines attributed to it.
// After verifying the signature and finding the partner that sent it:
$apiKey = $appKeys->forPartner($partnerId); // your own mapping
$correlationId = $logger->logRequest($apiKey, [
'method' => $request->method(),
'path' => $request->path(),
'headers' => [
'content-type' => $request->header('content-type'),
'x-signature' => '[REDACTED]',
],
'body' => $request->json()->all(),
]);
Never log the signing secret, and replace the signature header's value
yourself, as above: the SDK redacts only authorization, cookie,
set-cookie, x-api-key, x-tenant-token, proxy-authorization and, in stdout mode, a header whose value equals the app key, so a header such as x-signature or
stripe-signature is logged as given.
Outgoing webhooks, your code delivering an event to a partner's endpoint,
are outbound lines marked 'to' => 'partner' (requires partner-api/logger
2.3.0 or later). That marker is what lets the
partner see them: in its portal's Events, its assistant and over MCP. An
outbound line without it is a call to a service you use, and no partner ever
sees it.
Log one line per attempt, under the key of the partner you delivered to, with
three conventional fields so retries group under one delivery: event_type,
delivery_id (the same on every attempt) and attempt (1 for the first
try).
// One logger for deliveries. 'to' => 'partner' needs 'direction' => 'outbound'.
$deliveries = $logger->child(['direction' => 'outbound', 'to' => 'partner']);
$fields = [
'event_type' => $event->type, // 'booking.created'
'delivery_id' => $event->deliveryId, // the same on every retry
'attempt' => $attempt, // 1 for the first try
'method' => 'POST',
'path' => parse_url($endpoint, PHP_URL_PATH),
];
$startedAt = microtime(true);
try {
$response = $http->post($endpoint, ['json' => $event->payload, 'http_errors' => false]);
$status = $response->getStatusCode();
$fields += ['status_code' => $status, 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000)];
$status < 400
? $deliveries->info($apiKey, 'Webhook delivery', $fields)
: $deliveries->warn($apiKey, 'Webhook delivery', $fields);
} catch (\GuzzleHttp\Exception\TransferException $e) {
// No response: no status_code.
$deliveries->warn($apiKey, 'Webhook delivery failed', $fields + [
'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000),
'error' => $e->getMessage(),
]);
}
- You assert
'to' => 'partner'. Partner API cannot check where a call went. A call to a service you use, logged with'to' => 'partner', is shown to that partner with everything on the line: the vendor's name, its error text, your idempotency keys. Set it only on calls that go to the partner the key belongs to, and never on a logger that also logs your calls to other services. - Outbound only.
towithout'direction' => 'outbound', or a value other than'partner'or'service', is refused: the line is not sent andonErrorgets aninvalid-entryevent (direct mode raisesLoggerException).'to' => 'service'is the same as leaving it out.fromis reserved and refused the same way. - Where it shows. In your Diagnostics → Events the line carries a "To
partner" mark, and the "To partner" direction filter shows deliveries on
their own; the "Outbound" filter keeps only your calls to services. Lines
logged before you set
to, or by an SDK older than this release, stay hidden from the partner. - The same works in stdout mode: the line carries
"partnerapi_to":"partner".
Upstream call trail
Record the calls you make while serving a request, and logResponse() ships
them as upstream: [...] on its Outgoing response line, then clears them.
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
// Once, where you build the client. Every request through it is recorded
// against the request that makes it.
$stack = HandlerStack::create();
$stack->push($logger->upstreamMiddleware('stripe'));
$stripe = new Client(['handler' => $stack]);
// In a handler:
$correlationId = $logger->logRequest($apiKey, ['method' => 'POST', 'path' => '/orders']);
$stripe->post('https://api.stripe.com/v1/charges', ['form_params' => ['amount' => 1000]]);
// Anything that is not Guzzle: record it yourself.
$logger->upstream([
'name' => 'pricing',
'method' => 'GET',
'url' => 'https://pricing.internal/quote',
'status' => 200,
'durationMs' => 41,
]);
$logger->logResponse($apiKey, ['statusCode' => 201, 'duration' => 250, 'correlationId' => $correlationId]);
The response line then carries the calls in the order they were made:
"upstream": [
{ "name": "stripe", "method": "POST", "url": "https://api.stripe.com/v1/charges", "status": 200, "durationMs": 212, "requestId": "req_8Hk2" },
{ "name": "pricing", "method": "GET", "url": "https://pricing.internal/quote", "status": 200, "durationMs": 41 }
]
upstream($call) takes name (any label), method, url and
durationMs, and optionally status, requestId, errorCode, message and
attempt. Omit status for a network error. status must be an int (or a
whole float): '200' is not a status. A call ingest would reject (no
name, method or url, a method other than letters, - and _ up to 16
characters, a status outside 100 to 599, a durationMs that is not a finite
non-negative number) is dropped and reported to onError. It never throws.
upstreamMiddleware($name, $options = []) returns a Guzzle middleware
that records every request through it: method, URL, status, duration, and the
vendor's request id from the first header present in
$options['requestIdHeaders'] and then
UpstreamTrail::DEFAULT_REQUEST_ID_HEADERS (request-id, x-request-id,
x-amzn-requestid, x-amz-request-id, x-ms-request-id,
x-github-request-id, cf-ray). Any other option throws LoggerException
when the middleware is made.
- An HTTP error is recorded with its status, whether or not
http_errorsturns it into an exception. The body is never read. - A transport error is recorded with no status, an
errorCode(CURLE_COULDNT_CONNECT,CURLE_OPERATION_TIMEDOUT, ..., or the exception's short class name) and its message. The exception reaches your code unchanged. - A call belongs to the scope active when the request is sent, async requests included, so one client built at boot serves every request. The logger's own POSTs to ingest are never recorded.
Where to push it. A HandlerStack nests in push order, first pushed
outermost. Pushed onto HandlerStack::create(), it sits inside the redirect
middleware, so each redirect hop is its own call; unshift() it for one call
per request. Push Guzzle's retry middleware before it to record each attempt
as its own call, with attempt taken from the retry counter (0 for the
first attempt). Anywhere else attempt is omitted.
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
$stack = HandlerStack::create();
$stack->push(Middleware::retry(fn (int $retries, $request, $response = null) => $retries < 2 && $response?->getStatusCode() >= 500));
$stack->push($logger->upstreamMiddleware('stripe'));
Caps. At most 20 calls and 8 KB of trail per line
(UpstreamTrail::MAX_CALLS, MAX_BYTES); past either, the oldest calls are
dropped and the line gets _upstreamTruncated: true and _upstreamDropped: n.
name, url, errorCode and message are cut to 128, 2048, 128 and 1024
UTF-16 code units, and a requestId over 128 is left out. A URL's query
string, fragment and userinfo are stripped before the call is stored, and from
a transport error's message.
Which line carries it. Only the response line; a response with no calls
has no upstream key.
- Outside any scope, calls ship on the next
logResponse()made outside a scope: on PHP-FPM, this request's. If the logger has already logged a response outside any scope when it records such a call,onErrorgets a one-time warning: the logger is outliving its request and needs scopes. - A scope opened between a request's
logRequest()andlogResponse()ships its calls on that request's line, unless it logs its own. - Calls left in a top-level
runWithContext()when it ends, or recorded through its scoped logger afterwards, are dropped, never moved to another request. Drops are reported asinvalid-entryat most once a minute (the rest atflush()) and counted instats()['upstreamDropped'].
PII redaction helper
redactPII() redacts personal data before you hand it to a system whose
logs you do not control. It applies the ingest service's rules: this package,
ingest and @partner-api/logger all run the shared corpus in
packages/database/src/pii-redaction-corpus.json.
use PartnerApi\Logger\RedactPii;
use function PartnerApi\Logger\redactPII;
redactPII('contact alice@example.com');
// → 'contact [EMAIL_REDACTED]'
redactPII('call me on +44 20 7946 0000 today');
// → 'call me on [PHONE_REDACTED] today'
redactPII('GET https://api.example.com/v1/orders?token=abc 200');
// → 'GET https://api.example.com/v1/orders?[QUERY_REDACTED] 200'
redactPII([
'Authorization' => 'Bearer secret',
'X-Api-Key' => 'abc123',
'user' => ['email' => 'alice@example.com', 'password' => 'hunter2'],
]);
// → ['Authorization' => 'Bearer [TOKEN_REDACTED]', 'X-Api-Key' => '[KEY_REDACTED]', 'user' => ['email' => '[EMAIL_REDACTED]', 'password' => '[PASSWORD_REDACTED]']]
redactPII(['+44 20 7946 0000' => 'sent', '+44 20 7946 0001' => 'failed']);
// → ['[PHONE_REDACTED]' => 'sent', '[PHONE_REDACTED]#2' => 'failed']
RedactPii::redact('task_123e4567-e89b-12d3-a456-426614174000', ['redactUuids' => true]);
// → 'task_[ID]'
In a string it redacts:
- emails, JWTs, Bearer tokens and
access_token=/refresh_token=values; - issuer-prefixed credentials such as GitHub, AWS, Stripe and the platform's
own tokens; also generic
sk-,pk_,rk_,ak_,key_andtoken_keys, 32+ alphanumeric runs and 40+ character base64 runs; - passwords in
password=/pwd:assignments and"password": "..."JSON; - phone numbers: an international number however it is grouped, keeping a
status code, date or unit written after it. A signed count (
+12,+30s) is not a phone number, and ten bare digits go only near a word such asphoneorcall; - card numbers, CVVs, and IPv4 and IPv6 addresses;
- URL query strings: the URL keeps its host and path and ends in
?[QUERY_REDACTED]. A long REST path keeps its words and loses only its id-shaped pieces. A host outside ASCII (café.test) is kept as written, not converted to punycode, which would need ext-intl; - UUIDs, only with
redactUuids.
A card, token, JWT, IP or phone number after a JSON escape (\n) goes too.
In an array, a sensitive key (password, apiKey, clientSecret, cookie,
...) keeps its key and loses its value. A key that is itself PII is redacted
like a value; keys that redact alike keep both values, the second under #2.
A __proto__ key is dropped. Lists are walked item by item.
A redacted copy is returned; the input is never modified. Ints, floats, bools
and null pass through. Objects pass through unredacted, including a
stdClass from json_decode($json): decode with json_decode($json, true)
first.
Fail-closed. If PHP's regex engine reports an error on a string (its
backtrack or JIT stack limit), the whole string becomes [REDACTION_FAILED]
(RedactPii::REDACTION_FAILED), never unredacted or half redacted. The same
goes for an array key. The helper never throws.
Linear time and memory. Every rule is linear in its input, with PCRE's
JIT on or off. The test suite holds a 1 MB string of each adversarial shape to
under a second and 48 MB of peak memory, at the default 128 MB
memory_limit.
Options are booleans: preserveStructure (false drops a sensitive key
instead of masking it), redactEmails, redactApiKeys, redactTokens,
redactPasswords, redactPhoneNumbers, redactCreditCards,
redactIpAddresses and redactUrls, all true by default, and
redactUuids, false by default.
Metrics
// One data point
$logger->metric($apiKey, [
'slug' => 'revenue',
'timestamp' => '2024-06-01T00:00:00Z',
'value' => 54000,
'period' => 'Jun 01',
'metadata' => ['currency' => 'EUR'], // optional
]);
// Several points for one metric
$logger->metrics($apiKey, 'revenue', [
['timestamp' => '2024-06-01T00:00:00Z', 'value' => 54000, 'period' => 'Jun 01'],
['timestamp' => '2024-06-08T00:00:00Z', 'value' => 55200, 'period' => 'Jun 08'],
]);
A point may also carry a series of at most 50 characters. A slug is
lowercase letters, digits and hyphens; one call takes at most 1000 points.
Metrics are not buffered: each call posts at once and throws
PartnerApi\Logger\LoggerException on a validation or delivery failure.
Options
The constructor takes tenantToken, then optional baseUrl (default
https://ingest.partnerapi.com), httpClient (any Guzzle ClientInterface),
timestampProvider (a callable returning epoch milliseconds) and options.
Pass them by name. An unknown option or mode, an unusable stdoutSink, and
an empty tenantToken in stdout mode throw LoggerException at
construction.
| Option | Default | Laravel env (PARTNER_API_LOG_*) |
Effect |
|---|---|---|---|
mode |
Logger::MODE_BUFFERED |
MODE |
Logger::MODE_DIRECT posts on every log call, once, and throws LoggerException on failure; Logger::MODE_STDOUT writes each log call as one line to stdoutSink (Pipeline delivery) |
stdoutSink |
'php://stdout' |
STDOUT_SINK |
Where MODE_STDOUT writes: a stream URI or file path, an open stream, or a callable taking each line |
onError |
one error_log() |
none | Receives a LoggerErrorEvent for every drop |
batchSize |
100 | BATCH_SIZE |
Buffered entries that trigger a drain inside the log call; 0 leaves only flush() and the end-of-request drain |
maxBufferSize |
1000 | MAX_BUFFER_SIZE |
Entries held before the oldest are dropped |
maxRetries |
3 | MAX_RETRIES |
Retries per batch on a network fault, 408, 429 or 5xx, in flush() and the end-of-request drain only |
retryBaseDelayMs |
200 | RETRY_BASE_DELAY_MS |
First backoff window; it doubles per retry, with jitter |
retryMaxDelayMs |
5000 | RETRY_MAX_DELAY_MS |
Largest backoff window |
requestTimeoutMs |
5000 | REQUEST_TIMEOUT_MS |
Deadline for one POST, metrics included; 0 disables it |
autoDrainTimeoutMs |
1000 | AUTO_DRAIN_TIMEOUT_MS |
Budget for the drain in a log call, and on destroy; 0 disables it (on destroy, requestTimeoutMs per attempt) |
drainDeadlineMs |
5000 | DRAIN_DEADLINE_MS |
Budget for one flush() or end-of-request drain, retries included; 0 disables it |
flushOnShutdown |
true |
FLUSH_ON_SHUTDOWN |
Drain from a register_shutdown_function, and on a long-running worker when a logger is destroyed |
finishRequestOnShutdown |
true |
FINISH_REQUEST |
Call fastcgi_finish_request() before that drain |
batchSize is capped at maxBufferSize and at 1000. Under Laravel each
option is a snake_case key in config/partner-logger.php, read from the
variable shown; the token and base URL are PARTNER_API_TENANT_TOKEN and
PARTNER_API_BASE_URL.
Delivery and errors
Log calls (info, warn, error, debug, logRequest, logResponse)
append to an in-process buffer and never throw. The buffer drains:
- when it reaches
batchSize, inside the log call that filled it; - when you call
flush(); - at the end of the request. A
register_shutdown_functiondrains it afterfastcgi_finish_request(), so the client already has its response.
A shutdown function runs only when the process ends, so outside Laravel a
long-running worker calls flush() at the end of each request or job. Under
Laravel the provider drains the logger each HTTP request used, after its
response:
- Under PHP-FPM or
artisan serve, the application's logger. - Under Octane, the request's logger, warmed or not (since 2.1.1). Warming is
still recommended: add
PartnerApi\Logger\Logger::classtowarminconfig/octane.phpand one logger serves the worker instead of one being built per request; the request scope API then keeps requests apart. - In queue workers nothing drains per job: call
flush()at the end of each job, or fromQueue::after()/Queue::failing().
Under PHP-FPM, mod_php and the other one-request-per-lifecycle servers the
end-of-request drain holds every logger until it runs, as it always has. On a
long-running worker (the cli SAPI: Octane, RoadRunner, Swoole,
queue:work) it does not keep a logger alive, so a dropped logger is
collected; one dropped with entries still buffered drains them as it is
destroyed (see What this costs your request).
FrankenPHP in classic mode (one request per lifecycle) reports the
frankenphp SAPI, so it gets the long-running behaviour too: the
destroy-time drain, bounded by autoDrainTimeoutMs. Nothing is lost; only
the timing differs from PHP-FPM. NGINX Unit's SAPI is outside the
per-request list too, and behaves the same way.
A child forked with pcntl_fork() inherits the parent's buffer and pending
end-of-request drain, and delivers the parent's buffered entries again when it
exits. Call flush() before forking.
A POST carries at most 1000 entries and 1 MB of log lines.
What this costs your request
PHP has no event loop, so delivery is synchronous and bounded:
- A log call waits only for drain 1, for at most
autoDrainTimeoutMs(1 s): one attempt per batch, no backoff, and anything undelivered goes back on the buffer. A failed drain switches drain 1 off until a later drain delivers everything, so an ingest outage costs a request oneautoDrainTimeoutMsat most. - A logger dropped mid-request on a long-running worker with entries
still buffered (since 2.1.1) delivers them as it is destroyed, whenever its
last reference goes: on a function return, or whenever the cycle collector
runs. That is synchronous, so it costs what drain 1 costs: one attempt per
batch, no backoff, at most
autoDrainTimeoutMsfor the whole drain. What it could not deliver is dropped and reported asflush-failedordrain-timeout. That is once per dropped logger: keep one for the request. PHP-FPM is unchanged: there the drain after the response holds the logger. flush()and the end-of-request drain carry the retries and are bounded as a whole bydrainDeadlineMs(5 s). Entries still undelivered then are dropped and reported asdrain-timeout.
On PHP-FPM the end-of-request drain counts against
request_terminate_timeout, so keep drainDeadlineMs well under it.
max_execution_time does not help: on Unix it does not tick while PHP waits
on a socket. On a bare FPM app the drain's
fastcgi_finish_request() ends the response, so a shutdown function
registered after the first log call can no longer write output; set
finishRequestOnShutdown to false if your app needs that.
onError
Every drop goes to onError, which by default writes the message with
error_log(). Anything the hook throws is swallowed.
use PartnerApi\Logger\Logger;
use PartnerApi\Logger\LoggerErrorEvent;
$logger = new Logger(
tenantToken: getenv('PARTNER_API_TENANT_TOKEN'),
options: [
'onError' => function (LoggerErrorEvent $event): void {
error_log("[partner-logger] {$event->reason}: {$event->message}");
},
],
);
$event->reason |
Meaning |
|---|---|
flush-failed |
A batch was refused (a 4xx other than 408 or 429, or retries ran out) and discarded |
buffer-overflow |
maxBufferSize was reached and the oldest entries were dropped |
invalid-entry |
The call itself was unusable: no API key, unserialisable data, a malformed request or response array; also a rejected or dropped upstream call |
drain-timeout |
drainDeadlineMs ran out and the rest was dropped |
write-failed |
Stdout mode only: the sink threw, could not be opened, or took less than the whole line. At most one a minute; entryCount is the lines lost |
sink-warning |
Stdout mode only, once per logger: under PHP-FPM with php://stdout or php://stderr as the sink, which FPM discards or splits |
The event also carries message, entryCount, droppedTotal, and where they
apply status, attempts, retryable and cause.
Counters
$logger->stats();
// ['buffered' => 0, 'delivered' => 480, 'dropped' => 0, 'upstreamDropped' => 0]
upstreamDropped counts upstream calls dropped for want of a response line.
Pipeline delivery (stdout mode)
If you already ship your logs through a pipeline of your own (an OpenTelemetry Collector, Fluent Bit, Vector), the SDK can write each log call as one JSON line to stdout instead of sending it, and your collector forwards the lines to Partner API over OTLP/HTTP. Nothing else changes: the same calls, the same facade, context, request scopes and upstream trail.
Setting up the collector: the pipeline guide has tested configurations for the OpenTelemetry Collector, Fluent Bit and Vector, troubleshooting, the limits, and the line format for writing lines without the SDK.
Choose it when your services already log to stdout at volume and you do not want a second shipper inside the process, with its own buffer, retries and egress. Stay on the default (push) for small or serverless deployments, or wherever no collector runs: push needs nothing besides the SDK.
Switch with configuration, not code:
PARTNER_API_LOG_MODE=stdout
$logger = new Logger(
tenantToken: getenv('PARTNER_API_TENANT_TOKEN'),
options: ['mode' => Logger::MODE_STDOUT],
);
$logger->info($apiKey, 'Order created', ['orderId' => 42]);
{"partnerapi_line":"1.6.0","partnerapi_partner_ref":"v1:7167…c676","partnerapi_timestamp":"1700000000000000000","partnerapi_level":"info","level":"info","message":"Order created","orderId":42}
- Every line starts with
{"partnerapi_line":. Have your collector forward the lines that do toPOST https://ingest.partnerapi.com/v1/logs(OTLP/HTTP) with a staticx-tenant-tokenheader, and send everything else wherever it already goes. - The line names the partner by a reference, never by its key.
partnerapi_partner_refisv1:plus an HMAC of the app key's SHA-256, keyed with your tenant token.PartnerApi\Logger\PartnerReference::v1($tenantToken, $appKey)computes it if you write lines from your own logger. - The tenant token in the SDK must be the one your collector sends. The reference is keyed on it, so a line written under another token matches no partner and is dropped at ingest, where it is counted; the SDK cannot see it. The one exception is the 7 days after you roll the token, when lines written under the previous token are still matched (next point).
- Rolling the tenant token. The old token stops working within about two
minutes of the roll, with no overlap you can rely on, so give your collector
the new one as soon as you roll. Lines your app wrote under
the old token, including any still waiting in your collector, are still
attributed to their partner for 7 days after the roll. Redeploy your app
with the new token within those 7 days: after that, its lines under the old
token match no partner, and are dropped and reported to the collector as
unmatched. - Bodies and data travel through your pipeline as you pass them. The SDK
redacts the sensitive headers listed under
Request and response logging, and also any
header whose value is exactly the app key, whatever the header is called.
Everything else (request and response bodies,
data, a key in a query string or a body) reaches your collector, and your own log store if it keeps these lines, unsanitised. Ingest redacts personal data when the line arrives, as it does for push. A collector's line-size limit can also cut a long body before it reaches ingest. - Strip the container wrapper first. Container runtimes wrap each line
(CRI:
<time> stdout F …; Docker's json-file driver:{"log":"…"}) and split lines over 16 KiB. Your collector must unwrap and reassemble them before it filters on the prefix, or nothing matches. The OpenTelemetry Collector'sfile_logreceiver has acontaineroperator that removes either wrapper but rejoins only CRI (Kubernetes) partial lines: each file's separately only withinclude_file_path: true, and intact only withpreserve_trailing_whitespaces: true. Docker json-file partial lines need arecombineoperator. Neither separates stdout from stderr, so drop stderr before rejoining, and keep the SDK's lines on stdout. The pipeline guide has a tested configuration for each runtime. A file sink needs none of this. - A log call never touches the network. No buffer, batching, retries or
end-of-request drain;
flush(),shutdown()andclose()only flush the stream.metric()/metrics()still post to ingest as before. - Errors. A missing key or unserialisable data is reported to
onErroras in push mode and nothing is written. A sink that fails is reported aswrite-failed(Failed to write log: …, with the OS's error where there is one), at most once a minute: each report carries the lines lost since the last,stats()['dropped']counts every one, andflush()reports what the minute held back. A line youronErrorlogs while handling that failure is counted but not reported again. What happens after the write (in your collector, at ingest) never reaches the SDK. Instats(),deliveredcounts lines written in full. - Laravel: the logger is built on its first use, so an empty
PARTNER_API_TENANT_TOKENin stdout mode throwsLoggerExceptionfrom the firstPartnerLogger::call, not at boot.
Sinks
stdoutSink takes:
- a stream or file path:
'php://stdout'(the default),'php://stderr','php://fd/<n>', afile://URI or a local file path, opened in append mode on the first line and kept open. Use an absolute path: a relative one resolves against the working directory, which differs between PHP-FPM,artisanand a queue worker. A string is always a path, never a function name. Anything else (php://output, which under PHP-FPM would write your log lines into the HTTP response,php://memory,php://temp,http://,phar://, …) throwsLoggerExceptionat construction. Lines written to'php://stderr'are dropped by the guide's Kubernetes and Docker collector configurations, which forward stdout only. - an open, writable stream resource, which the logger never closes;
- a callable that receives each complete line, trailing
"\n"included, once per line.
Each line is a single fwrite() (or a single call), so lines from one process
never interleave. An emitter cut short mid-line ends the fragment with one
"\n", so the next line keeps its prefix, and reports the line as lost
(ingest drops the fragment as unparseable); the SDK does exactly that and
does not retry. Between processes sharing one output, a pipe write is atomic
only up to 4096 bytes on Linux, and a line with bodies is usually longer: give
each process its own output, or a file. Appends to one shared file from
several processes do not interleave on a local filesystem; on a network
filesystem (NFS, EFS) they can, so use one file per process there.
Rotation. A file sink stays open. At most once a second, the logger
checks whether its path still names the file it is writing to. When the file
was renamed or deleted (logrotate's default create mode), it opens the path
afresh and switches over only if that open succeeds; until then it keeps
writing to the file it has, reports nothing, and tries again a second later.
Lines written before the switch land in the rotated file. copytruncate keeps
the same file and needs no reopen.
PHP-FPM
An FPM worker's stdout is not the container's stdout. FPM discards worker
stdout and stderr unless the pool sets catch_workers_output = yes. With it,
FPM writes worker output to its own error log, prefixes each line with
[pool www] child 12 said into stdout: unless decorate_workers_output = no,
and splits any line longer than log_limit (1024 bytes by default). The
official Docker images set catch_workers_output = yes,
decorate_workers_output = no and log_limit = 8192, and send that log to
the master's stderr: there the lines appear on the container's stderr, not
stdout, and are still split above 8192 bytes. A split line is lost at ingest.
So under FPM, point the sink at a file your collector tails:
PARTNER_API_LOG_MODE=stdout
PARTNER_API_LOG_STDOUT_SINK=/var/log/app/partner-api.jsonl
A logger constructed under FPM with php://stdout or php://stderr as its
sink reports one sink-warning to onError saying so: once per logger
instance, not per line. Under FPM each request builds its own logger, so with
the default hook that is one error_log() line per request.
If you want one file per worker, build the logger in your own
service provider with getmypid() in the path: a value computed in
config/partner-logger.php is frozen by php artisan config:cache.
Octane and other long-running processes
Under php artisan octane:start (Swoole, RoadRunner or FrankenPHP) your
workers run as child processes of a server that re-renders what they print:
a JSON line on a worker's stdout is decoded, re-encoded and printed behind a
label, so it loses its prefix, and repeated stderr lines are merged. Neither
reaches ingest, while stats() still counts the lines as written. Under
Octane the sink must be a file, by absolute path:
PARTNER_API_LOG_MODE=stdout
PARTNER_API_LOG_STDOUT_SINK=/var/log/app/partner-api.jsonl
php://stdout is right only for a plain CLI process whose stdout is what your
container runtime captures: queue:work run directly (not under Horizon), or
a daemon of your own. If several such processes share one stdout, the pipe
limit above applies.
Testing
Pass a Guzzle client backed by a MockHandler, a fixed timestampProvider
and flushOnShutdown => false. The sleeper, randomizer and clock
options replace the backoff sleep, its jitter and the monotonic clock (in
milliseconds).
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use PartnerApi\Logger\Logger;
$sent = [];
$stack = HandlerStack::create(new MockHandler([new Response(200)]));
$stack->push(Middleware::history($sent));
$logger = new Logger(
tenantToken: 'test-token',
httpClient: new Client(['handler' => $stack]),
timestampProvider: fn () => 1700000000000,
options: ['flushOnShutdown' => false, 'batchSize' => 0],
);
$logger->info('app-key', 'Order created');
$logger->flush();
$body = json_decode((string) $sent[0]['request']->getBody(), true);
// $body['entries'][0]['timestamp'] === '1700000000000000000'
Related Packages
Observability toolkit for Laravel applications - Monitor HTTP requests, outbound...
Version History
| Version | Released | PHP | License |
|---|---|---|---|
| v2.3.0 | ^8.1 | MIT | |
| v2.2.0 | ^8.1 | MIT | |
| v2.1.1 | ^8.1 | MIT | |
| v2.1.0 | ^8.1 | MIT | |
| v2.0.0 | ^8.1 | MIT | |
| v1.2.0 | ^8.1 | MIT | |
| v1.1.0 | ^8.1 | MIT | |
| v1.0.0 | ^8.1 | MIT |