O bug que a gente debugou viajando no tempo

skail · 2026-07-29 · 7 min

TL;DR

  • A cena — um probe que consulta um provedor externo falhou 15 de 16 tentativas em produção, 9min41s de ponta a ponta. O log dizia "Response status code does not indicate success" quinze vezes seguidas.
  • O que a gente fez — em vez de grepar log, abrimos o trace, copiamos UM JSON do Monitor pro launchSettings.json e rodamos dotnet run com o estado exato que a produção tinha. Isso é time travel debug em produção.
  • O que ele revelou — um 400 Bad Request sendo retentado como se fosse falha passageira. A correção da política de retry é um arquivo; a capacidade de debug que expôs isso é a plataforma.

Um stakeholder me perguntou no chat: "o probe tá saudável?". Eu abri o Monitor e respondi em cinco segundos — sem abrir um único arquivo de log.

O que estava na tela: cinco execuções recentes do mesmo probe, todas concluídas, com a contagem de tentativas visível ao lado de cada uma — 2, 0, 3, 0, 1. Provedor instável, retentado sozinho, e o produto nunca ficou sabendo. E uma sexta execução ainda Running, com 14 tentativas acumuladas. Uma falha sendo absorvida ao vivo, legível num relance.

text · 8 lines
Monitor · env sandbox · últimas execuções (1 dia) tentativas
──────────────────────────────────────────────────────────────────
ProbeLiveFeed Completed 2
ProbeLiveFeed Completed 0
ProbeLiveFeed Completed 3
ProbeLiveFeed Completed 0
ProbeLiveFeed Completed 1
ProbeLiveFeed Running ← falha sendo absorvida agora 14

As imagens neste post são recreações fiéis da UI do Monitor / Debug Execution, com os identificadores trocados. O caso é uma execução de produção nossa (2026-07-18); a validação continua interna.

A execução durável grava cada tentativa como um evento enquanto a história acontece, não depois. A contagem de retry vem do histórico da própria execução, não de uma correlação de log que eu tive que montar. Foi por isso que eu consegui responder em cinco segundos. Agora eu queria a execução que estava travada.

O trace conta a história, não o log

Abrir aquela execução Running não me levou a um arquivo de log: me levou à árvore da tarefa. Raiz [SkailFunction] ProbeLiveFeed, seu comando filho, e um painel por passo com as tentativas empilhadas — 15 de 16, cada erro na sequência em que aconteceu.

text · 5 lines
Trace · task probe-sandbox/9f2c… · E2E 9min41s
root [SkailFunction] ProbeLiveFeed
└─ [SkailCommand] FetchProvider tentativa 15/16 ✗
├ 15× "Response status code does not indicate success"
└ [ Debug this step ] [ Retry this step ]

Uma execução, a história inteira em ordem. Sem arqueologia de correlação — sem juntar dez linhas de log de três serviços pra descobrir a ordem dos fatos. A execução durável grava o histórico completo de eventos, então o trace é a história. Você não a remonta.

Mas ver a falha em ordem ainda não é reproduzi-la. O erro dizia sempre a mesma frase genérica. Eu precisava do estado — a requisição exata, os parâmetros, o que voltou. É aí que o botão Debug this step muda o jogo.

Um JSON, um dotnet run e o estado real de produção

Esse botão não abre mais um painel de log. Ele abre um modal com um bloco de JSON pra copiar. Um só passo:

json · 3 lines
{
"SKAIL_DEBUG_FILE": "probe-sandbox_9f2c.debug.json"
}

Você cola isso no launchSettings.json do projeto:

json · 10 lines
{
"profiles": {
"probe-sandbox (debug)": {
"commandName": "Project",
"environmentVariables": {
"SKAIL_DEBUG_FILE": "probe-sandbox_9f2c.debug.json"
}
}
}
}

E roda:

bash · 1 lines
dotnet run

Recreação sanitizada da UI do modal Debug Execution do skail: um bloco JSON com SKAIL_DEBUG_FILE em destaque, seta para o trecho correspondente do launchSettings.json, e seta para dotnet run na IDE — o fluxo de copiar um JSON e reproduzir o task de produção localmente com o estado real. Workload renomeado probe-sandbox.

O modal Debug Execution: um JSON copiado, colado no launchSettings.json, e um dotnet run — o task de produção reproduzido localmente, com o estado real.

O SKAIL_DEBUG_FILE aponta pro envelope de estado capturado daquele task específico: a RPC que o iniciou, os eventos duráveis, os números de sequência, os timestamps e a exceção serializada. Nada de mock, nada de caso de teste montado tentando adivinhar a entrada — é a execução de produção, rodando na minha IDE, com o estado que a produção tinha. Zero chute de reprodução.

Isso é time travel debug: você não observa o que aconteceu de fora, você volta pro exato instante e ponto de execução e roda de novo, localmente, com o estado real. A documentação de Debug Execution descreve o fluxo por inteiro.

