Dia 13 — Saber o que está acontecendo em produção

Informatica · Conteudo · publicado em 30/09/2026
Dia 13 de 15

Saber o que está acontecendo em produção

Aula 1

Log que responde pergunta

O que separa um log útil de um log jogado no arquivo

O console.log('erro aqui') não responde pergunta nenhuma. Quem lê aquilo de manhã precisa saber o que aconteceu, onde, quando e se afeta uma pessoa ou todas — e nada disso está na linha.

Um log útil responde uma pergunta concreta sem precisar abrir o servidor. A forma que resolve isso é o log estruturado: a mesma linha de sempre, com campos de nome fixo, para que a máquina possa filtrar e a pessoa possa ler.

A tabela que guarda isso tem cinco colunas, e cada uma resolve uma pergunta:

ColunaPergunta que responde
dt_registroquando aconteceu
st_nivelo quanto isso importa
ds_request_ida qual requisição pertence
ds_mensagemo que aconteceu
ds_contextosob que circunstância aconteceu

O dt_registro com DEFAULT CURRENT_TIMESTAMP resolve o horário no log, e é a decisão que quase todo mundo inverte: a função de log não monta data nem hora. O carimbo vem do banco, e o stdout do processo — que o gerenciador já prefixa quando captura — é a segunda fonte. Duas fontes brigando produzem um log que mente sobre quando o evento aconteceu, e a mentira aparece quando se tenta reconstruir a ordem de dois eventos.

JSON.stringify é o que separa a máquina da pessoa

Log em texto livre é para o olho; o log em JSON é para os dois. A linha continua legível no terminal, e grep e jq conseguem extrair campo:

const linha = { nivel, request_id: requestId, mensagem, contexto };
console.log(JSON.stringify(linha));

O ds_contexto é o campo que carrega o log com contexto: rota, método, status e o codigo_mysql quando houve erro. É ele que diz onde o erro aconteceu — "falhou ao montar relatório de itens" é uma frase; etapa: 'montar relatorio' com o SQL ao lado é a diferença entre achar e garimpar.

O mesmo JSON.stringify vai para o INSERT, gravado numa coluna JSON. A consequência prática é que dá para contar sem ler:

SELECT st_nivel, COUNT(*) AS total FROM tb_log GROUP BY st_nivel

Os níveis: o que cada um promete

O nível de log não é decoração. É a resposta para a pergunta "isso acorda alguém?".

NívelSignificadoQuem age
erroralgo quebrou, e alguém vai ser acordadoo time, agora
warnfunciona, mas está estranhoo time, depois
infoo caminho normalquem precisa do histórico
debugsó desenvolvimentoquem está depurando

O debug é o nível que não existe em produção, e o filtro é o NODE_ENV: com ele em producao, o mínimo gravado passa a ser info, e o debug sai do volume junto com o payload e o tamanho da requisição. A informação fica, o barulho sai.

E o inverso também vale: o error que não acorda ninguém é o que treina o time a ignorar error. A diferença entre "logar tudo" e "logar o que importa" é o que separa um log consultado de um log enterrado.

ds_request_id é o que correlaciona requisições

Sem identificador, o log é um rio: mil linhas de requisições diferentes misturadas, e a única forma de achar a sua é ler tudo procurando hora parecida. Com o id da requisição repetido em toda linha daquela requisição, a pergunta "o que essa requisição fez?" vira uma contagem:

SELECT ds_request_id, COUNT(*) AS linhas
FROM tb_log GROUP BY ds_request_id

Isso é correlacionar requisições, e é o que separa o erro de uma pessoa do erro de todo mundo. O exemplo grava cinco linhas de uma requisição só — info, warn, warn, debug, error — e a contagem devolve 5 linhas, 1 error, 2 warn. Quem gera o id novo a cada requisição é a aula 2, com crypto.randomUUID(); aqui ele é fixo, para a saída ser idêntica a cada execução.

Log sem dado sensível: a máscara

O ponto onde o log vaza é o corpo da requisição. Um log de acesso que imprime o corpo inteiro registra a senha do usuário num texto que o banco guarda, o arquivo guarda e o backup guarda.

A regra é: o log precisa servir para achar o problema, e o problema quase nunca é a senha. Então o log guarda o comprimento e os últimos 4 caracteres, que bastam para dizer "é a mesma senha de ontem":

function mascara(valor, visiveis = 4) {
  const texto = String(valor ?? '');
  if (texto.length === 0) return '(vazio)';
  if (texto.length <= visiveis) return '*'.repeat(texto.length);
  return '*'.repeat(texto.length - visiveis) + texto.slice(-visiveis);
}

O email no log é um caso diferente do token no log, e a diferença é útil. O token é segredo e some inteiro. O domínio do e-mail não é segredo, e dizer "o erro é de um cliente só" vale mais do que esconder. O que não entra é a parte do local, que identifica a pessoa:

function mascaraEmail(email) {
  const corte = String(email ?? '').indexOf('@');
  return String(email).slice(0, 1) + '***' + String(email).slice(corte);
}

A prova de que a máscara funciona é o banco, não o código: a linha gravada é lida de volta e mostrada inteira. Uma senha de 19 caracteres aparece com 15 asteriscos e 4 letras — os 4 últimos, nunca os primeiros.

console.log é transitório e às vezes some em restart. As duas saídas de verdade são o log em arquivo e o stdout capturado pelo gerenciador de processo, e as duas recebem o mesmo JSON.stringify no mesmo ponto do código — por isso a função de log é uma função só, e não um console.log espalhado.

debug também vaza: o payload inteiro em debug é o caminho mais curto para a senha no arquivo de log. Lista de chaves e comprimento resolvem a depuração; o valor é o que vaza.

A máscara é para o dado que o usuário mandou e que você não pode devolver. A senha do banco continua vindo do .env, e o valor do .env não entra em log nenhum.

Nível não substitui contexto: um error sem rota e sem codigo_mysql diz que algo quebrou, e um info com a rota e o identificador diz exatamente onde. As duas coisas juntas é que a linha responde pergunta.

Exemplo

'use strict';

// Exemplo da aula 1 do dia 13: log que responde pergunta.
//
// A tese da aula: log nao e "jogar texto no arquivo". E uma linha que responde
// uma pergunta concreta sem precisar abrir o servidor. Para isso sao quatro
// campos estruturados e um carimbo de tempo:
//
//   1. st_nivel      — error / warn / info / debug
//   2. ds_request_id — o MESMO em todas as linhas da mesma requisicao
//   3. ds_mensagem   — o que aconteceu, em uma frase
//   4. ds_contexto   — JSON com o resto (rota, status, codigo do MySQL)
//   5. tempo         — carimbado pelo proprio banco, nao pelo codigo
//
// O quinto campo e o detalhe que quase todo mundo faz errado. A funcao NAO
// gera hora: e o `horario no log` que o `DEFAULT CURRENT_TIMESTAMP` da coluna
// coloca, e o stdout capturado pelo gerenciador ja vem prefixado. Duas fontes
// de tempo brigando produzem um log que mente sobre quando o evento aconteceu.
//
// O caso que o exemplo executa de verdade e o do dado sensivel: um corpo de
// requisicao com senha, token e e-mail. A funcao `mascara` deixa passar so os
// 4 ultimos caracteres, e o exemplo imprime a linha JA MASCARADA. O valor
// original nao vai para o banco, nao vai para o stdout e nao aparece em lugar
// nenhum do material.
//
// O identificador de requisicao aqui e FIXO de proposito (`req-demo-7f3a`):
// com ele a saida e identica toda vez que o exemplo roda. Quem gera um
// `id da requisicao` novo a cada requisicao, com `crypto.randomUUID()`, e a
// aula 2.
//
// A conexao vem do harness do material (`await conexao`). Este exemplo nao
// segura um servidor HTTP, entao a conexao compartilhada da para ele e o
// proprio harness a fecha quando a ultima consulta termina. A aula 2, que
// segura um servidor enquanto responde, abre a conexao dela mesma.

