Aparência
Observabilidade
O Khronos emite três coisas diferentes, e confundi-las leva a números errados:
| Saída | Ferramenta | Para quê |
|---|---|---|
| Eventos estruturados (logrus) | Graylog | Reconstruir um login passo a passo |
| Métricas (Prometheus) | Grafana | Agregados, alertas, SLO |
| Log operacional (slog) | stdout | Requisições HTTP, erros de bootstrap, cron |
Trace id
Cada requisição HTTP registrada no huma recebe um trace_id no RequestLogger, que:
- escreve o header
X-Trace-IDna resposta (antes e depois do handler); - injeta o valor no contexto sob
constant.ContextKeyTraceID; - é propagado nas chamadas de saída por
utils.SetTraceHeader(request).
O cron gera o seu próprio (uuid.New()) e injeta no contexto da mesma forma, então um refresh em background também é rastreável de ponta a ponta.
Para investigar um login:
trace_id:"6f6c6f2d-…"Eventos de login
Formato
Todo evento tem a mesma forma, produzida por emitProviderEvent em internal/services/events.go:
json
{
"message": "khronos-latam-event",
"event": "latam.oauth",
"step": "oauth",
"provider": "latam",
"username": "12345678900",
"trace_id": "6f6c6f2d-…",
"outcome": "error",
"duration_ms": 1834,
"status_class": "4xx",
"error_code": "invalid_credentials",
"error": "error on latam login response status 401 Unauthorized | body {...}"
}message—khronos-{provider}-event. Um por companhia, o que torna o stream selecionável só pela mensagem.event—{provider}.{step}. Identifica a observação sozinho.step— o estágio. Antes os dois campos guardavam a mesma string, deixando um deles sem informação.
Campos permitidos
A construção do evento usa uma allowlist, não os campos que o chamador passou:
go
var eventFieldAllowlist = []string{
"outcome", "duration_ms", "status_class", "login_path",
"attempt", "retried_step", "error_code", "error",
}É o que garante que credenciais, payloads, tokens, cookies e URLs não cheguem ao Graylog — mesmo que um chamador futuro coloque isso no mapa de fields.
Steps
step | Emitido por | Significado |
|---|---|---|
request | Handler | O evento terminal, um por requisição |
cache_get / cache_set / cache_delete | Services | Operações de cache |
path_selected | Services | Qual dos três caminhos foi tomado |
login | Services | Chamada de login na companhia |
oauth / oauth_refresh / oauth_mfa | Services | Chamadas OAuth |
verify / verify_mfa | LATAM | Verificação do código MFA |
authenticators / challenge | Smiles | Descoberta e disparo do canal de MFA |
mfa_login | Smiles | A troca de MFA inteira |
document_status | Smiles | Consulta de bloqueio da conta |
guest_login | Smiles | Geração de cookies |
xp_session | LATAM | Geração de cookies |
session | Azul | Montagem do cookie de sessão |
session_check | Azul | A sessão em cache ainda vive? |
account | Azul | Consulta de dados da conta |
login_retry | Azul | Uma retentativa começou |
pigeon_get_mfa_code | Pigeon | Busca do código, no fluxo da companhia |
Outcomes
outcome | Significa |
|---|---|
success | Deu certo |
error | Falhou (e só nesse caso há error_code e error) |
skipped | O passo não rodou nesta execução |
hit / miss / invalid / expired | Estados de leitura de cache |
valid | Verificação de sessão passou (Azul) |
retried | Uma retentativa foi disparada |
miss, invalid e expired não são erro
Eles não carregam error_code. Marcá-los como erro tornava error_code:* inutilizável como filtro de falhas — toda expiração normal aparecia como incidente.
O evento terminal
Cada requisição de login emite exatamente um {provider}.login.completed, no defer do handler:
go
defer func() {
services.EmitLoginCompleted(ctx, constant.CiaLatam, input.Body.Username, started, err)
}()Conte erros só no evento terminal
Os eventos de passo dizem onde a requisição falhou; o terminal diz que ela falhou, uma vez. Somar os dois conta a mesma falha duas ou três vezes. Consultas de taxa de erro devem filtrar event:"*.login.completed".
error_code vs error
error_code— o bucket, vindo demetrics.ClassifyLoginError. É o campo para agregar.error— o texto real do que quebrou, redigido. É o campo para ler.
Quando um passo mapeia a falha do upstream em uma sentinela, a causa original é preservada com stepFieldsWithCause(err, cause, started): error_code vem do erro retornado (mantendo a classificação e o status HTTP), enquanto error carrega o texto que o mapeamento descartaria.
Sem isso, o log lia Usuario e/ou senha invalido(s) e nunca mostrava a resposta por trás.
Redaction
Todo erro que vai para log passa por utils.RedactError. Ele mascara:
| Padrão | Exemplo |
|---|---|
| Chaves sensíveis em JSON | "password":"[REDACTED]" |
Pares chave=valor | keyToken=[REDACTED] |
| Credenciais em URL | https://[REDACTED]@proxy:8080 |
A lista de chaves cobre password, access_token, refresh_token, id_token, mfa_token, key_token, client_secret, authorization, cookie, set-cookie, proxy_url, token e as variações em camelCase — porque os upstreams usam as duas grafias e \b não separa keyToken.
O resultado é truncado em 512 bytes, em fronteira de rune válida, para que uma resposta grande do upstream não inunde o Graylog com um único campo.
Chaves mais longas vêm antes
A ordem da alternação no regex é intencional: access_token precisa vir antes de token, senão o mascaramento pegaria só o sufixo da chave.
Log operacional (slog)
O RequestLogger escreve uma linha por requisição:
[6f6c6f2d-…] 2026/08/07 - 12:00:00 | 200 | 1.234567s | 10.0.0.1:52344 | POST | /latam/loginTambém usam slog: o bootstrap (starting API, connected to redis), o graceful shutdown e o cron.
Onde procurar o quê
| Pergunta | Onde |
|---|---|
| "Por que este login específico falhou?" | Graylog, trace_id:"…" |
| "Quantos logins da Smiles falharam na última hora?" | Prometheus, khronos_login_errors_total |
| "Estamos sendo bloqueados pelo antibot?" | khronos_login_errors_total{reason="antibot"} |
| "O cache está sendo aproveitado?" | khronos_login_path_total |
| "O Pigeon está devolvendo códigos?" | khronos_dependency_operations_total{dependency="pigeon"} |
| "Este CPF tem sessão?" | POST /{cia}/exists-session |