Skip to content

Logging

There are two logging layers: request-level logging (automatic) and operation-level logging (explicit).

Request logging

Every API call made through request() or requestAs() is automatically logged as an IntegrationRequest record with:

  • Endpoint, HTTP method, status code
  • Full request and response data
  • Duration (measured via hrtime())
  • Error details on failure
  • Cache hit / stale hit counters
  • retry_of pointer for retries
  • Optional relatedTo model link

No configuration needed -- this happens automatically for every request through the integration.

Limiting what a body costs

Request bodies are cut at just under 64KB. Response bodies are stored whole by default, which is usually what makes integration_requests the largest table in a busy installation. Two things bring that down:

php
// config/integrations.php
'logging' => [
    'max_response_bytes' => 65_530, // null keeps bodies whole
],
php
class OpenRouterProvider implements IntegrationProvider, LimitsRequestLogging
{
    public function unloggedResponseEndpoints(): array
    {
        // Already stored, in a better shape, by the consumer.
        return ['POST:chat/completions'];
    }
}

The cap truncates and records the original size; the contract drops the body entirely for the endpoints it names. Either way the row itself stays, so health tracking, failure rates and the stats commands see no change.

Two kinds of response are exempt from both, because the package reads them back rather than just storing them: a response being cached is the cache's payload, and a response to an idempotent write backs IdempotencyConflict recovery.

Operation logging

Log business-level operations (syncs, imports, webhooks) separately from individual API requests:

php
$log = $integration->logOperation(
    operation: 'sync',
    direction: 'inbound',
    status: 'success',
    summary: 'Synced 42 tickets from Zendesk',
    metadata: ['ticket_count' => 42, 'new' => 12, 'updated' => 30],
    durationMs: 3200,
);

Structured result data

Use resultData to store the structured output of an operation, separate from operational metadata:

php
$log = $integration->logOperation(
    operation: 'issue.create',
    direction: 'outbound',
    status: 'success',
    summary: 'Created issue in GitHub',
    metadata: ['repo' => 'acme/api', 'labels' => ['bug']],
    resultData: ['issue_number' => 42, 'url' => 'https://github.com/acme/api/issues/42'],
    durationMs: 850,
);

$log->result_data['issue_number']; // 42

metadata is for operational context (configuration, counts, request IDs). resultData is for what the operation produced (references, created IDs, output values).

Status vocabulary

status is a free string, so any value is accepted. Three drive events:

StatusEvent
successOperationCompleted
failedOperationFailed
processingOperationStarted

partial marks a sync run where some items failed, and deferred an expected wait; both are recorded without an event, as is any other custom status. The documented values are available as IntegrationLog::STATUS_SUCCESS, STATUS_FAILED, STATUS_PROCESSING, STATUS_PARTIAL, and STATUS_DEFERRED.

Hierarchical logging

Use parentId for per-record granularity under a parent operation:

php
$parentLog = $integration->logOperation(
    operation: 'sync',
    direction: 'inbound',
    status: 'success',
    summary: 'Full ticket sync',
);

foreach ($tickets as $ticket) {
    $integration->logOperation(
        operation: 'sync',
        direction: 'inbound',
        status: 'success',
        externalId: $ticket['id'],
        summary: "Imported ticket {$ticket['id']}",
        parentId: $parentLog->id,
    );
}

Querying logs

php
$integration->logs()->successful()->get();
$integration->logs()->failed()->forOperation('sync')->get();
$integration->logs()->topLevel()->recent(48)->get(); // top-level logs from last 48 hours

Terminal vs transient failures

A listener running inside a sync item (event($this->event) in ProcessSyncItem) often catches a sub-step error, logs failed, and re-throws so the queue retries the whole job. Because logOperation(status: 'failed') dispatches OperationFailed every time, the same condition re-fires the event on every attempt — even when the item succeeds on a later one. The retry state that would let you tell "failed this attempt" from "failed terminally" lives in the job, not the listener.

The package threads it through as ambient context, the same way RequestContext reaches a request closure. Inside a sync item, read it with Integration::currentSyncAttempt():

php
class IngestGitHubIssue
{
    public function handle(GitHubIssueSynced $event): void
    {
        try {
            // ... sub-step that can fail ...
        } catch (\Throwable $e) {
            $attempt = $event->integration->currentSyncAttempt();
            $event->integration->logOperation(
                operation: 'ingest',
                direction: 'inbound',
                status: 'failed',
                error: $e->getMessage(),
            );

            throw $e; // let the queue retry
        }
    }
}

When a failure is logged inside a sync item, logOperation() records the attempt on the new integration_logs.attempt / max_attempts columns and attaches a SyncAttemptContext to the OperationFailed event. A listener can read $event->attempt?->isLikelyFinalAttempt() to down-rank mid-retry noise.

isLikelyFinalAttempt() is a heuristic

"Final attempt" can't be known for certain at log time: the wall-clock retry window can cut retries short, and even the last attempt may still succeed. For terminal alerting that fires exactly once per dead item, route it to SyncItemFailed — dispatched from ProcessSyncItem::failed() on genuine exhaustion — and use the attempt context only as a best-effort filter for operation-granularity OperationFailed hooks.

Structured Laravel log context

During sync and webhook processing, the package automatically adds integration context to Laravel's shared log context:

php
// Automatically added by SyncIntegration and ProcessWebhook jobs:
Log::shareContext([
    'integration_id' => 42,
    'integration_provider' => 'zendesk',
    'integration_name' => 'Production Zendesk',
    'integration_operation' => 'sync',
]);

Use IntegrationContext directly in your own code:

php
use Integrations\Support\IntegrationContext;

IntegrationContext::push($integration, 'custom-operation');
// ... your code, all Log:: calls include the context ...
IntegrationContext::clear();

Sync timeline

During a sync, all API requests are tracked and their IDs stored in the parent sync log's metadata:

php
$syncLog = $integration->logs()->forOperation('sync')->latest()->first();
$requestIds = $syncLog->metadata['request_ids'] ?? [];
$requests = IntegrationRequest::whereIn('id', $requestIds)->get();