Por que retry cego num 4xx é uma armadilha

Com esse task rodando na minha máquina, o breakpoint parou onde o log jamais me levaria. O envelope decodificado apontava para uma linha só:

text · 4 lines
HttpRequestException — 400 Bad Request
em FetchProvider (chamada ao provedor externo)
requisição 20:00:04Z
resposta 20:00:16Z (12s depois)

Um 400. E o [SkailFunction] pai vinha retentando aquilo até 15 de 16 tentativas. Aí está a armadilha inteira num número: um 400 não é falha passageira — é a forma da requisição que está errada. Retentar uma requisição malformada não conserta nada; só queima tentativa, tempo e cota do provedor até o teto de retry.

O código que gerou aquele trace tratava toda falha igual — qualquer exceção subia e o runtime redisparava a tarefa:

csharp · 5 lines
// ANTES: qualquer erro sobe e o runtime redirige a tarefa.
// Um 400 (requisição malformada) foi retentado até 15/16.
[SkailCommand]
public async SkailTask<FeedSnapshot> FetchProvider(ProbeRequest req)
=> await _feed.GetSnapshotAsync(req);

A correção é classificar o erro antes de deixá-lo subir. 4xx é permanente: falha rápido e entrega pro caminho de fallback. 5xx e timeout continuam retentáveis, com backoff:

csharp · 21 lines
// DEPOIS: classifica o erro. 4xx é permanente — falha rápido.
// 5xx / timeout continuam retentáveis (com backoff do runtime).
[SkailCommand]
public async SkailTask<FeedSnapshot> FetchProvider(ProbeRequest req)
{
try
{
return await _feed.GetSnapshotAsync(req);
}
catch (HttpRequestException ex) when (IsPermanent(ex.StatusCode))
{
// Retentar não conserta uma requisição malformada.
throw new PermanentFeedException(req.ProbeId, ex);
}
}
private static bool IsPermanent(HttpStatusCode? code) =>
code is >= HttpStatusCode.BadRequest // 4xx…
and < HttpStatusCode.InternalServerError // …abaixo de 5xx
and not HttpStatusCode.RequestTimeout // 408 é transitório
and not HttpStatusCode.TooManyRequests; // 429 idem

E o pai passa a saber o que fazer com o erro permanente — em vez de insistir, cai no caminho alternativo:

csharp · 18 lines
[SkailFunction]
public async SkailTask<ProbeResult> ProbeLiveFeed(ProbeRequest req)
{
FeedSnapshot snapshot;
try
{
snapshot = await FetchProvider(req);
}
catch (PermanentFeedException)
{
// 4xx não conserta com retry: usa o último bom conhecido.
snapshot = await ReadLastKnownGood(req.ProbeId);
}
var drift = Compare(snapshot, req.Baseline);
await RecordDrift(req.ProbeId, drift);
return new ProbeResult(drift.Count);
}

| Código do provedor | O que fazer | |---|---| | 4xx — requisição malformada | falha rápido → caminho de fallback | | 5xx · timeout (408/429) — falha passageira | retry com backoff (do runtime) |

Vale separar as duas coisas, porque elas moram em camadas diferentes. A política de retry é código de aplicação: essa correção coube num arquivo. Já a capacidade que me deixou ver o 400: abrir o trace, replicar o task com o estado de produção, parar no breakpoint certo — essa é da plataforma. O tem que fazer retry na trigger da documentação de boas práticas mora do lado do runtime; a classificação do erro mora do seu.

O que isso tem a ver com agentes de IA

Essa mesma postura é o que você vai querer no dia em que a ação de um agente falhar. Um agente que chama uma ferramenta e recebe um 400 silencioso quinze vezes é exatamente o probe desta história — só que decidindo o próximo passo com um modelo em cima.

Quando a chamada de ferramenta de um agente falha em produção, a pergunta não pode ser "cadê o log?". Tem que ser: consigo inspecionar essa execução de ponta a ponta e reproduzi-la localmente, com o estado real? Não "adiciona mais log e torce", e sim "abre o trace, replica o task, olha o estado". Observabilidade de verdade pra código que move dinheiro ou dados (agente ou não) se parece com isto agora. É a mesma propriedade que sustenta agentes de IA em produção.

O que fica

Nada disso começou como demonstração. Foi um probe de produção que caiu, um stakeholder perguntando se estava tudo bem, e um 400 escondido atrás de quinze linhas de erro idênticas. E é justamente por isso que vale: a plataforma diagnosticando o nosso próprio código é, ela mesma, a demonstração.

Cada camada me deu uma coisa:

Ajuste a política de retry num arquivo. Mas o que transformou uma tarde de grep numa investigação de dez minutos foi poder voltar no tempo até o task exato.