Integração com Symfony
Integre o ImmutableLog em qualquer aplicação Symfony com um EventSubscriber. O envio acontece em `kernel.terminate` — depois da resposta ser entregue ao cliente — usando o HttpClient nativo do Symfony.
Instalação
O subscriber usa o componente HttpClient e o uid do Symfony.
composer require symfony/http-client symfony/uidCódigo do EventSubscriber
Salve em `src/EventSubscriber/ImmutableLogSubscriber.php`. `onRequest` registra timestamp e request_id; `onException` guarda a exceção; `onTerminate` monta e envia o evento após a resposta. Query params passam por `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;
}
}Registro no services.yaml
Registre o subscriber injetando as credenciais. O HttpClientInterface é autowired automaticamente. O subscriber é detectado pela tag kernel.event_subscriber.
# 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.Nunca coloque o token no código. Defina IMTBL_API_KEY no .env.local e nunca faça commit dele.
Como funciona
kernel.request
Registra request_id e timestamp de início
kernel.exception
Guarda a exceção não tratada na request
kernel.terminate
Roda após a resposta — monta e envia o evento
Evento e trilha customizados
Defina os atributos `imtbl.event_name` e `imtbl.trail` na request dentro de qualquer controller. O `onTerminate` lê esses valores ao montar o evento.
<?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]);
}
}Padrão automático: http.METHOD.path.
Trilha imutável (immutable_trail)
A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. Defina o atributo `imtbl.trail` na request, ou propague entre serviços com o header `X-Imtbl-Trail`. O valor passa por `sanitizeTrail` antes do envio.
#[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']);
}A trilha não pode ser vazia, exceder 256 caracteres ou conter `:` — violações retornam `400 invalid_immutable_trail`. O `sanitizeTrail` corrige o valor antes do envio.
Resposta e códigos de status
A API de ingestão é assíncrona. Sucesso retorna 202 (novo) ou 200 (duplicata idempotente). Como o envio roda em kernel.terminate, esses códigos importam principalmente se você enviar eventos manualmente.
| Status | Significado |
|---|---|
| 202 | Evento aceito e enfileirado (novo) |
| 200 | Idempotency-Key já existente — duplicate: true |
| 400 | Idempotency-Key ausente/vazia ou invalid_immutable_trail |
| 403 | Assinatura inativa/expirada, escopo ou retenção |
| 413 | payload_too_large (limite do servidor: 16KB) |
| 429 | monthly_limit_exceeded — não fazer retry |
| 503 | mempool_full — transitório, pode dar retry |
Esta documentação reflete o comportamento atual da integração. Para dúvidas ou integrações avançadas, entre em contato com o time de suporte.
