Pular para o conteúdo principal
ImmutableLog logo
Voltar
SymfonyPHP

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.

bash
composer require symfony/http-client symfony/uid

Có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
<?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.

yaml
# 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
<?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.

php
#[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.

StatusSignificado
202Evento aceito e enfileirado (novo)
200Idempotency-Key já existente — duplicate: true
400Idempotency-Key ausente/vazia ou invalid_immutable_trail
403Assinatura inativa/expirada, escopo ou retenção
413payload_too_large (limite do servidor: 16KB)
429monthly_limit_exceeded — não fazer retry
503mempool_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.