Bugs corrigidos: - aula-15: tracing.js fallback OTel endpoint usava service name errado (otel-collector vs otel-collector-opentelemetry-collector) - aula-11/13: manifests k8s com gitea.kube.quest hardcoded → placeholder Arquivos legado removidos (9): - aula-10: gitlab-values.yaml, gitlab-registry-storage-secret.yaml, object-storage-secret.yaml, registry-storage-secret.yaml - aula-11: gitlab-runner-values.yaml, node-bugado/.gitlab-ci.yml - aula-13: 3x .gitlab-ci.yml (substituídos por .gitea/workflows/ci.yml) CLAUDE.md: comandos rápidos agora incluem aula-14 e aula-15
Aula 15 - APM: De "está lento" para "aqui está o N+1" (Grafana Tempo + OpenTelemetry)
Métricas dizem O QUE está errado. Traces dizem POR QUE.
O problema que esta aula resolve
Cenário real: uma aplicação começa rápida. Um agente de IA (ou um dev apressado) usa um ORM e mete um N+1. A base de dados cresce. A latência sobe de 50ms para 500ms ao longo de um mês.
O Victoria Metrics (aula-12) mostra que a latência subiu. Mas não mostra por quê. Você sabe que está lento — mas é o banco? A rede? Um serviço externo? Um loop de queries?
Sem traces, o debug é:
1. Olhar logs (milhares de linhas)
2. Adicionar console.log/dd() no código
3. Deploiar versão com debug
4. Esperar acontecer de novo
5. Repetir
Com traces, o debug é:
1. Abrir Grafana
2. Clicar no spike de latência
3. Ver o trace: 1 request HTTP → 147 queries SQL
4. Encontrar o N+1
Diferença: horas/dias vs. 30 segundos.
O Limiar de Doherty
Em 1982, Walter Doherty e Ahrvind Thadani publicaram um estudo na IBM Systems Journal mostrando que quando o tempo de resposta de um sistema passa de 400ms, o usuário perde o senso de "fluxo" — a interação deixa de parecer instantânea.
0-100ms → Instantâneo (o usuário nem percebe)
100-400ms → Rápido (o usuário nota mas tolera)
400ms-1s → Lento (perde o "flow state", perde engagement)
1s+ → Quebrado (o usuário sai)
O perigo do intervalo 100-400ms → 400ms-1s: o usuário não reclama. Ele simplesmente usa menos. A retenção cai silenciosamente. Nenhuma métrica de infraestrutura (CPU, RAM, disco) vai detectar isso — tudo parece normal.
O que detecta: latência por rota medida com OpenTelemetry + alertas no Victoria Metrics.
O que instalamos
┌─────────────────────────────────────────────────────────────────┐
│ Stack Completo │
│ │
│ App (Node.js / Laravel / qualquer linguagem) │
│ │ │
│ │ OpenTelemetry SDK (auto-instrumentação) │
│ │ │
│ ├──► Métricas ──► Victoria Metrics (aula-12, já instalado) │
│ │ └──► Grafana: dashboard RED │
│ │ │
│ └──► Traces ──► Grafana Tempo (esta aula) │
│ └──► Grafana: trace view │
│ │
│ Grafana conecta os dois: │
│ dashboard latência (VM) → clica → abre trace (Tempo) │
│ → vê cada SQL query │
│ → encontra o N+1 │
└─────────────────────────────────────────────────────────────────┘
Escolha do Tempo (e não Jaeger, SigNoz, etc.)
| Jaeger | SigNoz | Grafana Tempo | |
|---|---|---|---|
| Storage | Cassandra/Elasticsearch | ClickHouse | Local disk ou S3 (sem DB) |
| RAM mínima | ~512Mi+ | ~3Gi | ~256Mi |
| UI | Própria (mais uma URL) | Própria | Grafana (já temos) |
| Query language | Tags básicas | ClickHouse SQL | TraceQL |
| Metrics↔Traces | Manual | Próprio | Nativo no Grafana |
Tempo segue a mesma filosofia do workshop: single binary, sem dependências extras, integra com o que já temos.
Conceitos
O que é um Trace
Um trace é a jornada completa de um request pelo sistema:
Trace: GET /api/users (total: 487ms)
│
├── [Span] HTTP GET /api/users .................. 487ms
│ ├── [Span] middleware.auth .................. 2ms
│ ├── [Span] UserController.index ............ 480ms
│ │ ├── [Span] SELECT * FROM users ......... 3ms ← 1 query
│ │ ├── [Span] SELECT * FROM posts WHERE ... 3ms ← N+1 começa aqui
│ │ ├── [Span] SELECT * FROM posts WHERE ... 3ms
│ │ ├── [Span] SELECT * FROM posts WHERE ... 3ms
│ │ ├── ... (x147 vezes)
│ │ └── [Span] SELECT * FROM posts WHERE ... 3ms ← 147 queries
│ └── [Span] JSON serialize .................. 5ms
└── total SQL time: 441ms / 147 queries
Cada caixa é um span. O trace é a árvore completa.
RED Method (métricas por rota)
O padrão ouro de monitoramento de aplicações:
| Métrica | O que mede | Alerta quando |
|---|---|---|
| Rate | Requests por segundo por rota | Drop súbito (app caiu?) |
| Errors | % de respostas 5xx por rota | > 1% (algo quebrou) |
| Duration | Latência p50, p95, p99 por rota | p95 > 400ms (limiar de Doherty) |
OpenTelemetry gera essas métricas automaticamente. Victoria Metrics armazena. Grafana mostra.
Diferença: Métricas vs Traces
| Métricas (Victoria Metrics) | Traces (Tempo) | |
|---|---|---|
| Granularidade | Agregado (p99 de todos os requests) | Individual (cada request) |
| Custo | Baixo (números comprimidos) | Alto (cada span é um evento) |
| Responde | "A latência da rota /api/users está alta" | "Este request específico fez 147 queries SQL" |
| Analogia | Termômetro (temperatura do corpo) | Raio-X (o que está causando a febre) |
Use métricas pra detectar. Use traces pra diagnosticar.
Pré-requisitos
- Cluster Kubernetes Hetzner (aula-08)
- Victoria Metrics + Grafana (aula-12)
- kubectl e helm instalados
Estrutura
aula-15/
├── README.md
├── setup.sh # Instala Tempo + OTel Collector
├── cleanup.sh
│
├── tempo-values.yaml # Config do Grafana Tempo
├── otel-collector-values.yaml # Config do OpenTelemetry Collector
│
├── demo-app/ # App Node.js com N+1 intencional
│ ├── Dockerfile
│ ├── app.js # Servidor com rotas rápidas e lentas
│ ├── package.json
│ └── k8s/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── ingress.yaml
│
├── dashboards/
│ └── red-dashboard.json # Dashboard RED pra Grafana
│
└── alerts/
└── latency-alerts.yaml # VMRule: alerta quando p95 > 400ms
Instalação
cd aula-15
export KUBECONFIG=$(pwd)/../aula-08/kubeconfig
./setup.sh
O script instala:
- Grafana Tempo — backend de traces (namespace: monitoring)
- OpenTelemetry Collector — recebe traces das apps e envia pro Tempo
- Demo app — aplicação Node.js com N+1 intencional
- Dashboard RED — latência/rate/errors por rota
- Alerta de latência — dispara quando p95 > 400ms (Doherty)
Arquitetura
┌──────────────────────────────────────────────────────────────┐
│ Namespace: demo │
│ │
│ ┌──────────────────────────────────┐ │
│ │ demo-app (Node.js) │ │
│ │ @opentelemetry/auto-instrument │ │
│ │ │ │
│ │ GET /fast → 1 SQL query │ │
│ │ GET /slow → N+1 (100+ SQL) │ │
│ └──────────┬───────────────────────┘ │
│ │ OTLP (gRPC :4317) │
└─────────────┼────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Namespace: monitoring │
│ │
│ ┌────────────────────┐ │
│ │ OTel Collector │ │
│ │ │ │
│ │ Recebe OTLP ──────┬──► Grafana Tempo (traces) │
│ │ │ │
│ │ Gera métricas ─────┴──► Victoria Metrics (RED metrics) │
│ └────────────────────┘ │
│ │
│ ┌────────────────────┐ │
│ │ Grafana │ │
│ │ │ │
│ │ Dashboard RED ◄──── Victoria Metrics │
│ │ (latência p95) │ │
│ │ │ │ │
│ │ click spike │ │
│ │ ▼ │ │
│ │ Trace view ◄─────── Tempo │
│ │ (147 SQL spans) │ │
│ └────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Por que o OTel Collector?
A app poderia enviar direto pro Tempo. Mas o Collector faz duas coisas cruciais:
- Deriva métricas dos traces — gera automaticamente
http_server_request_duration(histograma por rota) sem instrumentar a app pra métricas separadamente - Desacopla — a app envia pra um endpoint fixo (
otel-collector:4317), o backend pode mudar sem tocar na app
A Demo App: node-bugado-n1
Uma aplicação Node.js simples com duas rotas:
// GET /fast — 1 query, resposta em ~5ms
app.get('/fast', async (req, res) => {
const users = await db.query('SELECT * FROM users LIMIT 10');
res.json(users);
});
// GET /slow — N+1, resposta em ~500ms
app.get('/slow', async (req, res) => {
const users = await db.query('SELECT * FROM users');
// N+1: uma query por usuário pra buscar posts
for (const user of users) {
user.posts = await db.query('SELECT * FROM posts WHERE user_id = ?', [user.id]);
}
res.json(users);
});
O OpenTelemetry auto-instrumento captura cada query SQL como um span — sem mudar o código.
Fluxo da demonstração
1. Gerar tráfego
# Rota rápida (1 query)
for i in $(seq 1 100); do curl -s https://demo.kube.quest/fast > /dev/null; done
# Rota lenta (N+1)
for i in $(seq 1 100); do curl -s https://demo.kube.quest/slow > /dev/null; done
2. Ver no dashboard RED (Grafana)
Abrir https://grafana.kube.quest → Dashboard "RED - Application":
- Rate:
/faste/slowcom ~100 requests cada - Errors: 0% (não é erro — é lentidão)
- Duration:
/fastp95 = 5ms,/slowp95 = 500ms
O alerta "Doherty Threshold" dispara pra /slow.
3. Clicar no spike → ver o trace
No gráfico de latência, clicar num ponto da rota /slow:
- Grafana abre o trace no Tempo
- Trace mostra: 1 span HTTP → 1 span Controller → 100 spans SQL
- Cada span SQL:
SELECT * FROM posts WHERE user_id = ? - N+1 encontrado em 30 segundos
4. Comparar com /fast
Clicar num ponto da rota /fast:
- Trace mostra: 1 span HTTP → 1 span Controller → 1 span SQL
SELECT * FROM users LIMIT 10- Latência: 5ms
A diferença é visual e imediata.
Alertas
Doherty Threshold (latência > 400ms)
# alerts/latency-alerts.yaml
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
name: latency-alerts
namespace: monitoring
spec:
groups:
- name: application.latency
rules:
- alert: DohertyThresholdExceeded
expr: |
histogram_quantile(0.95,
sum(rate(http_server_request_duration_seconds_bucket[5m])) by (le, http_route)
) > 0.4
for: 5m
labels:
severity: warning
annotations:
summary: "Rota {{ $labels.http_route }} acima do limiar de Doherty"
description: "p95 = {{ $value | humanizeDuration }}. Acima de 400ms, usuários perdem o senso de fluxo."
- alert: HighErrorRate
expr: |
sum(rate(http_server_request_duration_seconds_count{http_status_code=~"5.."}[5m])) by (http_route)
/ sum(rate(http_server_request_duration_seconds_count[5m])) by (http_route)
> 0.01
for: 5m
labels:
severity: critical
annotations:
summary: "Rota {{ $labels.http_route }} com {{ $value | humanizePercentage }} de erros"
Quanto custa em recursos
| Componente | Memory Request | Memory Limit |
|---|---|---|
| Grafana Tempo | 256Mi | 512Mi |
| OTel Collector | 128Mi | 256Mi |
| Demo app | 64Mi | 128Mi |
| Total adicional | ~448Mi | ~896Mi |
Tempo armazena traces em disco (10Gi PVC). Retenção: 7 dias por padrão.
Lições do Workshop
- Métricas ≠ Traces — métricas são o termômetro, traces são o raio-X. Use os dois.
- O limiar de Doherty (400ms) é onde a UX degrada silenciosamente. Meça latência por rota.
- N+1 é invisível sem traces — a app não dá erro, o CPU não sobe, o disco não enche. Só o usuário sofre.
- OpenTelemetry é o padrão — instrumenta uma vez, manda pra qualquer backend (Tempo, Jaeger, Datadog).
- OTel Collector como hub — deriva métricas RED dos traces automaticamente. Uma instrumentação, dois sinais.
- Grafana conecta tudo — métricas (Victoria Metrics) + traces (Tempo) na mesma UI, com correlação nativa.
O que NÃO cobrimos (e por quê)
| Tema | Por que não | Quando faz sentido |
|---|---|---|
| Logs (Loki/VictoriaLogs) | Logs são texto desestruturado — traces são melhores pra debug | Compliance, auditoria, erros não-HTTP |
| Distributed tracing multi-serviço | Workshop tem apps simples | Quando tiver 5+ microserviços |
| Continuous Profiling (Pyroscope) | Nível muito avançado | Quando traces não bastam (CPU profiling) |
| Real User Monitoring (RUM) | Precisa de frontend instrumentado | SPA com métricas de UX |
Cleanup
./cleanup.sh
Remove Tempo, OTel Collector e demo app. Victoria Metrics (aula-12) é mantido.