Programação Por Equipe DevBras

Capa do artigo: display_errors em produção: esconda o erro do PHP sem perder o diagnóstico

display_errors em produção: esconda o erro do PHP sem perder o diagnóstico

Desligue a exibição de erros no lugar certo (php.ini, pool do FPM ou .user.ini), mande tudo para o log com um código de ocorrência e confirme no servidor que a configuração pegou.

Um cliente manda o print: no topo da página de produtos aparece Fatal error: Uncaught mysqli_sql_exception: Access denied for user 'loja'@'localhost' (using password: YES) in /home/casapinho/public_html/inc/conexao.php:4. Numa linha, o visitante aprendeu o usuário do banco, o caminho do servidor e o nome do arquivo de conexão. A resposta curta: em produção, display_errors fica desligado e log_errors ligado, configurados no servidor e não no código; a pessoa vê uma frase com um código, e você encontra o detalhe no log por esse código.

O princípio de que a pilha não vai para a tela está no guia sobre erro 500 com frase e código. Este é o lado específico do PHP: quais diretivas, onde colocá-las para que valham de verdade e como provar que valeram.

Por que o erro aparece na página

O PHP tem duas saídas para um erro: a resposta HTTP e o log. A diretiva display_errors controla a primeira, log_errors a segunda, e error_reporting decide quais níveis contam. Pacotes de desenvolvimento como XAMPP vêm com exibição ligada, porque é cômodo ver o aviso na hora. O problema começa quando o projeto sobe com um ini_set('display_errors', '1') esquecido no topo, ou quando o servidor usa um php.ini de desenvolvimento.

Há um detalhe que pega muita gente: configurar no código não basta. Um erro de sintaxe acontece antes de qualquer linha do arquivo rodar, então o ini_set nunca é executado. E um warning num arquivo incluído antes do seu ini_set já foi impresso. A configuração precisa estar no servidor.

A configuração de produção

Estas são as diretivas que importam. Os valores valem para qualquer servidor de produção:

display_errors = Off
display_startup_errors = Off
log_errors = On
error_reporting = E_ALL
error_log = /var/log/php/casapinho-error.log
expose_php = Off

O error_reporting = E_ALL parece contraditório, mas não é: você quer que todos os erros sejam registrados, só não exibidos. Desligar os avisos para "limpar a tela" esconde também do log o warning que avisaria do problema antes de ele virar queda. O expose_php = Off remove o cabeçalho X-Powered-By, que anuncia a versão do PHP a quem pergunta.

Onde colocar, conforme o seu servidor

  1. Servidor próprio com PHP-FPM: use o arquivo do pool (por exemplo /etc/php/8.3/fpm/pool.d/casapinho.conf, o caminho varia por distribuição e versão) com php_admin_flag e php_admin_value. Valores definidos assim não podem ser alterados por ini_set dentro da aplicação, o que protege contra a linha esquecida.
  2. Apache com mod_php: use php_admin_flag no VirtualHost, com o mesmo efeito.
  3. Hospedagem compartilhada: muitos painéis têm um editor de configuração do PHP. Se o PHP roda como CGI ou FastCGI, um arquivo .user.ini na raiz do site também funciona, mas as mudanças podem levar alguns minutos para valer por causa do cache desse arquivo.
  4. Recarregue o serviço depois de alterar o pool ou o php.ini: sudo systemctl reload php8.3-fpm, ajustando o nome da unidade para a sua versão.
; /etc/php/8.3/fpm/pool.d/casapinho.conf
php_admin_flag[display_errors] = off
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/php/casapinho-error.log

Crie a pasta do log com dono igual ao usuário do pool e configure rotação com logrotate. Log que cresce sem limite enche o disco, e disco cheio derruba o site de um jeito muito pior que um warning na tela.

Um código de ocorrência que liga a tela ao log

Com a exibição desligada, um erro fatal vira uma página em branco ou um 500 cru. Melhor do que isso é uma página com uma frase e um código curto, que a pessoa pode mandar ao suporte. Você procura o código no log e acha a pilha completa.

<?php
// bootstrap.php, incluído no início de toda requisição
declare(strict_types=1);

set_error_handler(function (int $nivel, string $mensagem, string $arquivo, int $linha): bool {
    if (!(error_reporting() & $nivel)) {
        return false;
    }
    if ($nivel & (E_NOTICE | E_USER_NOTICE | E_DEPRECATED | E_USER_DEPRECATED)) {
        return false;
    }
    throw new ErrorException($mensagem, 0, $nivel, $arquivo, $linha);
});

