Pular para o conteúdo principal
ImmutableLog logo
Voltar
LaravelPHP

Integração com Laravel

Integre o ImmutableLog em qualquer aplicação Laravel com um middleware terminável. O método `terminate()` roda depois da resposta ser enviada ao cliente — ou seja, o envio é naturalmente fire-and-forget, sem adicionar latência.

Instalação

O middleware usa o Guzzle (já presente na maioria dos projetos Laravel) e o ramsey/uuid (incluso no framework).

bash
# Guzzle já vem no Laravel; instale se necessário:
composer require guzzlehttp/guzzle

Código do middleware

Salve em `app/Http/Middleware/ImmutableLogMiddleware.php`. O `handle()` registra timestamp e request_id; o `terminate()` monta o evento e envia após a resposta. Query params passam por `redact()`.

php
<?php
// app/Http/Middleware/ImmutableLogMiddleware.php
namespace App\Http\Middleware;

use Closure;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Ramsey\Uuid\Uuid;

class ImmutableLogMiddleware
{
    // 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 handle(Request $request, Closure $next)
    {
        $request->attributes->set(
            'imtbl.request_id',
            $request->header('X-Request-Id') ?? Uuid::uuid4()->toString()
        );
        $request->attributes->set('imtbl.started_at', microtime(true));

        return $next($request);
    }

    // terminate() roda DEPOIS da resposta ser enviada — fire-and-forget natural.
    public function terminate(Request $request, $response): void
    {
        try {
            $apiKey = env('IMTBL_API_KEY');
            if (empty($apiKey)) {
                return;
            }
            if (in_array($request->getPathInfo(), ['/health', '/healthz', '/metrics'], true)) {
                return;
            }

            $startedAt = $request->attributes->get('imtbl.started_at', microtime(true));
            $latencyMs = (int) ((microtime(true) - $startedAt) * 1000);
            $status    = $response->getStatusCode();
            $requestId = $request->attributes->get('imtbl.request_id');

            $kind = $status >= 400 ? 'error'
                : ($status >= 300 ? 'info' : ($status >= 200 ? 'success' : 'info'));
            $eventName = $request->attributes->get('imtbl.event_name')
                ?? 'http.' . $request->method() . '.' . $request->path();

            $context = ['ip' => $request->ip(), 'user_agent' => $request->userAgent() ?? 'unknown'];
            if ($user = $request->user()) {
                $context['user_id'] = $user->getAuthIdentifier();
                $context['email']   = $user->email ?? null;
            }

            $payload = [
                'id'        => Uuid::uuid4()->toString(),
                'kind'      => $kind,
                'message'   => $request->method() . ' /' . $request->path() . ' -> ' . $status,
                'timestamp' => gmdate('c'),
                'context'   => $context,
                'request'   => [
                    'request_id'   => $requestId,
                    'method'       => $request->method(),
                    'path'         => '/' . $request->path(),
                    // query params passam por redact() — nunca enviar segredos em claro.
                    'query_params' => self::redact($request->query()) ?: null,
                ],
                'metrics'   => ['latency_ms' => $latencyMs, 'status_code' => $status],
                'severity'  => $kind === 'error' ? 'high' : 'low',
            ];
            if ($kind === 'error') {
                $payload['error'] = [
                    'status_code' => $status,
                    'retryable'   => in_array($status, [408, 429, 500, 502, 503, 504], true),
                ];
            }

            // Trilha: attribute imtbl.trail -> header X-Imtbl-Trail.
            $trail = self::sanitizeTrail(
                $request->attributes->get('imtbl.trail') ?? $request->header('X-Imtbl-Trail')
            );

            // Todos os valores de meta precisam ser strings.
            $meta = [
                'type'       => $kind,
                'event_name' => $eventName,
                'service'    => env('IMTBL_SERVICE_NAME', 'laravel-service'),
                'request_id' => $requestId,
                'env'        => app()->environment(),
            ];
            if ($trail !== null) {
                $meta['immutable_trail'] = $trail;
            }

            $headers = [
                'Authorization' => "Bearer {$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;
            }

            (new Client(['timeout' => 5]))->post(
                (env('IMTBL_URL') ?: 'https://api.immutablelog.com') . '/v1/events',
                ['headers' => $headers, 'json' => ['payload' => json_encode($payload), 'meta' => $meta]]
            );
        } 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 do middleware

No Laravel 11+, registre no `bootstrap/app.php`. Em versões anteriores, adicione à propriedade `$middleware` do `app/Http/Kernel.php`.

php
<?php
// Laravel 11+ — bootstrap/app.php
use App\Http\Middleware\ImmutableLogMiddleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(function (Middleware $middleware) {
        // Global: captura todas as requisições.
        $middleware->append(ImmutableLogMiddleware::class);
    })
    ->create();

// Laravel <= 10 — app/Http/Kernel.php
// protected $middleware = [
//     // ...
//     \App\Http\Middleware\ImmutableLogMiddleware::class,
// ];

Nunca coloque o token no código. Defina IMTBL_API_KEY no .env e garanta que o .env está no .gitignore.

Como funciona

handle()

Registra request_id e timestamp de início

next($request)

A aplicação processa e a resposta é enviada ao cliente

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 `terminate()` lê esses valores ao montar o evento.

php
<?php
use Illuminate\Http\Request;

class PaymentController
{
    public function store(Request $request)
    {
        // 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 response()->json(['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::post('/flows/{id}/run', function (Request $request, string $id) {
    $request->attributes->set('imtbl.trail', "flow-{$id}");
    // ... business logic ...
    return response()->json(['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 middleware roda em 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.