const NIVEL_ORDEM = { debug: 10, info: 20, warn: 30, error: 40 };

// O `nivel de log` nao e decoracao: e a resposta para "isso acorda alguem?".
// `NODE_ENV` decide o que e gravado. Sem ele (desenvolvimento) entra ate
// `debug`; com `producao`, `debug` deixa de existir e o volume de log cai.
const AMBIENTE = process.env.NODE_ENV || 'desenvolvimento';
const NIVEL_MINIMO = AMBIENTE === 'producao' ? 'info' : 'debug';

function deveGravar(nivel, minimo = NIVEL_MINIMO) {
  return (NIVEL_ORDEM[nivel] ?? 0) >= (NIVEL_ORDEM[minimo] ?? 0);
}

// ================================================ 1. nunca logar dado sensivel
// A regra do `log sem dado sensivel`: o log precisa servir para achar o
// problema, e o problema quase nunca e a senha. Entao o log guarda o
// COMPRIMENTO e os ultimos caracteres, que bastam para dizer "e a mesma senha
// de ontem", e nao o valor. O `token no log` e o `email no log` seguem a
// mesma regra por caminhos diferentes: o token some inteiro, o e-mail guarda
// o dominio e perde o local.
function mascara(valor, visiveis = 4) {
  const texto = String(valor ?? '');
  if (texto.length === 0) return '(vazio)';
  if (texto.length <= visiveis) return '*'.repeat(texto.length);
  return '*'.repeat(texto.length - visiveis) + texto.slice(-visiveis);
}

// E-mail e diferente de senha: o dominio NAO e segredo e ajuda muito no log
// (da para saber se o erro e so de um cliente). O que nao entra e a parte do
// local, que identifica a pessoa.
function mascaraEmail(email) {
  const texto = String(email ?? '');
  const corte = texto.indexOf('@');
  if (corte < 1) return '(invalido)';
  return texto.slice(0, 1) + '***' + texto.slice(corte);
}

// Um segredo de exemplo, montado na hora. O exemplo precisa de um valor que
// pareca senha e token para a mascara ter o que mascarar, mas o VALOR nao pode
// estar escrito no arquivo: em aplicacao de verdade ele vem do `.env`
// (CONTRATO.md), e um literal no codigo vai parar no git. Aqui e sintetico.
function segredoDeExemplo(rotulo) {
  return ['valor', 'de', rotulo].join('-');
}

// ===================================================== 2. a linha de log
// Uma funcao, e nao um `console.log` espalhado: e ela que decide o nivel, monta
// o JSON, aplica a mascara e grava no banco. Um log que depende de cada um
// lembrar de mascarar e um log que vaza na primeira vez que alguem esquece.
//
// O `log estruturado` e o `log em JSON` sao a MESMA linha: o `JSON.stringify`
// vai para o stdout E para a coluna `ds_contexto`, no mesmo ponto. E o
// `log com contexto` que diz `onde o erro aconteceu` — sem ele, a linha sabe
// que algo quebrou e nao sabe onde.
async function registrar(conn, { nivel, requestId, mensagem, contexto = {} }) {
  if (!deveGravar(nivel)) return null;

  const linha = { nivel, request_id: requestId, mensagem, contexto };
  console.log(JSON.stringify(linha));

  const [gravado] = await conn.execute(
    'INSERT INTO tb_log (st_nivel, ds_request_id, ds_mensagem, ds_contexto) '
    + 'VALUES (?, ?, ?, ?)',
    [nivel, requestId, mensagem, JSON.stringify(contexto)]
  );
  return gravado.insertId;
}

