Symfony Integration
Integrate ImmutableLog into any Symfony application with an EventSubscriber. Sending happens on `kernel.terminate` — after the response is delivered to the client — using Symfony's native HttpClient.
Installation
The subscriber uses the Symfony HttpClient and uid components.
composer require symfony/http-client symfony/uidEventSubscriber code
Save it in `src/EventSubscriber/ImmutableLogSubscriber.php`. `onRequest` records the timestamp and request_id; `onException` stores the exception; `onTerminate` builds and sends the event after the response. Query params go through `redact()`.
<?php
// src/EventSubscriber/ImmutableLogSubscriber.php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ImmutableLogSubscriber implements EventSubscriberInterface
{
// Chaves cujos valores sao mascarados antes do envio (PII/segredos).
private const SENSITIVE = [
'password', 'pwd', 'senha', 'token', 'access_token', 'refresh_token', 'api_key',
'apikey', 'secret', 'client_secret', 'authorization', 'auth', 'credit_card',
'card_number', 'cvv', 'cpf', 'otp', 'code',
];
public function __construct(
private HttpClientInterface $http,
private string $apiKey,
private string $service = 'symfony-service',
private string $env = 'prod',
private string $apiUrl = 'https://api.immutablelog.com',
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onRequest', 4096],
KernelEvents::EXCEPTION => 'onException',
KernelEvents::TERMINATE => 'onTerminate',
];
}
public function onRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$req = $event->getRequest();
$req->attributes->set('imtbl.started_at', microtime(true));
$req->attributes->set(
'imtbl.request_id',
$req->headers->get('X-Request-Id') ?? Uuid::v4()->toRfc4122()
);
}
public function onException(ExceptionEvent $event): void
{
$event->getRequest()->attributes->set('imtbl.exception', $event->getThrowable());
}
// TERMINATE roda após a resposta ser enviada — fire-and-forget natural.
public function onTerminate(TerminateEvent $event): void
{
try {
if (empty($this->apiKey)) {
return;
}
$req = $event->getRequest();
$res = $event->getResponse();
$path = $req->getPathInfo();
if (in_array($path, ['/health', '/healthz', '/metrics'], true)) {
return;
}
$startedAt = $req->attributes->get('imtbl.started_at', microtime(true));
$latencyMs = (int) ((microtime(true) - $startedAt) * 1000);
$status = $res->getStatusCode();
$requestId = $req->attributes->get('imtbl.request_id');
$exc = $req->attributes->get('imtbl.exception');
$kind = $exc ? 'error'
: ($status >= 400 ? 'error' : ($status >= 300 ? 'info' : ($status >= 200 ? 'success' : 'info')));
$eventName = $req->attributes->get('imtbl.event_name')
?? 'http.' . $req->getMethod() . '.' . $path;
$payload = [
'id' => Uuid::v4()->toRfc4122(),
'kind' => $kind,
'message' => $req->getMethod() . ' ' . $path . ' -> ' . $status,
'timestamp' => gmdate('c'),
'context' => [
'ip' => $req->getClientIp() ?? 'unknown',
'user_agent' => $req->headers->get('User-Agent') ?? 'unknown',
],
'request' => [
'request_id' => $requestId,
'method' => $req->getMethod(),
'path' => $path,
// query params passam por redact() — nunca enviar segredos em claro.
'query_params' => self::redact($req->query->all()) ?: null,
],
'metrics' => ['latency_ms' => $latencyMs, 'status_code' => $status],
'severity' => $kind === 'error' ? 'high' : 'low',
];
if ($kind === 'error') {
$err = [
'status_code' => $status,
'retryable' => in_array($status, [408, 429, 500, 502, 503, 504], true),
];
if ($exc instanceof \Throwable) {
$err['exception'] = get_class($exc);
$err['exception_message'] = mb_substr($exc->getMessage(), 0, 500);
}
$payload['error'] = $err;
}
// Trilha: attribute imtbl.trail -> header X-Imtbl-Trail.
$trail = self::sanitizeTrail(
$req->attributes->get('imtbl.trail') ?? $req->headers->get('X-Imtbl-Trail')
);
// Todos os valores de meta precisam ser strings.
$meta = [
'type' => $kind,
'event_name' => $eventName,
'service' => $this->service,
'request_id' => $requestId,
'env' => $this->env,
];
if ($trail !== null) {
$meta['immutable_trail'] = $trail;
}
$headers = [
'Authorization' => "Bearer {$this->apiKey}",
'Content-Type' => 'application/json',
// Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
'Idempotency-Key' => "{$eventName}-{$requestId}",
'Request-Id' => $requestId,
'X-Client-TZ' => 'America/Sao_Paulo',
'X-Client-Offset-Minutes' => '-180',
];
if ($trail !== null) {
$headers['X-Imtbl-Trail'] = $trail;
}
$this->http->request('POST', $this->apiUrl . '/v1/events', [
'headers' => $headers,
'json' => ['payload' => json_encode($payload), 'meta' => $meta],
'timeout' => 5,
]);
} catch (\Throwable $e) {
// Never let audit logging break the application.
}
}
private static function redact(array $data): array
{
foreach ($data as $k => $v) {
if (is_string($k) && in_array(strtolower($k), self::SENSITIVE, true)) {
$data[$k] = '***REDACTED***';
} elseif (is_array($v)) {
$data[$k] = self::redact($v);
}
}
return $data;
}
// Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
private static function sanitizeTrail(?string $value): ?string
{
if ($value === null) {
return null;
}
$v = trim($value);
if ($v === '') {
return null;
}
$v = str_replace(':', '-', $v);
if (mb_strlen($v) > 256) {
$v = mb_substr($v, 0, 256);
}
return $v;
}
}Registration in services.yaml
Register the subscriber by injecting the credentials. HttpClientInterface is autowired automatically. The subscriber is detected via the kernel.event_subscriber tag.
# config/services.yaml
services:
App\EventSubscriber\ImmutableLogSubscriber:
arguments:
$apiKey: '%env(IMTBL_API_KEY)%'
$service: 'my-symfony-service'
$env: '%kernel.environment%'
# $http (HttpClientInterface) é autowired automaticamente.
# A tag kernel.event_subscriber é aplicada via autoconfigure.Never hardcode the token. Set IMTBL_API_KEY in .env.local and never commit it.
How it works
kernel.request
Records request_id and start timestamp
kernel.exception
Stores the unhandled exception on the request
kernel.terminate
Runs after the response — builds and sends the event
Custom event and trail
Set the `imtbl.event_name` and `imtbl.trail` request attributes in any controller. `onTerminate` reads these values when building the event.
<?php
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
class PaymentController
{
#[Route('/payments', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
// Sobrescreve o nome do evento e agrupa numa trilha auditavel.
$request->attributes->set('imtbl.event_name', 'payment.created');
$request->attributes->set('imtbl.trail', 'order-7782');
// ... lógica de negócio / business logic ...
return new JsonResponse(['ok' => true]);
}
}Auto default: http.METHOD.path.
Immutable trail (immutable_trail)
A trail groups related events into a single auditable timeline. Set the `imtbl.trail` request attribute, or propagate it across services with the `X-Imtbl-Trail` header. The value goes through `sanitizeTrail` before sending.
#[Route('/flows/{id}/run', methods: ['POST'])]
public function run(Request $request, string $id): JsonResponse
{
$request->attributes->set('imtbl.trail', "flow-{$id}");
// ... business logic ...
return new JsonResponse(['status' => 'ok']);
}The trail cannot be empty, exceed 256 characters, or contain `:` — violations return `400 invalid_immutable_trail`. `sanitizeTrail` fixes the value before sending.
Response and status codes
The ingestion API is asynchronous. Success returns 202 (new) or 200 (idempotent duplicate). Since sending runs on kernel.terminate, these codes matter mostly if you send events manually.
| Status | Meaning |
|---|---|
| 202 | Event accepted and queued (new) |
| 200 | Idempotency-Key already exists — duplicate: true |
| 400 | Missing/empty Idempotency-Key or invalid_immutable_trail |
| 403 | Inactive/expired subscription, scope or retention |
| 413 | payload_too_large (server limit: 16KB) |
| 429 | monthly_limit_exceeded — do not retry |
| 503 | mempool_full — transient, retryable |
This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.
