Skip to content

Live log streaming

Clever Cloud streams application logs over Server-Sent Events (SSE). The SDK wraps Symfony's EventSourceHttpClient so you iterate typed LogEntry objects - framing, reconnection, and Last-Event-ID resume are Symfony's job.

Read live logs

foreach ($client->logs->stream('app_xxx', 'orga_xxx') as $entry) {
    printf("[%s] %s\n", $entry->severity ?? 'INFO', $entry->message);
}

Signature (verified against src/Resource/V4/LogsResource.php):

public function stream(
    string $applicationId,
    ?string $organisationId = null,
    array $filters = [], // see "Filters" below
): LogStream
  • $organisationId === null triggers a /v2/self lookup to resolve the personal organisation id, costing one extra request. There is no /self variant of the logs endpoint; the personal organisation is addressed by its own user_<uuid> id.
  • Filter keys accepted: since, until, limit, deploymentId, instanceId, filter, service, field, maxRetryDurationInSeconds, throttleElements, throttlePerInMilliseconds, color. Everything goes into the query string; list values (instanceId, service, field) become a repeated parameter and booleans are sent as true / false. The full table lives in Logs.
  • The returned LogStream implements IteratorAggregate<int, LogEntry> - foreach is the only public consumer surface. LogStream now accepts an optional $maxDurationSeconds parameter to bound iteration time.
  • Both stream() and query() use the same SSE endpoint. There is no JSON endpoint; the API only supports text/event-stream content type.

LogEntry shape

Verified against src/Model/LogEntry.php. The API sends camelCase field names; the model now matches the wire format:

public string  $message;       // required
public ?string $id;
public ?string $severity;
public ?int    $priority;
public ?string $date;
public ?string $instanceId;
public ?string $applicationId;
public ?string $deploymentId;
public ?string $commitId;
public ?string $service;       // e.g. "run-p2993-i2993" (previously misnamed as $stream)
public ?string $region;
public ?string $zone;
public ?string $version;
public array   $raw;           // any extra fields the API may add later

Note: instanceId, applicationId and deploymentId were always null before the fix because the model asked for snake_case keys the API never sends.

Historical query

When you don't need live tailing - query() returns a one-shot list by consuming the SSE stream and stopping early:

/** @var list<LogEntry> $logs */
$logs = $client->logs->query('app_xxx', 'orga_xxx', [
    'since' => '2026-05-01T00:00:00Z',
    'until' => '2026-05-02T00:00:00Z',
    'filter' => 'level:error',
    'limit' => 100,
], 30); // maxDurationSeconds - required to bound quiet streams

Signature:

public function query(
    string $applicationId,
    ?string $organisationId = null,
    array $filters = [], // same keys as stream()
    int $maxDurationSeconds = 10,
): array

Important: query() needs a since filter to be historical at all. Without one, the endpoint only sends entries produced from that moment on, so the call degenerates into a short live tail. Two bounds are needed: limit is sent upstream and the server closes the connection once that many entries have been sent, but for quiet applications the endpoint holds the connection open and emits HEARTBEAT events forever. No combination of since / until / limit makes it hang up (verified against the live API), so $maxDurationSeconds is what actually guarantees this method returns.

Add-on, Kubernetes and access logs

The same pattern covers the other log streams:

// Runtime logs of an add-on or a Kubernetes cluster (`RESOURCE_LOG` events)
foreach ($client->logs->streamResource('kubernetes_xxx', 'orga_xxx', ['severity' => 'warning']) as $entry) {
    echo $entry->message, "\n";
}

// HTTP access logs (`ACCESS_LOG` events)
foreach ($client->logs->streamAccessLogs('app_xxx', 'orga_xxx') as $event) {
    echo json_encode($event->data), "\n";
}

streamResource() / queryResource() return LogEntry objects like stream(). Access logs have no published schema, so streamAccessLogs() and its siblings yield StreamEvent objects (type, data, id) holding the decoded JSON. See Logs for every method and filter.

Endpoint and authentication

Both methods hit:

GET /v4/logs/organisations/{ownerId}/applications/{applicationId}/logs

The endpoint only supports Server-Sent Events with content type text/event-stream. There is no JSON endpoint, which is why query() consumes the stream instead of doing a plain GET. The /v2/organisations/{org}/applications/{app}/logs path that this SDK targeted before does not exist and always answered 404.

Both credential types work. An earlier version of this page claimed logs required OAuth 1.0a and that API tokens always got a 404. That was a misdiagnosis: the 404 came from the wrong path above, not from the authentication mode. Streaming and querying logs with a Bearer token through api-bridge.clever-cloud.com is verified working.

The host depends on your credentials, which is the same routing rule as every other call, see Authentication:

  • API token (Bearer) goes to api-bridge.clever-cloud.com
  • OAuth 1.0a goes to api.clever-cloud.com

How LogStream decodes frames

Verified against src/Streaming/EventStream.php and src/Streaming/LogStream.php. EventStream does the SSE work shared by every stream; LogStream maps its events onto LogEntry objects:

  1. Symfony's EventSourceHttpClient yields chunks. The SDK only handles ServerSentEvent chunks; first-chunk markers, control frames and keep-alive comments are skipped.
  2. HEARTBEAT events are skipped. An END_OF_STREAM event, which the API sends once limit, until or its own timeout is reached, ends the iteration.
  3. Each event's data field is JSON-decoded. Empty or invalid payloads are silently dropped.
  4. LogStream maps the decoded array onto a LogEntry through AutoMapper; EventStream yields it as a StreamEvent.
  5. Non-2xx upstream responses surface as typed SDK exceptions (AuthException / NotFoundException / ServerException / ApiException) with the actual status code on the first chunk.
  6. PSR-18 / Symfony transport failures (DNS, TLS, connection reset) raise TransportException.

Resuming after a disconnect

EventSourceHttpClient keeps track of the last received event ID and sends it back on reconnect via the Last-Event-ID header. You don't have to do anything - the iteration just resumes.

Mocking the stream in tests

$frame1 = json_encode(['message' => 'hello', 'instanceId' => 'i_1']);
$frame2 = json_encode(['message' => 'world', 'instanceId' => 'i_2']);

$response = new MockResponse(
    ['data: '.$frame1."\n\n", 'data: '.$frame2."\n\n"],
    ['response_headers' => ['content-type' => 'text/event-stream']],
);

$client = (new ClientBuilder())
    ->withCredentials(Credentials::apiToken('test'))
    ->withHttpClient(new MockHttpClient([$response]))
    ->build();

$entries = iterator_to_array($client->logs->stream('app_42', 'orga_1'), false);
// 2 LogEntry instances

More detail in Testing.

Proxying SSE to a browser (e.g. dashboard)

The demo dashboard at demo/ wraps LogStream in a Symfony StreamedResponse and re-emits each entry as an SSE frame to the browser's EventSource. See demo/src/Controller/LogsController.php for the working pattern (heartbeats, session lock release, typed error events).