Construindo um SaaS do zero, parte 7: erros e logs em produção
Parte 7 da série: leia logs de edge functions no Supabase, registre erros do frontend e monte um alerta simples antes de o cliente reclamar.
Sumário do artigo

Seu SaaS está no ar desde a parte 5 e já manda e-mails desde a parte 6. Agora ele passa a falhar de jeitos que você não vê. O cliente que encontra uma tela branca raramente abre chamado: ele fecha a aba e não volta.
Esta parte monta três camadas de visibilidade: logs estruturados nas edge functions do Supabase, um registro de erros do frontend em uma tabela com RLS e uma consulta que serve de base para alerta. Tudo funciona com o stack da série (Lovable, React, TypeScript e Supabase), sem serviço pago extra.
Onde cada erro aparece
Cada camada do SaaS guarda seus erros em um lugar diferente, e o primeiro passo é saber qual olhar.
| Camada | Exemplo de falha | Onde olhar |
|---|---|---|
| Edge function | Exceção ao chamar uma API externa | Logs da função, no painel do Supabase |
| Banco e API | Policy de RLS negando um insert | Logs do Postgres e da API, na seção Logs |
| Auth | Login recusado ou token expirado | Logs de Auth, na seção Logs |
| Frontend | Tela branca por exceção no React | Console do navegador do usuário, que você não enxerga |
A última linha é a que mais pega fundador de surpresa. O erro acontece no navegador de outra pessoa, e sem captura explícita ele não chega a lugar nenhum.
Passo 1: log estruturado nas edge functions
Dentro de uma edge function, console.log e console.error vão para os logs da função. O problema é o formato livre: uma linha como "deu erro aqui" não se filtra nem se agrupa. Registre cada linha como JSON, com um nível, uma mensagem e um identificador da requisição.
// supabase/functions/criar-cobranca/index.ts
type Nivel = "info" | "error";
const log = (nivel: Nivel, msg: string, extra: Record<string, unknown> = {}) => {
const linha = JSON.stringify({ nivel, msg, ...extra });
if (nivel === "error") console.error(linha);
else console.log(linha);
};
Deno.serve(async (req) => {
const requestId = crypto.randomUUID();
try {
log("info", "inicio", { requestId, caminho: new URL(req.url).pathname });
// sua lógica aqui
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
log("error", "falha_nao_tratada", {
requestId,
erro: err instanceof Error ? err.message : String(err),
});
return new Response(JSON.stringify({ ok: false, requestId }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
});
Três detalhes deste trecho evitam dor de cabeça depois:
JSON.stringify(err)em um objetoErrordevolve{}, porquemessageestacknão são enumeráveis. Por isso o código extraierr.messageantes de logar.- A resposta 500 devolve o
requestId. Quando o cliente reportar o problema, ele manda esse código e você acha a linha exata no log. - Nunca coloque token, senha, e-mail completo ou dado de cartão em
extra. O log é um segundo banco de dados, e ninguém aplica RLS nele.
Passo 2: consultar os logs no Logs Explorer
No painel do Supabase, a seção Logs tem um Logs Explorer que aceita SQL sobre as tabelas de log, entre elas function_logs para o que as edge functions escrevem. Use o template de função que o próprio painel oferece e acrescente o filtro pela mensagem que você padronizou:
select
id,
function_logs.timestamp,
event_message,
metadata.function_id,
metadata.level
from function_logs
cross join unnest(metadata) as metadata
where event_message like '%falha_nao_tratada%'
order by timestamp desc
limit 50
Se o nome de alguma coluna divergir na sua versão do painel, parta do template que ele gera e mantenha só o where e o order by. O filtro por falha_nao_tratada só funciona porque o log do passo 1 usa sempre a mesma mensagem.
Passo 3: uma tabela para os erros do frontend
Crie uma tabela onde o navegador registra o que quebrou. A regra de RLS é a mesma da parte 3: o usuário só escreve linhas com o próprio user_id, validado em with check.
create table public.app_errors (
id uuid primary key default gen_random_uuid(),
created_at timestamptz not null default now(),
user_id uuid references auth.users (id) on delete set null,
source text not null check (source in ('frontend', 'edge')),
message text not null,
stack text,
route text,
request_id text
);
alter table public.app_errors enable row level security;
create policy "usuario registra o proprio erro"
on public.app_errors
for insert
to authenticated
with check (user_id = auth.uid());
A tabela não tem policy de select. Com RLS ligado e sem policy de leitura, nenhum usuário consegue ler as linhas pelo client. Você consulta pelo SQL Editor ou por uma edge function com a service role, que ignora RLS.
Passo 4: capturar erros no React
Um Error Boundary pega exceções lançadas durante a renderização. Ele não pega erro dentro de event handler nem em código assíncrono, então são necessários também os listeners globais error e unhandledrejection.
// src/lib/reportError.ts
import { supabase } from "@/integrations/supabase/client";
export async function reportError(message: string, stack?: string) {
try {
const { data } = await supabase.auth.getUser();
if (!data.user) return;
await supabase.from("app_errors").insert({
user_id: data.user.id,
source: "frontend",
message: message.slice(0, 500),
stack: stack?.slice(0, 4000),
route: window.location.pathname,
});
} catch {
// registrar erro nunca pode gerar outro erro
}
}
// src/components/ErrorBoundary.tsx
import { Component, type ReactNode } from "react";
import { reportError } from "@/lib/reportError";
type Props = { children: ReactNode };
type State = { falhou: boolean };
export class ErrorBoundary extends Component<Props, State> {
state: State = { falhou: false };
static getDerivedStateFromError(): State {
return { falhou: true };
}
componentDidCatch(error: Error) {
void reportError(error.message, error.stack);
}
render() {
return this.state.falhou ? (
<p>Algo deu errado. Recarregue a página e tente de novo.</p>
) : (
this.props.children
);
}
}
// no ponto de entrada da aplicação
window.addEventListener("error", (e) => void reportError(e.message, e.error?.stack));
window.addEventListener("unhandledrejection", (e) => void reportError(String(e.reason)));
Os slice limitam o tamanho do que entra na tabela. Sem esse limite, um loop de erro enche o banco em minutos. Para frear isso de vez, aplique a lógica de limite da matéria sobre rate limit, timeout e retry.
Se você precisa de agrupamento automático de erros iguais, source maps e alertas prontos, o Sentry cobre isso com o pacote @sentry/react: Sentry.init({ dsn }) na inicialização e Sentry.captureException(error) onde hoje você chama reportError. A tabela própria atende bem a fase inicial, e a troca depois é pequena.
Passo 5: a consulta que vira alerta
Com os erros em uma tabela, o alerta é uma consulta. Esta devolve quantos erros chegaram na última hora por origem:
select source, count(*) as total, max(created_at) as ultimo
from public.app_errors
where created_at > now() - interval '1 hour'
group by source;
O fluxo completo é uma edge function que roda essa contagem, compara com um limite que você escolhe e dispara o e-mail transacional da parte 6 quando passa dele. Para a função rodar sozinha a cada hora, o sábado tem um post inteiro sobre agendar rotinas com pg_cron e edge functions.
Erros comuns
| Erro | Efeito | Como evitar |
|---|---|---|
catch vazio |
A falha some sem rastro | Sempre logar com log("error", ...) |
| Retornar 200 com erro dentro do corpo | Logs e monitores tratam como sucesso | Usar status 4xx ou 5xx de verdade |
Logar o objeto Error direto |
Aparece {} no log |
Extrair message e stack |
| Logar dados pessoais | Vazamento por um canal sem RLS | Registrar só IDs e códigos |
Policy de select aberta em app_errors |
Usuário lê erros de outros | Manter a tabela sem leitura pelo client |
O que fazer agora
- Troque o
catchde cada edge function pelo padrão do passo 1, comrequestIdna resposta de erro. - Rode o SQL do passo 3 no SQL Editor e confirme que um usuário logado consegue inserir, mas não consegue fazer
selectna tabela. - Envolva a árvore principal do app com o
ErrorBoundarye registre os dois listeners globais. - Provoque um erro de propósito em ambiente de teste, confira a linha em
app_errorse a linha correspondente no Logs Explorer. - Salve a consulta do passo 5 no SQL Editor e rode por uma semana para descobrir qual é o seu volume normal de erros antes de definir o limite do alerta.
Não perca a próxima edição.
Toda quinta, 9h. Direto na sua caixa.
- Ferramentas que economizam horas do seu trabalho
- Agentes e automações que funcionam
- Bastidores do que estamos construindo