// =========================================================== 3. o programa
async function main() {
  const conn = await conexao;

  // `IF NOT EXISTS` + `TRUNCATE`: o exemplo roda varias vezes e tem de dar o
  // mesmo resultado. O `TRUNCATE` zera tambem o AUTO_INCREMENT, entao o `id`
  // das linhas impressas comeca sempre em 1.
  //
  // O indice de `ds_request_id` ja nasce dentro do CREATE TABLE. Fica assim
  // de proposito: um `ALTER TABLE ... ADD KEY` separado quebraria na segunda
  // execucao, porque o nome do indice ja existiria.
  await conn.query(`
    CREATE TABLE IF NOT EXISTS tb_log (
      id            INT AUTO_INCREMENT PRIMARY KEY,
      dt_registro   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
      st_nivel      VARCHAR(10)  NOT NULL,
      ds_request_id VARCHAR(40)  NOT NULL,
      ds_mensagem   VARCHAR(120) NOT NULL,
      ds_contexto   JSON         NULL,
      KEY ix_tb_log_req (ds_request_id)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  await conn.query('TRUNCATE TABLE tb_log');

  // O indice e o que faz a busca da aula 2 (`WHERE ds_request_id = ?`) nao
  // varrer a tabela inteira.
  const [ix] = await conn.query(
    "SELECT index_name, seq_in_index, column_name FROM information_schema.statistics "
    + "WHERE table_schema = DATABASE() AND table_name = 'tb_log' "
    + "AND index_name = 'ix_tb_log_req' ORDER BY seq_in_index"
  );
  console.log('--- 0. a tabela do log ---');
  console.log('indice ix_tb_log_req sobre: ' + ix.map((i) => i.column_name).join(', '));
  console.log('sem esse indice, a busca da aula 2 leria a tabela inteira');

  // Um identificador so por requisicao, repetido em TODA linha daquela
  // requisicao. E o que permite correlacionar: quantas linhas, em que ordem.
  const REQ = 'req-demo-7f3a';

  console.log('\n--- 1. os quatro niveis, e o que cada um promete ---');
  console.log('ambiente NODE_ENV: ' + AMBIENTE
    + ' — nivel minimo gravado: ' + NIVEL_MINIMO);

  await registrar(conn, {
    nivel: 'info',
    requestId: REQ,
    mensagem: 'requisicao recebida',
    contexto: { metodo: 'POST', rota: '/login', ip: '203.0.113.10' },
  });

  await registrar(conn, {
    nivel: 'warn',
    requestId: REQ,
    mensagem: 'tentativa de login recusada',
    contexto: { motivo: 'senha nao confere', tentativas_na_minuta: 2 },
  });

  // --------------------------------------------------------- dado sensivel
  // O corpo CHEGA inteiro, e por isso a mascara existe. Repare que o valor de
  // `senha`, `token` e `nm_email` nao e impresso em lugar nenhum: o que sai
  // no stdout e no banco e a versao mascarada.
  //
  // Os valores sao montados na hora por `segredoDeExemplo()`, e nao escritos
  // aqui: um segredo literal no arquivo e um segredo que vai parar no git. O
  // que a aula precisa e que eles EXISTAM e sejam mascarados, nao que valham
  // alguma coisa.
  const corpoRecebido = {
    nm_user: 'ana',
    nm_email: '[email protected]',
    senha: segredoDeExemplo('material'),
    token: segredoDeExemplo('token'),
  };

  await registrar(conn, {
    nivel: 'warn',
    requestId: REQ,
    mensagem: 'credencial recusada no login',
    contexto: {
      rota: '/login',
      ds_usuario: corpoRecebido.nm_user,
      ds_senha: mascara(corpoRecebido.senha),
      ds_token: mascara(corpoRecebido.token),
      ds_email: mascaraEmail(corpoRecebido.nm_email),
    },
  });

  await registrar(conn, {
    nivel: 'debug',
    requestId: REQ,
    mensagem: 'payload recebido, antes da validacao',
    contexto: {
      chaves: Object.keys(corpoRecebido),
      bytes: JSON.stringify(corpoRecebido).length,
      // Os VALORES nao vao para o log de debug tambem. Lista de chave e
      // comprimento bastam para depurar; valor e o que vaza.
      valores: '(nao logado)',
    },
  });

  // ------------------------------------------------------- erro de verdade
  // Um `error` aqui tem que ser um erro que aconteceu de verdade, e nao uma
  // frase. O par `console.error` + `console.log` e o que o CONTRATO.md exige:
  // o terminal mostra o erro, a pagina tambem, e a linha de contexto vem
  // ANTES para a pagina nao mostrar um erro solto.
  console.log('\n--- 2. error: consulta a uma tabela que nao existe ---');
  console.log('vou rodar: SELECT * FROM tb_item_inexistente');
  try {
    await conn.query('SELECT * FROM tb_item_inexistente');
  } catch (erro) {
    console.error(erro.code + ': ' + erro.message);
    console.log('  erro capturado:', erro.code, '- nivel error, alguem e avisado');
    await registrar(conn, {
      nivel: 'error',
      requestId: REQ,
      mensagem: 'falha ao montar relatorio de itens',
      contexto: {
        codigo_mysql: erro.code,          // e o que se compara, nao a message
        etapa: 'montar relatorio',
        sql: 'SELECT * FROM tb_item_inexistente',
      },
    });
  }

  // ------------------------------------------------------------- a leitura
  console.log('\n--- 3. o log gravado, lido de volta do banco ---');
  const [linhas] = await conn.query(
    'SELECT id, st_nivel, ds_request_id, ds_mensagem, ds_contexto '
    + 'FROM tb_log ORDER BY id'
  );
  console.log('linhas em tb_log: ' + linhas.length);
  for (const l of linhas) {
    const ctx = typeof l.ds_contexto === 'string'
      ? JSON.parse(l.ds_contexto) : l.ds_contexto;
    console.log('  id=' + l.id + '  ' + l.st_nivel.padEnd(5)
      + '  ' + l.ds_mensagem);
    console.log('      contexto: ' + JSON.stringify(ctx));
  }

  // A linha da mascara, mostrada como saiu do banco: e a unica forma de
  // provar que a senha nao esta la. Se alguem rodar e vir a senha inteira
  // nesta saida, a mascara foi quebrada.
  const [mascarada] = await conn.query(
    'SELECT ds_mensagem, ds_contexto FROM tb_log '
    + 'WHERE ds_request_id = ? AND ds_mensagem = ?',
    [REQ, 'credencial recusada no login']
  );
  const ctx = typeof mascarada[0].ds_contexto === 'string'
    ? JSON.parse(mascarada[0].ds_contexto) : mascarada[0].ds_contexto;
  console.log('\n--- 4. a linha que tem credencial, como ficou no banco ---');
  console.log(JSON.stringify(ctx, null, 2));
  const mascarados = ctx.ds_senha.split('*').length - 1;
  console.log('a senha original tem ' + corpoRecebido.senha.length
    + ' caracteres; no log sobram ' + (ctx.ds_senha.length - mascarados)
    + ' e ' + mascarados + ' viram asterisco');
  console.log('os 4 que sobram sao os ULTIMOS da senha, nao os primeiros');
  console.log('o dominio do e-mail ficou: e o que separa "erro do cliente"');
  console.log('de "erro nosso" — sem a parte que identifica a pessoa');

  // ----------------------------------------------------- correlacionar (tema)
  // Quantas linhas tem por requisicao. E `correlacionar requisicoes`: a
  // pergunta "o que essa requisicao fez?" vira uma contagem, sem abrir o
  // servidor e sem ler o log inteiro.
  console.log('\n--- 5. correlacionar: linhas por requisicao ---');
  const [porReq] = await conn.query(
    'SELECT ds_request_id, COUNT(*) AS linhas, '
    + "SUM(st_nivel = 'error') AS erros, "
    + "SUM(st_nivel = 'warn') AS avisos "
    + 'FROM tb_log GROUP BY ds_request_id ORDER BY ds_request_id'
  );
  for (const g of porReq) {
    console.log('  ' + g.ds_request_id + ' -> ' + g.linhas
      + ' linhas, ' + g.erros + ' error, ' + g.avisos + ' warn');
  }
  console.log('duas requisicoes distintas dariam dois numeros distintos: e assim');
  console.log('que se sabe se o erro foi de UMA pessoa ou de todo mundo');

  // --------------------------------------------------------- filtro de nivel
  // A mesma lista de eventos, com o filtro de producao aplicado. E o que
  // muda quando o volume de log incomoda: o `debug` deixa de ser gravado, e o
  // `info`/`warn`/`error` continuam — que sao os que respondem pergunta.
  console.log('\n--- 6. o filtro: o que entraria em producao ---');
  const eventos = ['debug', 'info', 'warn', 'error'];
  for (const minimo of ['debug', 'info']) {
    const passam = eventos.filter((n) => deveGravar(n, minimo));
    console.log('  nivel minimo ' + minimo.padEnd(5) + ' -> grava: '
      + passam.join(', '));
  }
  console.log('em producao o `debug` sai do volume, e com ele o `payload` e os');
  console.log('bytes da requisicao: a informacao fica, o barulho nao');

  // ------------------------------------------------------------ o carimbo
  // Prova de que a hora veio do banco e nao do codigo: as linhas tem
  // `dt_registro` preenchido, e nenhuma delas foi carimbada pela funcao.
  const [hora] = await conn.query(
    'SELECT COUNT(*) AS total, COUNT(dt_registro) AS com_hora FROM tb_log'
  );
  console.log('\n--- 7. onde a hora entrou ---');
  console.log('linhas: ' + hora[0].total + ', com dt_registro preenchido: '
    + hora[0].com_hora);
  console.log('a funcao `registrar` nao monta data nem hora: quem carimba e o');
  console.log('DEFAULT CURRENT_TIMESTAMP da coluna. O stdout do processo, que o');
  console.log('gerenciador ja prefixa, e a segunda fonte — o arquivo de log');

  // A versao do banco nunca e escrita a mao: o exemplo pergunta e imprime.
  // Repare no `[0]`: `query` devolve `[linhas, campos]`, e a destructuracao
  // `const [versao]` entrega o ARRAY de linhas, nao a linha.
  const [versao] = await conn.query('SELECT VERSION() AS versao');
  console.log('\nbanco em uso: ' + versao[0].versao);
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

--- 0. a tabela do log ---
indice ix_tb_log_req sobre: ds_request_id
sem esse indice, a busca da aula 2 leria a tabela inteira

--- 1. os quatro niveis, e o que cada um promete ---
ambiente NODE_ENV: desenvolvimento — nivel minimo gravado: debug
{"nivel":"info","request_id":"req-demo-7f3a","mensagem":"requisicao recebida","contexto":{"metodo":"POST","rota":"/login","ip":"203.0.113.10"}}
{"nivel":"warn","request_id":"req-demo-7f3a","mensagem":"tentativa de login recusada","contexto":{"motivo":"senha nao confere","tentativas_na_minuta":2}}
{"nivel":"warn","request_id":"req-demo-7f3a","mensagem":"credencial recusada no login","contexto":{"rota":"/login","ds_usuario":"ana","ds_senha":"*************rial","ds_token":"**********oken","ds_email":"a***@exemplo.com"}}
{"nivel":"debug","request_id":"req-demo-7f3a","mensagem":"payload recebido, antes da validacao","contexto":{"chaves":["nm_user","nm_email","senha","token"],"bytes":99,"valores":"(nao logado)"}}

--- 2. error: consulta a uma tabela que nao existe ---
vou rodar: SELECT * FROM tb_item_inexistente
  erro capturado: ER_NO_SUCH_TABLE - nivel error, alguem e avisado
{"nivel":"error","request_id":"req-demo-7f3a","mensagem":"falha ao montar relatorio de itens","contexto":{"codigo_mysql":"ER_NO_SUCH_TABLE","etapa":"montar relatorio","sql":"SELECT * FROM tb_item_inexistente"}}

--- 3. o log gravado, lido de volta do banco ---
linhas em tb_log: 5
  id=1  info   requisicao recebida
      contexto: {"metodo":"POST","rota":"/login","ip":"203.0.113.10"}
  id=2  warn   tentativa de login recusada
      contexto: {"motivo":"senha nao confere","tentativas_na_minuta":2}
  id=3  warn   credencial recusada no login
      contexto: {"rota":"/login","ds_usuario":"ana","ds_senha":"*************rial","ds_token":"**********oken","ds_email":"a***@exemplo.com"}
  id=4  debug  payload recebido, antes da validacao
      contexto: {"chaves":["nm_user","nm_email","senha","token"],"bytes":99,"valores":"(nao logado)"}
  id=5  error  falha ao montar relatorio de itens
      contexto: {"codigo_mysql":"ER_NO_SUCH_TABLE","etapa":"montar relatorio","sql":"SELECT * FROM tb_item_inexistente"}

--- 4. a linha que tem credencial, como ficou no banco ---
{
  "rota": "/login",
  "ds_usuario": "ana",
  "ds_senha": "*************rial",
  "ds_token": "**********oken",
  "ds_email": "a***@exemplo.com"
}
a senha original tem 17 caracteres; no log sobram 4 e 13 viram asterisco
os 4 que sobram sao os ULTIMOS da senha, nao os primeiros
o dominio do e-mail ficou: e o que separa "erro do cliente"
de "erro nosso" — sem a parte que identifica a pessoa

--- 5. correlacionar: linhas por requisicao ---
  req-demo-7f3a -> 5 linhas, 1 error, 2 warn
duas requisicoes distintas dariam dois numeros distintos: e assim
que se sabe se o erro foi de UMA pessoa ou de todo mundo

--- 6. o filtro: o que entraria em producao ---
  nivel minimo debug -> grava: debug, info, warn, error
  nivel minimo info  -> grava: info, warn, error
em producao o `debug` sai do volume, e com ele o `payload` e os
bytes da requisicao: a informacao fica, o barulho nao

--- 7. onde a hora entrou ---
linhas: 5, com dt_registro preenchido: 5
a funcao `registrar` nao monta data nem hora: quem carimba e o
DEFAULT CURRENT_TIMESTAMP da coluna. O stdout do processo, que o
gerenciador ja prefixa, e a segunda fonte — o arquivo de log

banco em uso: 10.11.14-MariaDB-0ubuntu0.24.04.1
Aula 2

Achar o erro pelo identificador

O identificador que atravessa a requisição

Sem identificador, o cliente escreve "deu erro" e começa uma conversa: que erro, em que tela, que hora, o que você fez antes. Cada resposta vem outra pergunta. Com um ID de requisição gerado na entrada, ele escreve uma frase — o id — e a conversa acaba.

O caminho tem cinco passos, e a ordem importa porque é a ordem em que a requisição acontece:

  1. crypto.randomUUID() gera o identificador único na entrada;
  2. o valor entra no log, e todas as linhas daquela requição carregam o mesmo;
  3. o erro.code e o stack vão para o log com esse id;
  4. a resposta HTTP sai com o cabeçalho X-Request-Id;
  5. o cliente lê o cabeçalho, e a busca é WHERE ds_request_id = ?.

A rastreabilidade é o nome do que os cinco passos produzem: rastrear a partir de uma frase do usuário até a linha exata do banco.

O X-Request-Id que o cliente manda também é respeitado — é assim que o id atravessa CDN, gateway e mais de um serviço sem se perder. Mas ele é texto livre, e texto livre dentro de um campo de cabeçalho é um furo: qualquer um manda 4 KB ali dentro. Por isso o servidor só aceita o que tem formato de UUID e substitui o resto:

const FORMATO_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function idDaRequisicao(req) {
  const recebido = req.headers['x-request-id'];
  if (typeof recebido === 'string' && FORMATO_UUID.test(recebido)) {
    return { id: recebido, origem: 'cliente' };
  }
  return { id: crypto.randomUUID(), origem: 'servidor' };
}

Um curl -i mostra o cabeçalho saindo:

const resposta = await fetch(base + '/saude');
console.log('X-Request-Id na resposta: ' + resposta.headers.get('x-request-id'));

A resposta do exemplo traz o id em três lugares — o cabeçalho, o corpo e o log — e os três com o mesmo valor. Quando o cliente reportar o problema, ele manda esse id e o time não precisa perguntar mais nada.

Achar requisição pelo ID é uma busca indexada

O ds_request_id tem índice na tabela, e o índice é o que torna a busca barata:

SELECT id, st_nivel, ds_mensagem, ds_contexto
FROM tb_log WHERE ds_request_id = ? ORDER BY id

A busca pelo id da requisição que quebrou devolve só as linhas dela — duas, no exemplo: a linha info da entrada e a linha error com o codigo_mysql, a etapa e o stack. A mesma consulta com um id que nunca aconteceu devolve zero linha, e é isso que prova que o filtro filtra: não é "as últimas linhas do log", é a requisição pedida.

O EXPLAIN confirma que o planejador usou o índice:

EXPLAIN SELECT id FROM tb_log WHERE ds_request_id = ?

Sem o índice, a mesma busca lê a tabela inteira. Com ele, a diferença é entre "achar o erro" e "esperar dois minutos para achar o erro".

O 500 não pode devolver o stack

A tentação é devolver a mensagem do erro para facilitar. É exatamente o que vaza. O corpo de um erro de banco carrega o código, o nome da tabela e o SQL — informação que descreve a estrutura interna para quem não deveria conhecer, e que vira mapa de ataque.

O corpo do 500 do exemplo tem três campos, e nenhum deles é o erro:

{
  "erro": "ER_INTERNO",
  "mensagem": "recurso indisponivel",
  "request_id": "identificador-que-muda-a-cada-execucao"
}

O request_id é um UUID novo a cada execução do exemplo, porque cada requisição recebe o seu. É o único valor da página que muda entre uma rodada e outra, e ele muda por desenho — um id fixo seria o oposto de identificador único.

ER_INTERNO é um código seu, não do MySQL. mensagem é o que o cliente pode mostrar para o usuário final sem explicar nada da sua arquitetura. request_id é a ponte: com ele, quem recebe o erro encontra o erro.code verdadeiro no log, em segundos, e o time inteiro continua com o que importa.

A tradução de erro.code é o que permite isso. O erro.code é o que a aplicação compara; o texto é o que o usuário vê:

const TRADUCAO = {
  ER_DUP_ENTRY: 'registro ja existe',
  ER_NO_REFERENCED_ROW: 'referencia invalida',
  ER_NO_SUCH_TABLE: 'recurso indisponivel',
  ER_DATA_TOO_LONG: 'campo maior que o permitido',
};

function traduzirErro(erro) {
  return TRADUCAO[erro && erro.code] || 'erro interno, ja registramos para investigacao';
}

ER_NO_SUCH_TABLE no exemplo é o caso que mostra o motivo: traduzido, vira "recurso indisponivel"; cru, ele entrega o nome da tabela. E o status também sai da tradução, porque "a pessoa mandou coisa errada" e "nos quebramos" têm respostas diferentes: ER_DUP_ENTRY é 409, ER_DATA_TOO_LONG é 400, e qualquer código fora da lista é 500.

O stack vai para o log, e enxuto: caminho absoluto da máquina de quem rodou e número de linha que muda a cada edição são ruído no meio do que importa. Guardar a primeira linha do erro.stack mais o nome do arquivo já responde "onde o erro aconteceu" sem vazar o diretório do servidor.

Endpoint de saúde: o que monitora e o que acorda

O endpoint de saúde (/saude, ou health check) responde se o processo está vivo e se o banco responde. Os dois, e não só o processo: MySQL parado com Node no ar passa em qualquer verificação que olhe só o processo, e a API está fora do ar de todo modo.

const [saude] = await conn.query('SELECT 1 AS vivo');
res.end(JSON.stringify({
  estado: saude[0].vivo === 1 ? 'saudavel' : 'instavel',
  banco: saude[0].vivo === 1 ? 'respondendo' : 'sem resposta',
  request_id: id,
}));

O uptime é a métrica do outro lado: ela diz que o serviço responde, e não que ele faz o que deveria. As duas juntas fecham o quadro.

O que acorda alguém não é o evento isolado, é a quantidade de erro por janela — e é isso que permite monitorar e saber que quebrou sem ninguém olhar tela:

SELECT st_nivel, COUNT(*) AS total, COUNT(DISTINCT ds_request_id) AS reqs
FROM tb_log GROUP BY st_nivel

O COUNT(DISTINCT ds_request_id) é o número que separa os dois incidentes que parecem iguais: três error de uma pessoa só é uma conta com senha errada; três error de três pessoas é o sistema fora. O mesmo total de linhas,oum problema completamente diferente — e só o DISTINCT diz qual dos dois é.

Header de rastreio é X-Request-Id por convenção, mas o nome é seu: o que importa é que o mesmo valor saia na resposta e entre no log. Se um proxy intermediário reescreve o cabeçalho, a correlação morre ali — e é por isso que o id vai também no corpo.

Cliente que reporta erro deve copiar o id da tela, não digitá-lo. Erro de digitação transforma uma busca de um segundo em uma conversa inteira de suporte.

Alerta por volume absoluto erra dos dois lados: alerta em todo warn acorda alguém toda noite, e alerta em contagem alta de error espera o sistema cair. O alerta útil é taxa por janela: quantidade de error por minuto acima do normal, com o id da requisição de exemplo anexado para o time começar pelo meio.

Log com --inspect ligado em produção abre uma porta de depuração na rede. É o mesmo cuidado do dado sensível: o que o log ajuda a diagnosticar também vaza para quem lê, e quem lê pode não ser você.

Exemplo

'use strict';

// Exemplo da aula 2 do dia 13: achar o erro pelo identificador.
//
// A ideia inteira: um `ID de requisicao` gerado na ENTRADA do servidor
// atravessa toda a requisicao. Ele entra no log, ele volta para o cliente no
// cabecalho `X-Request-Id`, e e por ele que o log e procurado depois. Sem ele,
// o cliente fala "deu erro" e comeca uma conversa; com ele, o cliente manda
// uma frase — o `id` — e a busca devolve a linha exata. Isso e `rastrear`:
// sair de uma frase do usuario e chegar na linha do banco.
//
// O fluxo inteiro e executado aqui, na ordem em que acontece na vida real:
//
//   1. `crypto.randomUUID()` gera o id na entrada
//   2. o `id` entra no cabecalho `X-Request-Id` que o cliente mandou, se houver
//   3. a rota trabalha e grava log com ESSE id
//   4. a resposta sai com o cabecalho `X-Request-Id`
//   5. o cliente le o cabecalho, e a busca e `WHERE ds_request_id = ?`
//
// O caso do 500 fecha a aula. O corpo da resposta nao pode devolver o stack:
// ele traz caminho do arquivo, versao do Node e o SQL com o nome da tabela.
// O que o cliente recebe e o id. O stack fica no log, junto com `erro.code`.
//
// O que NAO pode vazar: o nome da tabela interna no corpo do 500. Por isso o
// corpo tem `erro: 'ER_INTERNO'` e nao o `erro.code` cru do MySQL — o cliente
// precisa de uma frase que possa mostrar para o usuario final, e o
// `erro.code` vaza o nome do banco.
//
// A traducao de `erro.code` para texto do cliente esta na funcao
// `traduzirErro`: e o que separa "algo quebrou aqui dentro" de "o dado que voce
// mandou esta errado".
//
// POR QUE ESTE EXEMPLO ABRE A CONEXAO DELE MESMO
// O `conexao` que o harness entrega e compartilhado, e um servidor HTTP que
// responde varias requisicoes com intervalos entre elas nao pode usa-lo: entre
// uma consulta e outra passa tempo sem nada em voo, e a conexao fecha por
// baixo. Por isso o exemplo declara o proprio `require('mysql2/promise')` e
// fecha com `end()` no `finally` — que e o que o codigo de producao faz.

const http = require('node:http');
const crypto = require('node:crypto');
const { createConnection } = require('mysql2/promise');

// =============================================== 1. o identificador unico
// Duas Fontes de id, e a ordem importa:
//
//   - o cabecalho `X-Request-Id` que o cliente mandou: e quem propaga o id
//     quando a requisicao passa por varios servicos (CDN, gateway, API)
//   - `crypto.randomUUID()`: quando o cliente nao mandou nada
//
// Confiar no id do cliente sem validar e um furo: ele e texto livre e pode
// vir com 4 KB de lixo dentro do campo. O exemplo so aceita o que tem o
// formato de um UUID, e o resto e substituido.
const FORMATO_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function idDaRequisicao(req) {
  const recebido = req.headers['x-request-id'];
  if (typeof recebido === 'string' && FORMATO_UUID.test(recebido)) {
    return { id: recebido, origem: 'cliente' };
  }
  return { id: crypto.randomUUID(), origem: 'servidor' };
}

// ========================================== 2. traduzir erro para o cliente
// O `erro.code` do MySQL e o que a APLICACAO compara. O que o USUARIO ve
// precisa ser outra coisa: um texto que o cliente possa mostrar, sem nome de
// tabela, sem SQL e sem dialeto.
//
// `ER_NO_SUCH_TABLE` e o exemplo perfeito de codigo que NUNCA pode vazar para
// fora: ele diz o nome da tabela que o usuario nem sabe que existe.
const TRADUCAO = {
  ER_DUP_ENTRY: 'registro ja existe',
  ER_NO_REFERENCED_ROW: 'referencia invalida',
  ER_NO_SUCH_TABLE: 'recurso indisponivel',
  ER_DATA_TOO_LONG: 'campo maior que o permitido',
  ER_BAD_NULL_ERROR: 'falta um campo obrigatorio',
  ER_ACCESS_DENIED_ERROR: 'sem permissao para esta operacao',
};

// Codigos que NAO entram na traducao e viram erro interno, sem detalhe.
const PADRAO_INTERNO = 'erro interno, ja registramos para investigacao';

function traduzirErro(erro) {
  const texto = TRADUCAO[erro && erro.code];
  return texto || PADRAO_INTERNO;
}

// O codigo HTTP tambem e escolha, e nao `catch` generico. A diferenca entre
// "a pessoa mandou coisa errada" e "nos quebramos" e o que define quem recebe
// a resposta: no primeiro e o cliente que corrige, no segundo e o time.
const STATUS_POR_CODIGO = {
  ER_DUP_ENTRY: 409,
  ER_DATA_TOO_LONG: 400,
  ER_BAD_NULL_ERROR: 400,
  ER_NO_REFERENCED_ROW: 409,
};

function statusDoErro(erro) {
  return STATUS_POR_CODIGO[erro && erro.code] || 500;
}

// O stack responde "onde o erro aconteceu", e o log e o lugar certo para
// guarda-lo. So que o stack cru carrega o caminho absoluto da maquina de quem
// rodou, e a linha muda a cada edicao do arquivo — os dois sao ruido que nao
// ajuda a procurar. A funcao corta o caminho para o nome do arquivo e mantem
// so a primeira linha, que e a que diz o que deu errado.
function stackEnxuto(erro) {
  const linhas = String(erro.stack).split('\n');
  const cabecalho = linhas[0];
  const origem = (linhas[1] || '').trim();
  const arquivo = origem.match(/([\w.-]+\.js):(\d+):(\d+)/);
  if (!arquivo) return cabecalho;
  return cabecalho + ' | em ' + arquivo[1] + ' linha ' + arquivo[2];
}

async function main() {
  const conn = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  // A mesma tabela da aula 1, com `TRUNCATE` no comeco: rodar duas vezes tem
  // de dar o mesmo resultado, e o `id` volta sempre a 1.
  await conn.query(`
    CREATE TABLE IF NOT EXISTS tb_log (
      id            INT AUTO_INCREMENT PRIMARY KEY,
      dt_registro   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
      st_nivel      VARCHAR(10)  NOT NULL,
      ds_request_id VARCHAR(40)  NOT NULL,
      ds_mensagem   VARCHAR(120) NOT NULL,
      ds_contexto   JSON         NULL,
      KEY ix_tb_log_req (ds_request_id)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  await conn.query('TRUNCATE TABLE tb_log');

  // A tabela do caminho feliz. A rota de `POST` grava nela, e a rota de `GET`
  // consulta OUTRA tabela que e dropada no meio do exemplo — e assim que o 500
  // nasce de um erro REAL do banco e nao de um `throw` escrito a mao.
  await conn.query(`
    CREATE TABLE IF NOT EXISTS tb_item_d13a2 (
      id      INT AUTO_INCREMENT PRIMARY KEY,
      nm_item VARCHAR(40) NOT NULL,
      qtd     INT         NOT NULL
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  await conn.query('TRUNCATE TABLE tb_item_d13a2');
  await conn.query('DROP TABLE IF EXISTS tb_item_que_cai');

  // ------------------------------------------------------------------- log
  // A funcao leva o id como PARAMETRO, nunca puxa de uma variavel global: e o
  // que garante que a linha gravada e da requisicao que esta em tratamento, e
  // nao da anterior.
  async function registrar(nivel, requestId, mensagem, contexto = {}) {
    const [gravado] = await conn.execute(
      'INSERT INTO tb_log (st_nivel, ds_request_id, ds_mensagem, ds_contexto) '
      + 'VALUES (?, ?, ?, ?)',
      [nivel, requestId, mensagem, JSON.stringify(contexto)]
    );
    console.log('  [log] ' + nivel.padEnd(5) + ' ' + requestId + ' | ' + mensagem);
    return gravado.insertId;
  }

  // ----------------------------------------------------------------- rotas
  const servidor = http.createServer(async (req, res) => {
    // PASSO 1: o id nasce aqui, na entrada. Tudo abaixo usa esta variavel.
    const { id, origem } = idDaRequisicao(req);

    // O cabecalho do log mostra o prefixo `[entrada]` para a linha de contexto
    // vir ANTES de qualquer erro na pagina.
    await registrar('info', id, 'requisicao recebida', {
      metodo: req.method,
      rota: req.url,
      id_de_origem: origem,
    });

    // ------------------------------------------------------- /saude
    // O `endpoint de saude` (o `health check`) responde se o processo esta
    // vivo e se o banco responde. Os dois, e nao so o processo: MySQL parado e
    // Node no ar e "saudavel" para quem so olha o processo, e a API esta
    // fora. E a `metrica` de `uptime`: diz que o servico responde, e nao que
    // ele faz o que deveria.
    if (req.url === '/saude') {
      const [saude] = await conn.query('SELECT 1 AS vivo');
      await registrar('info', id, 'health check respondeu');
      res.writeHead(200, {
        'Content-Type': 'application/json; charset=utf-8',
        'X-Request-Id': id,
      });
      return res.end(JSON.stringify({
        estado: saude[0].vivo === 1 ? 'saudavel' : 'instavel',
        banco: saude[0].vivo === 1 ? 'respondendo' : 'sem resposta',
        request_id: id,
      }));
    }

    // ------------------------------------------------- /itens: o caminho feliz
    if (req.url === '/itens' && req.method === 'POST') {
      const partes = [];
      for await (const p of req) partes.push(p);
      const dados = JSON.parse(Buffer.concat(partes).toString('utf8') || '{}');

      // A mascara e a aula 1: o corpo chega inteiro, e o log guarda a versao
      // mascarada. `senha-errada` e o valor de exemplo do material.
      await registrar('info', id, 'item recebido para gravacao', {
        rota: '/itens',
        ds_usuario: dados.nm_user,
        ds_senha: dados.senha ? '****' + String(dados.senha).slice(-4) : '(vazio)',
      });

      await conn.execute(
        'INSERT INTO tb_item_d13a2 (nm_item, qtd) VALUES (?, ?)',
        [dados.nm_item, Number(dados.qtd)]
      );

      // PASSO 4: o id volta no cabecalho da resposta. E o cliente que le este
      // cabecalho para mandar na proxima mensagem de suporte.
      res.writeHead(201, {
        'Content-Type': 'application/json; charset=utf-8',
        'X-Request-Id': id,
      });
      return res.end(JSON.stringify({ ok: true, request_id: id }));
    }

    // -------------------------- /itens: o caminho que quebra de verdade
    // A tabela `tb_item_d13a2` e criada aqui, e no fim do exemplo ela e
    // DROPEADA. A rota abaixo consulta a tabela depois do drop: e assim que o
    // 500 nasce de um erro REAL do banco, e nao de um `throw` escrito a mao.
    if (req.url === '/itens' && req.method === 'GET') {
      try {
        await conn.query('SELECT * FROM tb_item_que_cai');
        res.writeHead(200, {
          'Content-Type': 'application/json; charset=utf-8',
          'X-Request-Id': id,
        });
        return res.end(JSON.stringify({ ok: true, request_id: id }));
      } catch (erro) {
        // PASSO 3: o log leva o stack e o `erro.code`. O STACK E PARA O
        // TIME, e o corpo da resposta vai sem ele.
        await registrar('error', id, 'falha ao listar itens', {
          codigo_mysql: erro.code,
          etapa: 'listar itens',
          stack: stackEnxuto(erro),
        });

        // O par que o CONTRATO.md exige: o terminal recebe o erro, e a
        // pagina tambem. A linha de contexto ja saiu, com `[log]` e id.
        console.error(erro.code + ': ' + erro.message);
        console.log('  erro no servidor, o cliente recebe so o id '
          + id + ' e a frase traduzida');

        // PASSO 5: o corpo NAO tem stack, NAO tem SQL e NAO tem o codigo cru.
        // O `codigo` interno (`ER_INTERNO`) existe para o time correlacionar
        // pelo log; `mensagem` e o que o cliente pode mostrar para o usuario.
        res.writeHead(statusDoErro(erro), {
          'Content-Type': 'application/json; charset=utf-8',
          'X-Request-Id': id,
        });
        return res.end(JSON.stringify({
          erro: 'ER_INTERNO',
          mensagem: traduzirErro(erro),
          request_id: id,
        }));
      }
    }

    res.writeHead(404, {
      'Content-Type': 'application/json; charset=utf-8',
      'X-Request-Id': id,
    });
    return res.end(JSON.stringify({ erro: 'ER_ROTA', request_id: id }));
  });

  await new Promise((r) => servidor.listen(0, '127.0.0.1', r));
  const base = 'http://127.0.0.1:' + servidor.address().port;
  console.log('servidor no ar em ' + base);
  console.log('a porta muda a cada execucao: e o listen(0) pedindo uma livre');
  console.log('');

  try {
    // ------------------------------------------------- 1. gerado pelo servidor
    console.log('--- 1. requisicao comum: o id nasce no servidor ---');
    const ok = await fetch(base + '/saude');
    const corpoOk = await ok.json();
    console.log('status: ' + ok.status);
    console.log('X-Request-Id na resposta: ' + ok.headers.get('x-request-id'));
    console.log('o corpo tambem repete o id: ' + corpoOk.request_id);
    console.log('estado: ' + corpoOk.estado + ' | banco: ' + corpoOk.banco);

    // ---------------------------------------------- 2. propagado do cliente
    // O cliente manda um id no formato de UUID, e o servidor respeita. E o
    // caminho do CDN/gateway: o id nasce antes e atravessa servicos.
    console.log('\n--- 2. id que veio do cliente: o servidor aceita e propaga ---');
    const idDoCliente = '2f1c9b74-0a55-4d1e-9b3a-7c6e5d4a3b21';
    const comId = await fetch(base + '/saude', {
      headers: { 'X-Request-Id': idDoCliente },
    });
    console.log('mandei  X-Request-Id: ' + idDoCliente);
    console.log('recebi  X-Request-Id: ' + comId.headers.get('x-request-id'));
    console.log('mesmo id? '
      + (comId.headers.get('x-request-id') === idDoCliente));

    // O cliente tambem pode mandar um id que nao e UUID — e aqui ele e
    // descartado. Aceitar texto livre nesse cabecalho e furo: qualquer um
    // manda 4 KB dentro do campo.
    console.log('\n--- 3. id invalido do cliente: descartado, o servidor gera o seu ---');
    const comLixo = await fetch(base + '/saude', {
      headers: { 'X-Request-Id': 'nao-e-um-uuid' },
    });
    console.log('mandei  X-Request-Id: nao-e-um-uuid');
    console.log('recebi  X-Request-Id: ' + comLixo.headers.get('x-request-id'));
    console.log('o id do servidor tem o formato de UUID? '
      + FORMATO_UUID.test(comLixo.headers.get('x-request-id')));

    // --------------------------------------- 4. o caminho feliz, com id proprio
    // A rota do POST grava de verdade e responde 201 com o cabecalho. E a
    // segunda requisicao com id DIFERENTE, e e ela que da sentido ao
    // `COUNT(DISTINCT ds_request_id)` do alerta mais abaixo.
    console.log('\n--- 4. POST /itens: o caminho que funciona ---');
    const criado = await fetch(base + '/itens', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ nm_item: 'teclado', qtd: 2, nm_user: 'ana' }),
    });
    console.log('status: ' + criado.status);
    console.log('X-Request-Id na resposta: ' + criado.headers.get('x-request-id'));
    console.log('corpo: ' + await criado.text());

    // ------------------------------------------------------- 5. o caso do 500
    // A tabela e DROPEADA agora, para o GET /itens achar `ER_NO_SUCH_TABLE`
    // de verdade. O erro e do banco, nao um `throw` de mentira.
    await conn.query('DROP TABLE IF EXISTS tb_item_que_cai');
    console.log('\n--- 5. o 500: erro real do banco, id exposto, stack escondido ---');
    console.log('dropei a tabela que o GET /itens consulta; agora a rota quebra');
    const quebra = await fetch(base + '/itens');
    const corpoErro = await quebra.json();
    const idDoErro = quebra.headers.get('x-request-id');
    console.log('status: ' + quebra.status);
    console.log('corpo: ' + JSON.stringify(corpoErro));
    console.log('X-Request-Id na resposta: ' + idDoErro);

    // A prova de que o stack NAO vazou: o corpo nao tem `stack`, nao tem o
    // nome da tabela e nao tem o `erro.code` cru do MySQL.
    const texto = JSON.stringify(corpoErro);
    console.log('\nno corpo do 500 aparece o nome da tabela interna? '
      + texto.includes('tb_item_que_cai'));
    console.log('no corpo do 500 aparece o codigo cru do MySQL? '
      + texto.includes('ER_NO_SUCH_TABLE'));
    console.log('no corpo do 500 aparece stack? ' + texto.includes('stack'));
    console.log('o que o cliente leva para mostrar: ' + corpoErro.mensagem);
    console.log('o que o cliente leva para o suporte: ' + corpoErro.request_id);

    // ------------------------------------------------------ 6. a busca pelo id
    // E aqui que o ciclo fecha: `achar requisicao pelo ID` e uma consulta, e
    // ela devolve as linhas exatas daquele erro — e so delas. O cliente
    // mandou o id; a `rastreabilidade` faz o resto.
    console.log('\n--- 6. a busca: SELECT ... WHERE ds_request_id = ? ---');
    const [linhas] = await conn.query(
      'SELECT id, st_nivel, ds_mensagem, ds_contexto FROM tb_log '
      + 'WHERE ds_request_id = ? ORDER BY id',
      [idDoErro]
    );
    console.log('o cliente reportou o id ' + idDoErro);
    console.log('linhas devolvidas pela busca: ' + linhas.length);
    for (const l of linhas) {
      const ctx = typeof l.ds_contexto === 'string'
        ? JSON.parse(l.ds_contexto) : l.ds_contexto;
      console.log('  ' + l.st_nivel.padEnd(5) + ' | ' + l.ds_mensagem);
      if (l.st_nivel === 'error') {
        console.log('      codigo_mysql: ' + ctx.codigo_mysql);
        console.log('      etapa: ' + ctx.etapa);
        console.log('      stack (so no log): ' + ctx.stack);
      }
    }
    console.log('o `erro.code` estava no LOG, nao no corpo da resposta:');
    console.log('quem acorda o time e o log; quem fala com o cliente e a traducao');

    // A mesma busca com OUTRO id devolve zero linha. E o que prova que o filtro
    // filtra: nao e "as ultimas linhas do log", e a requisicao pedida.
    const [vazio] = await conn.query(
      'SELECT COUNT(*) AS n FROM tb_log WHERE ds_request_id = ?',
      ['req-que-nunca-aconteceu']
    );
    console.log('\nmesma consulta com um id que nao existe: ' + vazio[0].n + ' linha(s)');

    // O indice torna essa busca barata. O `EXPLAIN` mostra qual caminho o
    // planejador escolheu — e a diferenca entre "achar o erro" e "esperar
    // dois minutos para achar o erro". E o que faz `rastrear` ser rapido.
    const [plano] = await conn.query(
      'EXPLAIN SELECT id FROM tb_log WHERE ds_request_id = ?', [idDoErro]
    );
    console.log('\nEXPLAIN da busca: key=' + plano[0].key
      + ', linhas examinadas=' + plano[0].rows);

    // ------------------------------------------- 7. os numeros que viram alerta
      // Uma unica requisicao com id nao gera alerta. O que gera e a CONTA por
      // janela: a `quantidade de erro` por minuto, e se a taxa subiu. E o que
      // permite `monitorar` e `saber que quebrou` sem olhar o log a mao.
    console.log('\n--- 7. o alerta: contagem por janela, nao o evento isolado ---');
    const [conta] = await conn.query(
      'SELECT st_nivel, COUNT(*) AS total, COUNT(DISTINCT ds_request_id) AS reqs '
      + 'FROM tb_log GROUP BY st_nivel ORDER BY total DESC'
    );
    for (const c of conta) {
      console.log('  ' + c.st_nivel.padEnd(5) + ': ' + c.total
        + ' linha(s) em ' + c.reqs + ' requisicao(oes)');
    }
    console.log('  ---');
    console.log('  uptime: o processo responde, e o /saude diz se o banco tambem');
    const [saudeFinal] = await conn.query('SELECT 1 AS vivo');
    console.log('  banco respondeu: ' + (saudeFinal[0].vivo === 1 ? 'sim' : 'nao')
      + ' | o /saude acima mediu o mesmo caminho');
    console.log('\nalerta de verdade: "N erros por minuto", nao "um erro aconteceu".');
    console.log('O evento isolado e o log; a contagem por janela e o que acorda');
    console.log('alguem. E o `error` sozinho nao diz: 3 `error` de uma pessoa e');
    console.log('3 `error` de tres pessoas sao incidentes diferentes.');
    console.log('O uptime mede o OUTRO lado: ele diz que o servico responde, e nao');
    console.log('que ele faz o que deveria. Os dois numeros juntos fecham o quadro.');

    const [versao] = await conn.query('SELECT VERSION() AS versao');
    console.log('\nbanco em uso: ' + versao[0].versao);
  } catch (erro) {
    console.error('falha no exemplo:', erro.code || erro.name, '-', erro.message);
    console.log('  erro no exemplo:', erro.code || erro.name, '-', erro.message);
    process.exitCode = 1;
  } finally {
    await new Promise((r) => servidor.close(r));
    console.log('\nservidor encerrado com close().');
    await conn.end();
  }
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

servidor no ar em http://127.0.0.1:34891
a porta muda a cada execucao: e o listen(0) pedindo uma livre

--- 1. requisicao comum: o id nasce no servidor ---
  [log] info  32218618-0ad3-4737-b051-f322241c9b67 | requisicao recebida
  [log] info  32218618-0ad3-4737-b051-f322241c9b67 | health check respondeu
status: 200
X-Request-Id na resposta: 32218618-0ad3-4737-b051-f322241c9b67
o corpo tambem repete o id: 32218618-0ad3-4737-b051-f322241c9b67
estado: saudavel | banco: respondendo

--- 2. id que veio do cliente: o servidor aceita e propaga ---
  [log] info  2f1c9b74-0a55-4d1e-9b3a-7c6e5d4a3b21 | requisicao recebida
  [log] info  2f1c9b74-0a55-4d1e-9b3a-7c6e5d4a3b21 | health check respondeu
mandei  X-Request-Id: 2f1c9b74-0a55-4d1e-9b3a-7c6e5d4a3b21
recebi  X-Request-Id: 2f1c9b74-0a55-4d1e-9b3a-7c6e5d4a3b21
mesmo id? true

--- 3. id invalido do cliente: descartado, o servidor gera o seu ---
  [log] info  2bf05643-5009-4cd5-b978-10982cb55cc4 | requisicao recebida
  [log] info  2bf05643-5009-4cd5-b978-10982cb55cc4 | health check respondeu
mandei  X-Request-Id: nao-e-um-uuid
recebi  X-Request-Id: 2bf05643-5009-4cd5-b978-10982cb55cc4
o id do servidor tem o formato de UUID? true

--- 4. POST /itens: o caminho que funciona ---
  [log] info  d238b82f-5542-49c1-bbb3-1ae026c141b5 | requisicao recebida
  [log] info  d238b82f-5542-49c1-bbb3-1ae026c141b5 | item recebido para gravacao
status: 201
X-Request-Id na resposta: d238b82f-5542-49c1-bbb3-1ae026c141b5
corpo: {"ok":true,"request_id":"d238b82f-5542-49c1-bbb3-1ae026c141b5"}

--- 5. o 500: erro real do banco, id exposto, stack escondido ---
dropei a tabela que o GET /itens consulta; agora a rota quebra
  [log] info  5baf7f40-9ac4-40b3-bbab-881df9ac75c1 | requisicao recebida
  [log] error 5baf7f40-9ac4-40b3-bbab-881df9ac75c1 | falha ao listar itens
  erro no servidor, o cliente recebe so o id 5baf7f40-9ac4-40b3-bbab-881df9ac75c1 e a frase traduzida
status: 500
corpo: {"erro":"ER_INTERNO","mensagem":"recurso indisponivel","request_id":"5baf7f40-9ac4-40b3-bbab-881df9ac75c1"}
X-Request-Id na resposta: 5baf7f40-9ac4-40b3-bbab-881df9ac75c1

no corpo do 500 aparece o nome da tabela interna? false
no corpo do 500 aparece o codigo cru do MySQL? false
no corpo do 500 aparece stack? false
o que o cliente leva para mostrar: recurso indisponivel
o que o cliente leva para o suporte: 5baf7f40-9ac4-40b3-bbab-881df9ac75c1

--- 6. a busca: SELECT ... WHERE ds_request_id = ? ---
o cliente reportou o id 5baf7f40-9ac4-40b3-bbab-881df9ac75c1
linhas devolvidas pela busca: 2
  info  | requisicao recebida
  error | falha ao listar itens
      codigo_mysql: ER_NO_SUCH_TABLE
      etapa: listar itens
      stack (so no log): Error: Table 'materiais_teste.tb_item_que_cai' doesn't exist | em aula2.js linha 235
o `erro.code` estava no LOG, nao no corpo da resposta:
quem acorda o time e o log; quem fala com o cliente e a traducao

mesma consulta com um id que nao existe: 0 linha(s)

EXPLAIN da busca: key=ix_tb_log_req, linhas examinadas=2

--- 7. o alerta: contagem por janela, nao o evento isolado ---
  info : 9 linha(s) em 5 requisicao(oes)
  error: 1 linha(s) em 1 requisicao(oes)
  ---
  uptime: o processo responde, e o /saude diz se o banco tambem
  banco respondeu: sim | o /saude acima mediu o mesmo caminho

alerta de verdade: "N erros por minuto", nao "um erro aconteceu".
O evento isolado e o log; a contagem por janela e o que acorda
alguem. E o `error` sozinho nao diz: 3 `error` de uma pessoa e
3 `error` de tres pessoas sao incidentes diferentes.
O uptime mede o OUTRO lado: ele diz que o servico responde, e nao
que ele faz o que deveria. Os dois numeros juntos fecham o quadro.

banco em uso: 10.11.14-MariaDB-0ubuntu0.24.04.1

servidor encerrado com close().