Voltar ao blog

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.

C
Caio Braga
20 de outubro de 2026 · 9 min de leitura
Sumário do artigo
Construindo um SaaS do zero, parte 7: erros e logs em produção

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 objeto Error devolve {}, porque message e stack não são enumeráveis. Por isso o código extrai err.message antes 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

  1. Troque o catch de cada edge function pelo padrão do passo 1, com requestId na resposta de erro.
  2. Rode o SQL do passo 3 no SQL Editor e confirme que um usuário logado consegue inserir, mas não consegue fazer select na tabela.
  3. Envolva a árvore principal do app com o ErrorBoundary e registre os dois listeners globais.
  4. Provoque um erro de propósito em ambiente de teste, confira a linha em app_errors e a linha correspondente no Logs Explorer.
  5. 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.
Tags
#saas#supabase#logs#lovable#monitoramento
● Não perca essa chance

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

100% gratuito. Cancele quando quiser.

Compartilhar