Arquitetura SaaS
18 de fevereiro de 2026 · 10 min de leitura
Observabilidade é um daqueles tópicos que não parece urgente — até o momento em que um cliente liga dizendo que algo travou e você não consegue nem confirmar se o problema aconteceu de verdade, quando foi exatamente, e se ainda está happening. Nesse momento, você percebe que logs soltos no console e métricas genéricas de infraestrutura não são observabilidade. São ruído. Em SaaS multi-tenant, o desafio é maior porque uma falha pode afetar um tenant específico, um subconjunto de tenants, ou todos — e sem as correlações certas nos logs, você não consegue distinguir. Este post cobre o que configurar antes do primeiro cliente em produção.
O que observabilidade significa na prática (sem o buzzword)
Observabilidade é a capacidade de responder perguntas sobre o sistema a partir dos dados que ele produz — sem precisar modificar o código para investigar um problema específico.
As três perguntas que você mais vai precisar responder em produção:
- 'O que aconteceu exatamente às 14h37 para o tenant X?'
- 'Essa lentidão está afetando todos os tenants ou só alguns?'
- 'Qual endpoint está causando o aumento de latência desde o último deploy?'
Para responder essas três perguntas, você precisa de três coisas: logs estruturados com contexto de tenant, traces distribuídos correlacionando requests entre serviços, e métricas separadas por tenant. Não necessariamente os sistemas mais sofisticados do mercado — mas os três pilares precisam existir.
Logs estruturados com Serilog: o contexto que não pode faltar
Log estruturado significa que cada entrada de log é um objeto com campos definidos — não uma string de texto livre. A diferença entre os dois:
// Log de texto livre — praticamente inútil para busca em produção
[14:37:22] Erro ao processar pedido do tenant abc123: timeout na conexão com banco
// Log estruturado — filtrável, agregável, correlacionável
{
"timestamp": "2026-05-28T14:37:22Z",
"level": "Error",
"message": "Timeout na conexão com banco",
"tenantId": "3e4f5a6b-...",
"requestId": "a1b2c3d4",
"endpoint": "POST /api/pedidos",
"duracao_ms": 30012,
"exception": "SqlException: Connection timeout"
}
Configuração do Serilog no .NET Core para output em JSON com os campos certos:
// Program.cs
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.WriteTo.Console(new JsonFormatter())
.WriteTo.Seq(serverUrl: configuration["Seq:Url"]!) // ou CloudWatch, Datadog, etc.
.CreateLogger();
builder.Host.UseSerilog();
O Enrich.FromLogContext() é o que permite injetar campos dinamicamente em todos os logs do request — incluindo tenantId e requestId. O middleware que faz essa injeção:
public class LogContextMiddleware
{
private readonly RequestDelegate _next;
public LogContextMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
{
var requestId = context.TraceIdentifier;
using (LogContext.PushProperty("requestId", requestId))
using (LogContext.PushProperty("tenantId", tenantContext.TenantId))
using (LogContext.PushProperty("tenantPlan", tenantContext.Plan))
using (LogContext.PushProperty("endpoint", $"{context.Request.Method} {context.Request.Path}"))
{
await _next(context);
}
}
}
Com esse middleware ativo, qualquer _logger.LogError("...") em qualquer parte da aplicação automaticamente inclui tenantId, requestId e endpoint na entrada de log. O desenvolvedor não precisa lembrar de passar esses campos — eles estão no contexto.
Isolamento de logs: o que um tenant não pode ver
Logs estruturados com tenantId resolvem a correlação — mas criam um risco se você expõe logs para operadores ou clientes: um log mal filtrado pode mostrar dados de outro tenant.
Três regras para manter o isolamento:
Nunca logue dados de negócio que não são seus para logar. Um log de erro pode incluir o tenantId e o ID do recurso que falhou. Não precisa incluir o nome do cliente, o valor da transação, ou qualquer dado sensível do payload. Se o payload inteiro for logado para debug, garanta que isso só aconteça em ambientes não-produção.
// Errado — loga payload inteiro em produção
_logger.LogDebug("Payload recebido: {Payload}", JsonSerializer.Serialize(request));
// Correto — loga apenas identificadores
_logger.LogDebug("Request recebido. TenantId={TenantId} RecursoId={RecursoId}",
tenantId, request.RecursoId);
Separe alertas por tenant de alertas de plataforma. Um aumento de erros no tenant X não deve disparar o mesmo alerta que um aumento de erros na plataforma inteira. Com logs estruturados, você consegue criar queries separadas: tenantId = 'X' AND level = 'Error' vs level = 'Error' sem filtro de tenant.
Nunca exponha logs brutos para o cliente. Se você vai oferecer algum tipo de audit log ou histórico de atividade para o tenant, construa uma interface específica que filtra e formata os dados — nunca acesso direto ao sistema de logs.
Distributed tracing: seguindo um request de ponta a ponta
Quando o sistema tem mais de um serviço — API, Worker, serviço de e-mail, banco — um único request do usuário pode gerar operações em vários desses componentes. Sem tracing, você vê o log de erro no Worker mas não consegue correlacionar com o request HTTP que o originou.
O .NET tem suporte nativo a distributed tracing via System.Diagnostics.Activity. Para exportar esses traces para uma ferramenta de visualização, use OpenTelemetry:
// Program.cs
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
{
options.EnrichWithHttpRequest = (activity, request) =>
{
// Adiciona tenantId como atributo do trace
var tenantId = request.Headers["x-tenant-id"].FirstOrDefault();
if (tenantId != null)
activity.SetTag("tenant.id", tenantId);
};
})
.AddEntityFrameworkCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(configuration["Otlp:Endpoint"]!);
})
);
Com isso, cada request gera um trace com spans para cada operação: a chamada HTTP, as queries no banco, as chamadas para serviços externos. O tenant.id como atributo do trace permite filtrar por tenant no Jaeger, Grafana Tempo, Honeycomb, ou qualquer ferramenta compatível com OpenTelemetry.
No contexto early-stage, não é necessário subir um sistema de tracing completo imediatamente. O que não pode faltar é o requestId sendo propagado entre serviços via header — isso já permite correlacionar logs manualmente enquanto o volume é baixo.
Métricas por tenant: quando o problema é de um cliente específico
Métricas de infraestrutura — CPU, memória, latência média — são úteis para detectar problemas globais. Elas não ajudam a detectar que um tenant específico está com performance degradada enquanto todos os outros estão normais.
Duas métricas por tenant que detectam a maioria dos problemas práticos:
Taxa de erro por tenant — se um tenant começa a ter taxa de erro acima de 1%, você quer saber antes do cliente ligar.
Latência de P95 por tenant — a latência média esconde outliers. P95 (o tempo de resposta abaixo do qual estão 95% das requisições) revela degradação que afeta uma minoria dos requests mas é consistente o suficiente para causar experiência ruim.
Com Prometheus e a biblioteca prometheus-net no .NET:
public class MetricasMiddleware
{
private static readonly Counter _requestCounter = Metrics.CreateCounter(
"http_requests_total",
"Total de requests HTTP",
new CounterConfiguration
{
LabelNames = new[] { "tenant_id", "metodo", "status_code" }
});
private static readonly Histogram _requestDuration = Metrics.CreateHistogram(
"http_request_duration_seconds",
"Duração dos requests HTTP",
new HistogramConfiguration
{
LabelNames = new[] { "tenant_id", "endpoint" },
Buckets = new[] { 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0 }
});
private readonly RequestDelegate _next;
public MetricasMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
{
var sw = Stopwatch.StartNew();
await _next(context);
sw.Stop();
var tenantId = tenantContext.TenantId.ToString();
var endpoint = context.Request.Path.Value ?? "unknown";
var statusCode = context.Response.StatusCode.ToString();
_requestCounter.WithLabels(tenantId, context.Request.Method, statusCode).Inc();
_requestDuration.WithLabels(tenantId, endpoint).Observe(sw.Elapsed.TotalSeconds);
}
}
Um ponto de atenção: com muitos tenants, o número de séries temporais cresce proporcionalmente. Para plataformas com centenas de tenants, considere agrupar tenants por plano em vez de por ID individual — ou use uma ferramenta como Grafana com agregação dinâmica.
SLO por tenant: o que prometer e como medir
SLO (Service Level Objective) é o nível de serviço que você se compromete a entregar. Em SaaS B2B, isso normalmente aparece no contrato como SLA — mas antes de colocar no contrato, você precisa conseguir medir.
Um SLO simples e mensurável para começar:
- Disponibilidade: 99,5% de uptime no mês (equivale a menos de 3h36min de downtime/mês)
- Latência: P95 de responses abaixo de 1 segundo para endpoints críticos
- Erro: taxa de erro abaixo de 0,5% dos requests
O importante é que esses números venham de dados reais — não de expectativa. Se você ainda não tem dados de produção, comece com SLOs conservadores e ajuste para cima conforme a estabilidade do sistema for comprovada.
Para calcular disponibilidade por tenant a partir dos logs:
# Query no Seq, Grafana Loki ou CloudWatch Insights
disponibilidade = (requests_com_sucesso / total_requests) * 100
# Filtrado por tenant
SELECT
tenantId,
COUNT(*) FILTER (WHERE statusCode < 500) * 100.0 / COUNT(*) AS disponibilidade_pct
FROM logs
WHERE timestamp > NOW() - INTERVAL '30 days'
GROUP BY tenantId
HAVING disponibilidade_pct < 99.5;
Rodar essa query semanalmente — ou ter um dashboard que exibe continuamente — é o mínimo para saber se você está cumprindo os SLOs antes que um cliente perceba que não está.
O stack mínimo que funciona sem custo proibitivo no early-stage
A tentação ao montar observabilidade é escolher o stack mais completo possível logo de início. Datadog, New Relic, Honeycomb — são ótimas ferramentas, mas com custo que não faz sentido quando você tem dez tenants.
Um stack que cobre os fundamentos com custo controlado:
Logs: Seq (self-hosted no Docker, gratuito para desenvolvimento e times pequenos) ou CloudWatch Logs (pago por ingestão, mas integrado nativamente se você já está na AWS). Ambos suportam queries estruturadas e alertas.
Traces: Jaeger self-hosted para começar. Quando o volume crescer e o custo de operação superar o custo de uma ferramenta gerenciada, migra para Grafana Tempo ou Honeycomb.
Métricas: Prometheus + Grafana. O Grafana Cloud tem um tier gratuito generoso para times pequenos. Para quem já está na AWS, CloudWatch Metrics com dashboards customizados também funciona bem.
Alertas: qualquer uma das opções acima suporta alertas por e-mail ou Slack. Defina alertas para pelo menos três situações: taxa de erro acima de 1% nos últimos 5 minutos, latência P95 acima de 2s, e DLQ com mensagens acumulando.
O que não pode faltar, independente de qual stack você escolher: tenantId em todos os logs, requestId propagado entre serviços, e pelo menos um dashboard que mostre saúde por tenant — não só saúde global da plataforma.
Se você quer montar a camada de observabilidade do seu SaaS antes que os problemas em produção apareçam sem você conseguir depurar — fala com a Logik Digital.