function paginaDeErro(string $codigo): void
{
    if (!headers_sent()) {
        http_response_code(500);
        header('Content-Type: text/html; charset=utf-8');
    }
    echo '<p>Algo deu errado do nosso lado. Se precisar de ajuda, informe o código '
        . htmlspecialchars($codigo, ENT_QUOTES, 'UTF-8') . '.</p>';
}

set_exception_handler(function (Throwable $e): void {
    $codigo = strtoupper(bin2hex(random_bytes(4)));
    error_log('[' . $codigo . '] ' . get_class($e) . ': ' . $e->getMessage()
        . ' em ' . $e->getFile() . ':' . $e->getLine() . PHP_EOL . $e->getTraceAsString());
    paginaDeErro($codigo);
});

register_shutdown_function(function (): void {
    $erro = error_get_last();
    if ($erro !== null && in_array($erro['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
        $codigo = strtoupper(bin2hex(random_bytes(4)));
        error_log('[' . $codigo . '] fatal: ' . $erro['message'] . ' em ' . $erro['file'] . ':' . $erro['line']);
        paginaDeErro($codigo);
    }
});

O set_error_handler transforma warnings em exceções, para que um aviso não passe adiante com a página meio montada. Notices e deprecations voltam para o tratamento padrão do PHP, que só os registra no log: assim uma atualização de versão que deprecia uma função não derruba páginas que ainda funcionam. O register_shutdown_function pega os fatais, que os outros dois handlers não alcançam. Antes de registrar a mensagem, confira se ela não carrega senha ou dado pessoal; o guia de log sem senha mostra o que filtrar.

Se você usa framework, ele já faz esse papel e tem uma chave própria para o modo de depuração: APP_DEBUG=false no Laravel, WP_DEBUG_DISPLAY como falso no WordPress e, no Drupal, a opção de não exibir mensagens de erro na página de log e erros (admin/config/development/logging). Essa chave não substitui o display_errors do servidor, porque o framework só entra depois que o PHP já começou a rodar.

Como provar que a configuração pegou

Rodar php -i no terminal mostra a configuração do PHP de linha de comando, que costuma usar outro php.ini. O que vale é a configuração vista pelo servidor web. Crie temporariamente um arquivo com nome difícil de adivinhar, peça pelo navegador e apague logo em seguida:

<?php
header('Content-Type: text/plain; charset=utf-8');
foreach (['display_errors', 'display_startup_errors', 'log_errors', 'error_reporting', 'error_log', 'expose_php'] as $chave) {
    echo $chave . ' = ' . var_export(ini_get($chave), true) . PHP_EOL;
}

Depois force um erro de propósito num ambiente de homologação com a mesma configuração e confira três coisas: a página mostra só a frase e o código, o log tem a linha com o mesmo código e a resposta não traz X-Powered-By.

Erros comuns

  • Configurar só com ini_set no código: erros de sintaxe e avisos anteriores à linha continuam aparecendo na tela.
  • Baixar o error_reporting para esconder avisos: o log fica mudo e você descobre o problema quando ele já derrubou o site.
  • Conferir com php -i no terminal: você vê a configuração da linha de comando, não a do servidor web, e conclui errado que está tudo desligado.
  • Deixar o arquivo de diagnóstico no ar: um phpinfo() esquecido expõe caminhos, módulos e variáveis de ambiente.
  • Log sem rotação: o disco enche e o site cai por um motivo que não tem nada a ver com o erro original.
  • Copiar a pilha do erro em ticket ou grupo aberto: o mapa que você escondeu da página reaparece em outro lugar.

Checklist

  • display_errors e display_startup_errors estão desligados na configuração do servidor, não só no código.
  • log_errors está ligado e error_reporting é E_ALL.
  • O arquivo de log existe, recebe gravações e tem rotação configurada.
  • A página de erro mostra uma frase e um código que aparece também no log.
  • A resposta HTTP não traz o cabeçalho X-Powered-By.
  • O modo de depuração do framework está desligado em produção.
  • Nenhum arquivo de diagnóstico ficou publicado.

Provoque agora um erro numa página de homologação configurada igual à produção: o que aparece na tela é um código para o suporte ou o caminho completo do seu servidor?

Avalie este artigo

Referência: http://drupal-br.org/node/6870