[{"content":" Mais um capítulo da série GenServer the \u0026ldquo;rabbit hole\u0026rdquo;. Dessa vez sem GenServer — só log mesmo. Mas o mesmo princípio: a abstração parece simples, e quase ninguém percebe que está usando errado até a conta chegar.\nImagina o cenário: 2h da manhã, um pod reiniciou e o alerta acordou alguém. A pessoa abre o Loki, filtra pela label do app, e a primeira tela do navegador é isso aqui:\n12:45:33Z info [InboundWebhook] - Income message: %{\u0026#34;path\u0026#34; =\u0026gt; \u0026#34;proxy/977671907166781440/example.cdn.com/v3/openlive/CA2585112_5_2\u0026#34;, ...} 12:45:33Z info Sent 200 in 44ms 12:45:33Z warn [ExternalProvider EventWorker] - Event already processed, skipping: \u0026#34;869247060431936_2026-05-21...\u0026#34; 12:45:33Z warn [SetAuthToken] Resource ba8bf8f3-5ceb-4d7d-a318-f0a88e228f41 send token 010000000000003D and AuthToken is not found 12:45:33Z info Sent 200 in 12ms 12:45:33Z warn [ExternalProvider EventWorker] - Event already processed, skipping: \u0026#34;865478070223930_...\u0026#34; 12:45:33Z info Sent 200 in 39ms 12:45:33Z error Erro ao criar recurso: \u0026#34;network exception\u0026#34; 12:45:33Z info Sent 200 in 22ms 12:45:33Z warn [SetAuthToken] Resource 38d916e1-1ee7-4f91-9f96-db6ff14c0bd7 send token 0100F68F9DD034FB and AuthToken is not found ... (mais 70 linhas por segundo) A janela cobre 1 segundo. Pra entender o restart, é preciso do que aconteceu nos últimos 5 minutos. Boa sorte.\nEsse é o estado de logs de uma aplicação Elixir hipotética em produção (qualquer semelhança com casos reais que você já viu\u0026hellip; bom, é proposital =P). O caso fictício vai servir como fio condutor do artigo — a história é só pra dar concretude pras boas práticas que vêm a seguir.\n16 linhas por segundo vêm do código do time Suponha que a gente rode uma query simples no Loki cobrindo a última hora de produção da aplicação do exemplo, agregando por erl_level:\nsum by (erl_level) (count_over_time({app=\u0026#34;my-app\u0026#34;}[1h])) Os números abaixo são ilustrativos — não saíram de um cluster específico, mas refletem a ordem de grandeza que aparece em apps Elixir maduras rodando Phoenix + Tesla + alguns workers. O ponto não são os valores absolutos: é a forma do problema.\nLevel Linhas/hora % do total Linhas/segundo info 236.667 89,4% 65,7 warning 23.557 8,9% 6,5 error 5.406 2,0% 1,5 notice 22 \u0026lt;0,01% ~0 Total 265.652 100% 73,5 Boa parte desse volume é baseline esperado de framework — coisas que o time não escreveu. O lib/phoenix/logger.ex (request summary \u0026quot;Sent 200 in 44ms\u0026quot;) sozinho gera 200.015 linhas/h (84,5% do total). Some o lib/tesla/middleware/logger.ex (cliente HTTP, 5.244/h) e os crash reports do runtime (gen_server.erl, 1.508/h), e você tem ~207k linhas que não dá pra diminuir sem mexer em config de lib.\nO que sobra — ~58.900 linhas/h, ~16,4 linhas/segundo — é o que o time escreveu. E é aí que mora a história.\nAntes de seguir, um número que vale destacar: 5.406 erros por hora em estado estacionário. Sem incidente, sem rollout, só a app vivendo. Pra efeito de comparação: um post-mortem real registrou 1.931 erros Postgrex 22001 em 3 horas durante um incidente que derrubou pod. A taxa de erro \u0026ldquo;saudável\u0026rdquo; é 8 vezes mais alta do que a taxa de erro durante um incidente que escreveu um post-mortem.\nE quando você filtra o ruído de framework e olha de onde vêm essas 16 linhas/s que o time gera, o quadro fica desconfortável:\ntopk(10, sum by (file) (count_over_time({app=\u0026#34;my-app\u0026#34;} | json [1h]))) (filtrando arquivos sob lib/myapp/)\nArquivo Linhas/h % do que o time escreveu lib/myapp/gateways/external_provider/event_worker.ex 16.218 27,5% lib/myapp/resources/set_auth_token.ex 15.374 26,1% lib/myapp/jobs/consumers/job_finished_consumer.ex 12.848 21,8% lib/myapp/resources/workers/update_resource_cost_center_worker.ex 2.832 4,8% lib/myapp/gateways/device_cloud/positions/enrich_position.ex 2.676 4,5% 3 arquivos respondem por 75% do log produzido pelo time. Cinco arquivos respondem por ~85%. O resto da codebase — milhares de módulos — gera, somados, menos log do que o event_worker.ex do ExternalProvider sozinho.\nEsse é o efeito da poluição. Cada linha dessas, individualmente, parecia útil quando foi escrita. Somadas, elas afogam o que importa quando alguém precisa investigar — exatamente o oposto do que log existe pra fazer. Log poluído não ajuda a debugar; atrapalha. Cada linha \u0026ldquo;só por garantia\u0026rdquo; empurra a linha que realmente conta a história mais pra longe da tela.\nE a concentração também é um ponto que vale guardar pro resto do artigo: cada um desses arquivos do topo tem, no fundo, uma única linha de Logger.x que dispara em loop. O ruído todo do Loki não é a aplicação inteira gritando — é meia dúzia de chamadas que, por estarem em caminho quente, dominam o volume. Vamos olhar o líder pra entender o padrão:\nLogger.warning( \u0026#34;[ExternalProvider EventWorker] - Event already processed, skipping: #{inspect(cache_key)} - #{alert_type}\u0026#34; ) Tradução: \u0026ldquo;esse evento já foi processado, então não vou processar de novo\u0026rdquo;. Isso é dedup funcionando. É o caminho feliz do código. É exatamente o que a função foi escrita pra fazer.\nE é classificado como warning. Em produção. 4,5 vezes por segundo. 16 mil vezes por hora.\nEsse é o tema do artigo.\nParte 1 — O custo do ruído 💎 Log não é gratuito. Cada linha tem três custos diferentes.\nToda vez que aparece um Logger.info(\u0026quot;vou só logar uma coisinha aqui\u0026quot;) no código, três orçamentos invisíveis estão sendo gastos:\n1. Custo direto de infra. Em uma stack de observabilidade self-hosted, você não paga ingestion fee pra ninguém. Mas paga AWS pelos EC2 que rodam o Loki, pelos EBS/S3 que guardam os chunks indexados, pela rede que move log entre pod, ingester e storage. Esse custo cresce linearmente com volume e com retenção. Cada \u0026quot;#{inspect(big_map)}\u0026quot; num caminho quente infla a linha de 80 bytes pra 4 kB e isso vira EBS extra no mês seguinte — não é uma fatura assustadora de SaaS, é uma curva silenciosa de infra. (Loki — best practices for labels tem o resumo de por que o índice sofre tanto quanto o storage.)\n2. Custo cognitivo do on-call. O ser humano que está lendo a tela às 2h da manhã tem largura de banda finita. Se ele precisa rolar 30 segundos de \u0026ldquo;skipping\u0026rdquo; antes de achar a stack trace, a velocidade de resolução do incidente cai. Esse custo não aparece em nenhuma fatura, mas aparece no MTTR (e nas DORA Metrics — falamos sobre).\n3. Custo de cardinalidade. Esse é o mais sutil e o que mais machuca o Loki no médio prazo. Mensagem que muda a cada chamada explode o índice. Olha o pior ofensor do exemplo:\nLogger.warning( \u0026#34;[#{@log_namespace}] Resource #{resource.id} send token #{token} and AuthToken is not found\u0026#34; ) 15.374 linhas/hora dessas, cada uma com uma mensagem única (resource_id e token diferentes). Pra você procurar todas as ocorrências desse caso no Loki, não dá pra filtrar por mensagem exata — você precisa de regex (|~), que é mais caro. E você nunca vai conseguir agrupar por essa \u0026ldquo;categoria de evento\u0026rdquo; porque do ponto de vista da indexação são 15 mil categorias diferentes por hora.\nO padrão correto seria:\n# Mensagem fixa + metadata Logger.warning(\u0026#34;AuthToken not found for incoming token\u0026#34;, resource_id: resource.id, token: token ) Mensagem virou uma categoria. resource_id virou label estruturado, indexável, agrupável. O custo de query no Loki cai. E a busca passa a ser:\n{app=\u0026#34;my-app\u0026#34;} | json | msg=\u0026#34;AuthToken not found for incoming token\u0026#34; em vez de:\n{app=\u0026#34;my-app\u0026#34;} |~ \u0026#34;AuthToken is not found\u0026#34; E o melhor: o resource_id virou um label que você pode pivotar (sum by (resource_id)) pra descobrir se o problema é em um recurso específico ou todos. Antes era impossível.\n🎯 Regra prática: mensagem é constante. Variável é metadata. Se o seu Logger.X(\u0026quot;...\u0026quot;) tem #{interpolation}, pare e pergunte: por que isso não é metadata?\nConfigurando o formatter e o handler A boa notícia é que a infra pra isso costuma já estar disponível — só precisa ser configurada. Em uma app Elixir tipica, o setup mínimo tem duas camadas:\na) Formatter de texto pra dev/teste — config :logger, :default_formatter controla como cada linha aparece no console local. Aqui você lista os campos de metadata que quer ver no terminal:\n# config/config.exs config :logger, :default_formatter, format: \u0026#34;$time $metadata[$level] $message\\n\u0026#34;, metadata: [ :request_id, :graphql_operation_name, :graphql_operation_type, :organization_id, :resource_id, :log_name, :span_id, :trace_id, :module ] Isso é só pra leitura humana no console. Em produção a saída precisa ser estruturada — texto colado não é indexável.\nb) Handler JSON pra produção — plugando uma lib como LoggerJSON, cada chamada de Logger.x vira uma linha JSON com msg, level, time, e toda a metadata como campo de primeira classe:\n# config/runtime.exs if config_env() == :prod do config :logger, :default_handler, formatter: {LoggerJSON.Formatters.Basic, metadata: :all} end A lib já vem com formatters prontos pra alguns destinos comuns (Basic, GoogleCloud, Datadog) — escolha o que casa com o backend de logs que você usa. O importante é que, com handler JSON ligado, resource_id, request_id, trace_id deixam de ser texto solto na mensagem e viram colunas que o Loki (ou equivalente) indexa.\nA linha do set_auth_token do exemplo já poderia ter resource_id indexável e não tem. Não é falta de infra, é falta de hábito.\nLogger.metadata em camadas: configure uma vez, herde sempre Você não precisa repetir resource_id: em toda chamada de log dentro da árvore de processamento de um recurso. O Logger.metadata/1 é por-processo e propaga pra todos os logs subsequentes naquele mesmo processo Elixir. O caso bom vive em middlewares de entrada como esse:\n# lib/my_app_web/middlewares/operation_name_logger.ex defmodule MyAppWeb.Middleware.OperationNameLogger do @behaviour Absinthe.Middleware alias Absinthe.Blueprint.Document.Operation @impl true def call(resolution, _opts) do case Enum.find(resolution.path, \u0026amp;match?(%Operation{}, \u0026amp;1)) do %Operation{name: name, type: type} when not is_nil(name) -\u0026gt; Logger.metadata(graphql_operation_name: name, graphql_operation_type: type) _ -\u0026gt; Logger.metadata(graphql_operation_name: \u0026#34;#NULL\u0026#34;, graphql_operation_type: \u0026#34;#UNKNOWN\u0026#34;) end resolution end end E o pulo do gato é plugar esse middleware no middleware/3 do schema, pra que ele rode antes de qualquer resolver:\n# lib/my_app_web/schema.ex defmodule MyAppWeb.Schema do use Absinthe.Schema # ... def middleware(middleware, _field, _object) do [MyAppWeb.Middleware.OperationNameLogger | middleware] end end A partir daí, toda chamada de Logger.x dentro daquele request — seja no resolver, no contexto, no Ecto, em qualquer lib — herda esses campos. Custo zero por linha.\nA mesma técnica casa bem com outros entry-points — processamento de recurso, de organização, de Oban worker:\n# Em um Oban worker, antes de fazer qualquer outra coisa: def perform(%Job{args: %{\u0026#34;resource_id\u0026#34; =\u0026gt; resource_id, \u0026#34;organization_id\u0026#34; =\u0026gt; org_id}}) do Logger.metadata(resource_id: resource_id, organization_id: org_id) do_the_work() end Daí pra frente, ninguém precisa interpolar IDs em mensagem. O do_the_work() chama mais 5 funções, cada uma faz 3 logs — todos saem com resource_id e organization_id no JSON. Sem trabalho extra.\nSe não quiser repetir esse setup em cada worker, dá pra centralizar via handler de telemetry do Oban. Tem um evento [:oban, :job, :start] que você pode escutar e injetar a metadata antes do perform/1 rodar:\n# lib/my_app/application.ex def start(_type, _args) do :telemetry.attach( \u0026#34;oban-logger-metadata\u0026#34;, [:oban, :job, :start], \u0026amp;MyApp.ObanLogger.handle_event/4, nil ) # ... end # lib/my_app/oban_logger.ex defmodule MyApp.ObanLogger do def handle_event([:oban, :job, :start], _measure, %{job: job}, _config) do Logger.metadata( oban_worker: job.worker, oban_job_id: job.id, resource_id: job.args[\u0026#34;resource_id\u0026#34;], organization_id: job.args[\u0026#34;organization_id\u0026#34;] ) end end Vantagem: workers ficam limpos. Desvantagem: a metadata depende da convenção de keys no args — se um worker usa \u0026quot;device_id\u0026quot; em vez de \u0026quot;resource_id\u0026quot;, fica fora. Escolha o que casa com o time.\nO inspect que nunca dorme Pior do que interpolar ID é interpolar payload inteiro. Caso típico no nosso exemplo:\nLogger.info( \u0026#34;[InboundWebhook] - Income message: #{inspect(params, printable_limit: :infinity, limit: :infinity)}\u0026#34; ) Repare nos parâmetros do inspect: printable_limit: :infinity, limit: :infinity. A chamada está opt-in explicitamente em não truncar. Na prática, esse Logger.info virou uma promessa contratual de imprimir o payload inteiro, por maior que ele seja, em produção, em todo request — provavelmente uma decisão tomada num momento em que isso fazia sentido, mas que envelheceu junto com o volume do endpoint.\nAproveita e olha um vizinho:\nLogger.debug(\u0026#34;Complete resource data: #{inspect(resource_data)}\u0026#34;) Logger.debug em produção fica silenciado pelo config :logger, level: :info, então essa linha não chega no Loki — mas chega no log local de dev/staging, e na primeira vez que alguém ativar debug num pod pra investigar algo, ela vai imprimir a struct inteira de cada recurso a cada interação do usuário no mapa.\n⚠️ Regra prática: inspect/1 em log é cheiro forte. Se você precisa ver o payload pra debugar, salve o ID e busque o payload depois (no banco, no S3, num replay). Se o payload é pequeno e estável, vira metadata estruturada. Se é grande e dinâmico, não é log, é dump — e dump tem outro lugar.\nParte 2 — Severidade não é decoração 💎 error é o nível \u0026ldquo;alguém precisa olhar\u0026rdquo;. Se você usa pra fluxo normal, ninguém olha mais.\nTem uma versão do ditado da fada do dente que diz: se tudo é importante, nada é importante. O nível do log é exatamente isso — uma promessa de relevância. Quando você usa error pra um cenário que não é erro, você quebra o contrato com quem está lendo.\nA semântica que eu defendo (e que é mais ou menos consenso no Elixir e fora):\nNível Cenário Quem é o público debug Detalhe técnico útil só durante investigação ativa Você, no dia em que precisar. Silenciado em prod. info Evento de negócio importante; transição de estado relevante Auditoria, replay, \u0026ldquo;o que esse pod fez na última hora\u0026rdquo; warning Algo degradou mas se recuperou; condição inesperada que não impediu o fluxo O time, na próxima retrospectiva error Algo quebrou e alguém precisa olhar On-call, agora Com esse critério na mão, vamos olhar dois casos típicos.\nCaso 1: auth-deny não é erro if authorized?(token) do conn else Logger.error(\u0026#34;Unauthorized request with token: #{inspect(token)}\u0026#34;) abort_request(conn) end Em produção, isso aparece com erl_level=error:\n{ \u0026#34;msg\u0026#34;: \u0026#34;Unauthorized request with token: nil\u0026#34;, \u0026#34;function\u0026#34;: \u0026#34;call/2\u0026#34;, \u0026#34;module\u0026#34;: \u0026#34;Elixir.MyAppWeb.Plugs.RequireApiToken\u0026#34;, \u0026#34;request_id\u0026#34;: \u0026#34;GLGTwuoyCvEsYq0AtM1E\u0026#34; } Tem dois aprendizados nessa linha:\nCliente sem token (ou com token errado) é fluxo normal de uma API pública. Não é falha do sistema. Ninguém vai ser acordado às 2h da manhã pra investigar isso. Encaixaria melhor como info ou — se vale a pena ser observável — entrando como métrica (:telemetry.execute([:myapp, :auth, :denied], …)) em vez de log. #{inspect(token)} acaba colocando o valor do token na linha de log. No exemplo, o token era nil (faltou o header no request). Mas no dia em que vier um token inválido mas real, ele vai parar no Loki self-hosted, indexado, retido pelo período que o cluster guarda. É um risco de exposição de credencial que vale evitar. E a alternativa via telemetry não fica solta — o caminho concreto é declarar a métrica e plugar num Reporter:\n# lib/my_app/telemetry.ex defmodule MyApp.Telemetry do import Telemetry.Metrics def metrics do [ counter(\u0026#34;myapp.auth.denied.count\u0026#34;, event_name: [:myapp, :auth, :denied], tags: [:reason] ) ] end end Daí no application.ex:\n# lib/my_app/application.ex children = [ {TelemetryMetricsPrometheus, [metrics: MyApp.Telemetry.metrics()]}, # ... ] E no plug, no lugar do Logger.error:\n:telemetry.execute([:myapp, :auth, :denied], %{count: 1}, %{reason: :missing_token}) Agora \u0026ldquo;quantos auth-deny tivemos hoje\u0026rdquo; é um gráfico, com tag por razão. Nada vai pro Loki — e ninguém é acordado.\nCaso 2: o retry esperado virou alerta Logger.error(\u0026#34;Erro ao criar recurso: #{inspect(response)}\u0026#34;) Esse log aparece em produção como:\n{ \u0026#34;msg\u0026#34;: \u0026#34;Erro ao criar recurso: \\\u0026#34;network exception\\\u0026#34;\u0026#34;, \u0026#34;module\u0026#34;: \u0026#34;Elixir.MyApp.Gateways.DeviceCloud.Device.ConfigResource\u0026#34;, \u0026#34;function\u0026#34;: \u0026#34;configure_resource/3\u0026#34; } Aqui o que acontece é: chamada HTTP pra um gateway externo deu network exception. O código trata isso logo na linha seguinte: get_resource(device_channel, area_id, external_id) — vai consultar a API pra ver se o recurso já existe, faz retry, segue a vida. A operação se recupera sozinha.\nIsso não é error. Isso é warning (talvez), ou nem isso. E o top 1 de volume de erros nesse mesmo serviço é exatamente esse módulo: 1.628 \u0026ldquo;erros\u0026rdquo; por hora vindos de process_event.ex do mesmo gateway, no mesmo padrão de \u0026ldquo;API externa lenta/instável, tô tentando de novo\u0026rdquo;.\nQuem vê o painel de erros vê 5.406 errors/h e mete o pé do freio. Quem investiga descobre que ~30-50% disso é retry esperado de gateway. A métrica mente — e quando ela mente, ninguém olha mais.\nO caso bom (também tem) Pra fechar, um exemplo correto:\nLogger.warning( \u0026#34;[CrmWebhookWorker] webhook_id=#{webhook.id} rate-limited — snoozing #{seconds}s\u0026#34; ) Rate limit do destino: algo degradou (não vamos entregar agora), mas se recupera (vamos tentar de novo em seconds). Level warning, mensagem ação-orientada, ID estruturado (mesmo que dentro da string — o webhook_id= ajuda quem busca, mas a forma ideal seria Logger.warning(\u0026quot;rate-limited; snoozing\u0026quot;, webhook_id: webhook.id, snooze_seconds: seconds)).\nEsse log diz uma coisa útil pra um humano: estou fazendo o snooze, fica de olho se virar epidemia. É exatamente isso que warning deve dizer.\nSobre o idioma do log Você vai notar que parte dos exemplos acima está em português (Erro ao criar recurso, Recurso não encontrado) e parte em inglês. Não é incomum encontrar codebases bilíngues — coisa que acontece naturalmente em time bilíngue ao longo do tempo, e que individualmente cada escolha fez sentido.\nNão tem nada de errado em logar em português. O custo é específico de misturar na mesma codebase: alerta por substring precisa cobrir as duas variantes ({app=\u0026quot;my-app\u0026quot;} |~ \u0026quot;not found|não encontrado\u0026quot;), busca no Loki força lembrar de duas versões da palavra, e quem ainda está se ambientando no domínio gasta atenção extra na tradução mental.\nA reflexão é simples: escolher um idioma só — inglês ou português, qualquer dos dois — entrega mais consistência do que o estado misto. É uma conversa que vale o time fazer com calma, escolher um e seguir daí em diante.\nParte 3 — Telemetry e Logger não são a mesma coisa 💎 Se você consegue contar e agrupar, é métrica. Se você precisa contar a história, é log.\nUma stack Elixir moderna costuma ter :telemetry configurado, e o OpenTelemetry plugado no formatter de log via trace_id e span_id. Os dois canais são complementares, não substitutos. A convenção que eu uso:\nTelemetry responde \u0026ldquo;quantos / com que frequência / quão rápido\u0026rdquo;. Métrica, agregação, dashboard. Ex.: [:myapp, :crm, :webhook, :received], com :duration, :batch_size, provider. Logger responde \u0026ldquo;o que aconteceu nesse caso específico que eu preciso reconstruir\u0026rdquo;. Evento singular, com IDs pra cross-reference. Ex.: Logger.warning(\u0026quot;webhook rate-limited\u0026quot;, webhook_id: id, retry_in_seconds: s). O caso bom é esse:\n:telemetry.execute( [:myapp, :crm, :webhook, :received], Map.put(summary, :batch_size, length(events)), %{provider: :hubspot} ) Métrica pura. Nenhum log redundante. Quem quiser saber \u0026ldquo;quantos webhooks o HubSpot mandou hoje\u0026rdquo; não vai ler logs — vai abrir o dashboard.\nPlugando isso na sua app Pra :telemetry.execute/3 virar gráfico de fato, falta o outro lado da equação — alguém escutando o evento e exportando como métrica. O caminho mais direto numa app Elixir moderna é a combinação :telemetry_metrics + um Reporter:\n# lib/my_app/telemetry.ex defmodule MyApp.Telemetry do import Telemetry.Metrics def metrics do [ counter(\u0026#34;myapp.crm.webhook.received.count\u0026#34;, event_name: [:myapp, :crm, :webhook, :received], tags: [:provider] ), distribution(\u0026#34;myapp.crm.webhook.received.duration\u0026#34;, event_name: [:myapp, :crm, :webhook, :received], measurement: :duration, tags: [:provider], unit: {:native, :millisecond} ) ] end end # lib/my_app/application.ex children = [ {TelemetryMetricsPrometheus, [metrics: MyApp.Telemetry.metrics()]}, # ... ] Pronto: o Reporter expõe um endpoint /metrics no formato Prometheus, e o evento [:myapp, :crm, :webhook, :received] vira myapp_crm_webhook_received_count_total{provider=\u0026quot;hubspot\u0026quot;} no painel.\nPra casos mais específicos onde Reporter pronto não dá conta, dá pra anexar um handler customizado:\n:telemetry.attach( \u0026#34;myapp-crm-webhook-logger\u0026#34;, [:myapp, :crm, :webhook, :received], fn _event, measure, meta, _config -\u0026gt; # lógica customizada aqui MyApp.Metrics.record(measure, meta) end, nil ) Cuidado com isso em hot path — attach/4 roda síncrono no processo que emitiu o evento. Pra evento de alta frequência, prefira o Reporter pronto.\nO caso ruim Quase toda codebase tem: o mesmo evento gera log E métrica. O log fica desalinhado da métrica (texto antigo, formato antigo, sem o campo novo que entrou na métrica) e o on-call lê o log porque é o que aparece no Loki. Pior dos dois mundos.\n🎯 Regra prática: antes de adicionar um Logger.info num caminho de alta frequência, pergunte: isso vai virar gráfico um dia? Se vai, é telemetria. Não log.\nParte 4 — Debuggabilidade: a hora em que o log salva Tudo que conversamos até aqui foi sobre podar. Mas o log tem um trabalho que nenhuma outra ferramenta faz tão bem: contar a história de um caso específico, pra um humano, depois do fato.\nPensa num incidente de cascata via process links (o tipo de coisa que a Parte 2 da série descreve). O que permite reconstruir esse tipo de incidente em algumas horas é exatamente o que costuma estar bem feito nos logs:\ntrace_id estável atravessando processos. A Task que crashou tinha o mesmo trace_id do request original; o GenServer que recebeu o {:EXIT, _, :shutdown} tinha o trace_id herdado por causa do Logger.metadata. Mensagens distinguíveis das exceções: Postgrex.Error 22001 string_data_right_truncation é uma string única e googlável. function_clause em Highlander.handle_info/2 aponta direto pro código. Contagem agregada funciona porque a mensagem é estável: count_over_time({...} |= \u0026quot;22001\u0026quot; [3h]) dá 1.931 sem precisar de regex caro. É esse trio que permite sair da hipótese errada (\u0026ldquo;é OOM\u0026rdquo;) pra hipótese certa (\u0026ldquo;é cascade de link via Task\u0026rdquo;) em poucas iterações.\nAgora olha o anti-padrão exato disso:\nexception -\u0026gt; Logger.error(\u0026#34;Unexpected error: #{inspect(exception)}\u0026#34;, error: exception, stacktrace: __STACKTRACE__ ) Resolution.put_result(resolution, @default_error) A intenção é exatamente a certa: middleware do Absinthe que captura exceções inesperadas pra não derrubar a resposta GraphQL. A forma, porém, tem três pontos que vale repensar do ponto de vista de investigabilidade:\nA mensagem é constante (\u0026quot;Unexpected error: ...\u0026quot;) mas o conteúdo do inspect(exception) muda a cada caso. Você vai cair na mesma armadilha de cardinalidade da Parte 1 — não dá pra agrupar por \u0026ldquo;tipo de exceção\u0026rdquo;. O resolver que estourou não está no log (não tem graphql_operation_name na mensagem — vai pegar do metadata se o OperationNameLogger rodou antes; bom em teoria, frágil em prática). O caller recebe {:error, :unknown} (o @default_error) — não tem como o frontend distinguir \u0026ldquo;validação falhou\u0026rdquo; de \u0026ldquo;BEAM pegou fogo\u0026rdquo;. O conserto idiomático seria algo como:\nexception -\u0026gt; Logger.error(\u0026#34;absinthe resolver raised\u0026#34;, exception_module: exception.__struct__, exception_message: Exception.message(exception), stacktrace: Exception.format_stacktrace(__STACKTRACE__) ) Resolution.put_result(resolution, @default_error) Mensagem estável (\u0026quot;absinthe resolver raised\u0026quot;), módulo da exceção como metadata indexável (exception_module=Postgrex.Error, exception_module=ArgumentError), mensagem humana separada. Agora dá pra fazer:\nsum by (exception_module) (count_over_time({app=\u0026#34;my-app\u0026#34;} | json | msg=\u0026#34;absinthe resolver raised\u0026#34; [1h])) e ver, em um gráfico, quais exceções são as mais frequentes. Mesma intenção do código original, com debuggabilidade ligada.\nBônus: PII em log Os mesmos caminhos que comem o pulmão do Loki também são onde a PII costuma vazar. Caso típico:\nLogger.debug(\u0026#34;[Forms] form: create with params #{inspect(params)}\u0026#34;) params aqui é o body inteiro do request — em checklist pode incluir CPF, telefone, foto. Mesmo sendo debug (silenciado em prod), basta alguém ativar debug num pod por 5 minutos pra essa linha derramar dados pessoais no Loki. E uma vez no Loki, fica retido pelo período de retenção. É um caso onde vale repensar a estratégia: logar só o ID do form recém-criado, e deixar o payload completo fora.\nPra payload de request, o padrão correto começa na entrada — Absinthe, por exemplo, tem filtro explícito de variáveis:\nconfig :absinthe, Absinthe.Logger, pipeline: true, filter_variables: [\u0026#34;access_token\u0026#34;, \u0026#34;password\u0026#34;, \u0026#34;secret\u0026#34;] Lista explícita de chaves a filtrar. Não é à prova de balas (precisa lembrar de incluir cada campo novo), mas é melhor que inspect/1 puro.\nPra uma rede de segurança adicional — que pega o que escapa do filtro de entrada — o LoggerJSON aceita redactors aplicados a toda saída de log:\n# config/runtime.exs (prod) config :logger, :default_handler, formatter: {LoggerJSON.Formatters.Basic, metadata: :all, redactors: [ {LoggerJSON.Redactors.RedactKeys, [\u0026#34;password\u0026#34;, \u0026#34;token\u0026#34;, \u0026#34;cpf\u0026#34;, \u0026#34;access_token\u0026#34;]} ]} Toda chave dessa lista, encontrada em qualquer profundidade da metadata serializada, sai como \u0026quot;[REDACTED]\u0026quot;. Não substitui o filtro na entrada (que evita inclusive o objeto sequer ser construído), mas funciona como linha de defesa final pra quando alguém esquece.\nChecklist: como avaliar uma linha de log antes de mergear Toda vez que você adicionar (ou tocar) um Logger.x num PR, passe por essa checklist:\nCamada 1 — A linha Level certo? error é \u0026ldquo;acorde alguém\u0026rdquo;, warning é \u0026ldquo;degradou mas se recuperou\u0026rdquo;, info é \u0026ldquo;evento de negócio\u0026rdquo;, debug é \u0026ldquo;investigação ativa\u0026rdquo;. Sob dúvida, desça um nível. Mensagem fixa? Se a string muda a cada chamada (#{...}), você está fabricando cardinalidade. Mova o que muda pra metadata. Sem inspect/1 de struct grande? Especialmente sem printable_limit: :infinity. Se precisa ver o objeto inteiro, não é log, é dump. Camada 2 — Contexto Tem ID que liga essa linha ao resto da execução? request_id, resource_id, organization_id, trace_id. O formatter do app já declara esses campos — basta usar. Você precisa repetir o ID em toda chamada? Quase nunca. Configure uma vez no entry-point com Logger.metadata/1 (hexdocs) e toda a árvore herda. Camada 3 — Conteúdo PII fora? Token, CPF, e-mail, telefone, foto, payload inteiro de request não entram em log. Lista explícita de campos a filtrar, no estilo do Absinthe filter_variables, e redactor de defesa final no LoggerJSON. Não duplica o que telemetry já mede? Se a pergunta que essa linha responde é \u0026ldquo;quantos / em quanto tempo\u0026rdquo;, a resposta é métrica, não log. Camada 4 — Volume Está num hot path? LiveView event handler, Broadway processor, Oban worker em loop, GenServer com mailbox cheia. Se sim: o level natural é debug. Se a intuição é que precisa ser info, vale articular o motivo antes — geralmente um deles aparece. A linha é repetida muitas vezes com a mesma mensagem? Considere se é \u0026ldquo;evento individual\u0026rdquo; (log) ou \u0026ldquo;métrica de frequência\u0026rdquo; (telemetry). Camada 5 — Investigabilidade Se um humano precisar achar essa linha no Loki às 3 da manhã, ele consegue? A mensagem é distinguível? Tem labels que permitem narrar (| json | msg=\u0026quot;...\u0026quot;)? Tem trace_id pra cruzar com OTel? Se essa linha aparecer 10.000 vezes/h, alguém vai notar? Se não vai, o nível tá errado pra cima. Se vai, o nível tá certo — mas é melhor virar telemetry. Em uma frase Log é o canal mais barato de comunicar com o seu eu de daqui a três meses. Não desperdice.\nOs números que abriram o artigo — 16 linhas/segundo escritas pelo time do exemplo, 5.406 erros/hora, 75% disso vindo de 3 arquivos — não são \u0026ldquo;muito log\u0026rdquo;. São log mal calibrado. A diferença entre o estado atual e um estado saudável não é cortar pela metade: é cada linha ter um leitor previsto e um trabalho específico.\nEsse artigo não pede pra ninguém abrir PR amanhã. O objetivo dele é só pôr cada um dos pontos acima na cabeça do time. Da próxima vez que você abrir um arquivo e tiver que adicionar (ou tocar) um Logger.x, que a pergunta \u0026ldquo;esse log responde a alguma das 5 camadas do checklist?\u0026rdquo; venha junto, e que a resposta seja consciente.\nE da próxima vez que você for escrever Logger.error(\u0026quot;Erro ao …\u0026quot;), lembra: 1.628 outras linhas idênticas já estão na fila, e nenhuma delas é erro.\nReferências Logger (hexdocs) — toda a API, com a parte de metadata Logger.metadata/1 — propagação por processo logger_json (hexdocs) — formatter JSON e redactors :telemetry (hexdocs) — métrica vs log Telemetry.Metrics (hexdocs) — definição declarativa de métricas TelemetryMetricsPrometheus (hexdocs) — Reporter pra expor /metrics Loki — best practices for labels — por que cardinalidade na mensagem dói 12-Factor App — XI. Logs — a opinião clássica sobre log como stream de eventos GenServer the \u0026ldquo;rabbit hole\u0026rdquo; (parte 1) — parte 1 da série GenServer the \u0026ldquo;rabbit hole\u0026rdquo; — Parte 2: A cascata silenciosa — parte 2 DORA Metrics — onde MTTR vira número, e onde log de qualidade vira diferença mensurável ","permalink":"https://daniel.ws/pt-br/posts/logs-in-production-the-invisible-cost-of-noise/","summary":"\u003cblockquote\u003e\n\u003cp\u003eMais um capítulo da série \u003ca href=\"https://daniel.ws/pt-br/posts/genserver-the-rabbit-hole/\"\u003eGenServer the \u0026ldquo;rabbit hole\u0026rdquo;\u003c/a\u003e. Dessa vez sem GenServer — só log mesmo. Mas o mesmo princípio: a abstração parece simples, e quase ninguém percebe que está usando errado até a conta chegar.\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eImagina o cenário: 2h da manhã, um pod reiniciou e o alerta acordou alguém. A pessoa abre o Loki, filtra pela label do app, e a primeira tela do navegador é isso aqui:\u003c/p\u003e","title":"Logs em produção: o custo invisível do ruído"},{"content":" Continuação de GenServer the \u0026ldquo;rabbit hole\u0026rdquo;. Se você ainda não leu, recomendo passar por lá antes — vamos partir do mesmo projeto genserver_study e dos mesmos conceitos de mailbox, supervisor e max_restarts.\nNo artigo anterior nós exploramos como um GenServer \u0026ldquo;mal comportado\u0026rdquo; pode encerrar toda a árvore de supervisão quando estoura o max_restarts do supervisor. Mostramos como mensagens são perdidas no mailbox e como o \u0026ldquo;Let it crash\u0026rdquo; não é exatamente um cheque em branco para deixar processos quebrarem aleatoriamente.\nNaquele exemplo, entretanto, o erro nascia dentro do próprio GenServer — uma exception explícita no handle_info/2. A vida real raramente é tão direta. Boa parte dos restarts de pods Elixir que eu já investiguei em produção tem a mesma assinatura: um worker singleton que não tinha nenhuma exception no seu próprio código simplesmente desaparece, levando junto seu supervisor, e o BEAM sai com exit-code != 0. Você olha a memória do pod e está em ~5GB com limite de 24Gi. Não é OOM. É supervisor cascade.\nA pergunta que fica é: como um worker que nunca crasha morre?\nA resposta envolve três conceitos que costumam ser tratados como detalhe nas docs oficiais e que, juntos, formam a maior fonte de \u0026ldquo;restart fantasma\u0026rdquo; em apps Elixir:\nProcess links propagam exits silenciosamente — e quase tudo que você usa cria links por baixo dos panos. trap_exit transforma exits em mensagens, mas se você não tem clauses para todas as variantes, o :function_clause te derruba. Validações que ficaram só \u0026ldquo;na camada de cima\u0026rdquo; — erros conhecidos viram exceptions de runtime no caminho mais crítico. Vamos por partes 🐇.\nLinks: a faca de dois gumes Quando um processo Elixir começa a rodar, ele pode estar conectado a outros processos por dois mecanismos diferentes: links e monitors. Os dois servem para \u0026ldquo;saber que alguém morreu\u0026rdquo;, mas a diferença é fundamental:\nLink (Process.link/1, spawn_link/1, Task.async/1, Task.async_stream/3): conexão bidirecional. Se A está linkado com B e B morre com reason != :normal, então A também recebe o sinal de exit e morre junto (a menos que A esteja com trap_exit ativado). Monitor (Process.monitor/1, Task.async_nolink/1): conexão unidirecional. A recebe uma mensagem {:DOWN, ref, :process, pid, reason} quando B morre, mas A não é afetado. Por que isso importa? Porque um link te dá uma garantia muito forte: se um dos lados morre, o outro também morre. Em uma árvore de supervisão isso é exatamente o que você quer — o supervisor está linkado aos filhos, então ele sabe que alguém caiu e pode tomar uma decisão (restartar, escalar, encerrar). Mas em um worker comum que não deveria morrer junto com seus auxiliares, o link silencioso é uma armadilha.\nE o pior: a maioria das funções \u0026ldquo;ergonômicas\u0026rdquo; de spawn em Elixir cria links por padrão. Vamos olhar uma das mais comuns: Task.async_stream/3.\nTask.async_stream: o link que ninguém leu Da doc oficial:\nEach element of enumerable will be prepended to the given args and processed by its own task. Those tasks will be linked to an intermediate process that is then linked to the caller process.\nIf you find yourself trapping exits to ensure errors in the tasks do not terminate the caller process, consider using Task.Supervisor.async_stream_nolink/6 to start tasks that are not linked to the caller process.\nEm outras palavras: se qualquer uma das Tasks crashar com uma exception não tratada, o processo que chamou Task.async_stream/3 morre junto. A doc inclusive já avisa, mas é fácil deixar passar quando você está fazendo um simples fan-out paralelo de IO.\nVamos reproduzir isso no nosso projeto genserver_study. Crie um novo módulo BatchServer:\ndefmodule BatchServer do use GenServer @prefix \u0026#34;[BatchServer]\u0026#34; @interval 1_500 def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__) @impl true def init(_) do IO.puts(\u0026#34;#{@prefix} Initializing GenServer\u0026#34;) schedule_next_run() {:ok, %{batches: 0}} end @impl true def handle_info(:run_batch, %{batches: n} = state) do IO.puts(\u0026#34;#{@prefix} Iniciando batch #{n + 1} (PID #{inspect(self())})\u0026#34;) 1..5 |\u0026gt; Task.async_stream(\u0026amp;process_job/1, ordered: false, max_concurrency: 3, timeout: 1_000 ) |\u0026gt; Stream.run() IO.puts(\u0026#34;#{@prefix} Batch #{n + 1} concluído\u0026#34;) schedule_next_run() {:noreply, %{state | batches: n + 1}} end defp process_job(id) do IO.puts(\u0026#34;#{@prefix} Processando job #{id}\u0026#34;) :timer.sleep(100) # Job 3 simula uma falha externa não esperada # (imagine: API externa retornou body inválido, payload \u0026gt; 255 chars, etc.) if id == 3 do raise \u0026#34;API externa devolveu erro inesperado\u0026#34; end IO.puts(\u0026#34;#{@prefix} Job #{id} done\u0026#34;) end defp schedule_next_run, do: Process.send_after(self(), :run_batch, @interval) end Adicione na árvore de supervisão substituindo os módulos anteriores:\ndef start(_type, _args) do children = [ BatchServer ] opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor] Supervisor.start_link(children, opts) end Suba a aplicação:\niex -S mix [BatchServer] Initializing GenServer [BatchServer] Iniciando batch 1 (PID #PID\u0026lt;0.156.0\u0026gt;) [BatchServer] Processando job 1 [BatchServer] Processando job 2 [BatchServer] Processando job 3 18:42:11.301 [error] Task #PID\u0026lt;0.160.0\u0026gt; started from BatchServer terminating ** (RuntimeError) API externa devolveu erro inesperado (genserver_study 0.1.0) lib/genserver_study/batch_server.ex:34: BatchServer.process_job/1 ... Function: \u0026amp;BatchServer.process_job/1 Args: [3] 18:42:11.302 [error] GenServer BatchServer terminating ** (EXIT from #PID\u0026lt;0.156.0\u0026gt;) shell process exited with reason: an exception was raised: ** (RuntimeError) API externa devolveu erro inesperado (genserver_study 0.1.0) lib/genserver_study/batch_server.ex:34: BatchServer.process_job/1 [BatchServer] Initializing GenServer [BatchServer] Iniciando batch 1 (PID #PID\u0026lt;0.166.0\u0026gt;) ... Repare nas duas linhas em sequência:\nTask #PID\u0026lt;0.160.0\u0026gt; \u0026hellip; terminating — uma das Tasks levanta RuntimeError. GenServer BatchServer terminating — e logo em seguida o GenServer morre junto, sem ter executado uma linha sequer do código dele que pudesse falhar. O BatchServer nunca crashou por culpa própria. A Task estava linkada a um processo intermediário que estava linkado ao BatchServer. Quando a Task levantou exception, o sinal de exit subiu pelos links e matou o GenServer. Se esse loop acontecer rapidamente o suficiente, o supervisor estoura max_restarts e a aplicação inteira sai — exatamente o cenário do incidente real que motivou esse artigo.\n💡 Detalhe sutil: o erro foi numa Task. O log mostra a stack trace da Task. Quando você está investigando um restart fantasma e procura o \u0026ldquo;primeiro erro do GenServer X\u0026rdquo; nos logs, você não vai achar nada relacionado ao código do GenServer. A causa raiz está em outro processo que estava linkado a ele. Pesquise pelo nome do GenServer como o \u0026ldquo;started from\u0026rdquo;, não como o \u0026ldquo;GenServer terminating\u0026rdquo;.\nSolução 1: Task.Supervisor.async_stream_nolink A correção mais direta é desacoplar as tasks do worker que as despacha. A função Task.Supervisor.async_stream_nolink/6 faz exatamente isso: as Tasks ficam linkadas a um Task.Supervisor (não ao seu GenServer), e o stream emite tuplas {:ok, value} ou {:exit, reason} para cada elemento — você decide o que fazer com cada uma.\nVamos adicionar um Task.Supervisor na árvore e refatorar o BatchServer:\ndef start(_type, _args) do children = [ {Task.Supervisor, name: BatchServer.TaskSupervisor}, BatchServer ] opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor] Supervisor.start_link(children, opts) end E no BatchServer, troque o Task.async_stream/3 por:\n@impl true def handle_info(:run_batch, %{batches: n} = state) do IO.puts(\u0026#34;#{@prefix} Iniciando batch #{n + 1} (PID #{inspect(self())})\u0026#34;) BatchServer.TaskSupervisor |\u0026gt; Task.Supervisor.async_stream_nolink(1..5, \u0026amp;process_job/1, ordered: false, max_concurrency: 3, timeout: 1_000, on_timeout: :kill_task ) |\u0026gt; Enum.each(fn {:ok, _value} -\u0026gt; :ok {:exit, reason} -\u0026gt; IO.puts(\u0026#34;#{@prefix} ⚠️ job falhou: #{inspect(reason)}\u0026#34;) end) IO.puts(\u0026#34;#{@prefix} Batch #{n + 1} concluído\u0026#34;) schedule_next_run() {:noreply, %{state | batches: n + 1}} end Repare em duas mudanças importantes além da troca da função:\non_timeout: :kill_task — sem essa opção, um timeout no async_stream_nolink ainda derruba o caller (on_timeout: :exit é o default!). Com :kill_task, o timeout vira mais um {:exit, :timeout} no stream. Enum.each com pattern matching para :ok e :exit — agora cada falha é uma tupla que você inspeciona, loga, métrifica, etc. A falha de uma Task não derruba mais o GenServer. Subindo a aplicação novamente:\niex -S mix [BatchServer] Initializing GenServer [BatchServer] Iniciando batch 1 (PID #PID\u0026lt;0.158.0\u0026gt;) [BatchServer] Processando job 1 [BatchServer] Processando job 2 [BatchServer] Processando job 3 19:01:44.512 [error] Task #PID\u0026lt;0.162.0\u0026gt; started from #PID\u0026lt;0.157.0\u0026gt; terminating ** (RuntimeError) API externa devolveu erro inesperado ... [BatchServer] Processando job 4 [BatchServer] Processando job 5 [BatchServer] Job 4 done [BatchServer] Job 5 done [BatchServer] ⚠️ job falhou: {%RuntimeError{message: \u0026#34;API externa devolveu erro inesperado\u0026#34;}, [...]} [BatchServer] Batch 1 concluído [BatchServer] Iniciando batch 2 (PID #PID\u0026lt;0.158.0\u0026gt;) ... O PID do BatchServer agora é estável entre batches (#PID\u0026lt;0.158.0\u0026gt;). A Task que falhou está logada como \u0026ldquo;started from #PID\u0026lt;0.157.0\u0026gt;\u0026rdquo; — esse é o Task.Supervisor, não o nosso GenServer.\n🎯 Regra prática: se um GenServer faz fan-out paralelo de trabalho cujo escopo é \u0026ldquo;esta iteração\u0026rdquo; (e não a vida do server), use Task.Supervisor.async_stream_nolink/6. Use Task.async_stream/3 apenas quando você quer que o erro derrube o caller (cenários raros, normalmente em scripts de migração ou jobs de batch que rodam isolados em um BEAM dedicado).\nTrap exit: a tentação de \u0026ldquo;só capturar tudo\u0026rdquo; A outra opção para conter exits é o Process.flag(:trap_exit, true) que mencionamos lá em cima. Da doc oficial:\nIf pid is trapping exits, the exit signal is transformed into a message {:EXIT, from, reason} and delivered to the message queue of pid.\nParece a bala de prata: ative trap_exit no seu GenServer e os exits dos processos linkados viram mensagens normais que você trata no handle_info/2. Não morre mais.\nNão é tão simples. E a biblioteca Highlander (um singleton manager para cluster Elixir) tem um caso histórico que ilustra bem o problema.\nA Highlander ativa trap_exit para detectar conflitos de nome ({:EXIT, _, :name_conflict} em casos de netsplit). O código relevante na versão 0.2.1:\n@impl true def handle_info({:DOWN, ref, :process, _, _}, %{ref: ref} = state) do {:noreply, register(state)} end def handle_info({:EXIT, _pid, :name_conflict}, %{pid: pid} = state) do :ok = Supervisor.stop(pid, :shutdown) {:stop, {:shutdown, :name_conflict}, Map.delete(state, :pid)} end # ⚠️ E só. Não tem clause para {:EXIT, _, :shutdown}, {:EXIT, _, _}, nem para _msg. Resultado: quando o supervisor interno do Highlander estourou ele mesmo o max_restarts (cascateando de um bug a montante), ele encerrou com motivo :shutdown. Esse :shutdown chegou no GenServer do Highlander como {:EXIT, pid, :shutdown}. Não existia clause para essa mensagem → :function_clause → o singleton inteiro caiu.\nSe você ativa trap_exit, você é responsável por tratar todas as variantes possíveis de {:EXIT, _, _}. E mais ainda: idealmente, qualquer GenServer crítico deveria ter um handle_info catch-all como defesa em profundidade contra mensagens desconhecidas (de bibliotecas, de timers órfãos, de monitores antigos):\ndefmodule SingletonServer do use GenServer @prefix \u0026#34;[SingletonServer]\u0026#34; def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__) @impl true def init(_) do Process.flag(:trap_exit, true) IO.puts(\u0026#34;#{@prefix} Initializing (PID #{inspect(self())})\u0026#34;) {:ok, %{children: []}} end # Tratamento específico que importa para a lógica @impl true def handle_info({:EXIT, _pid, :normal}, state), do: {:noreply, state} def handle_info({:EXIT, pid, :shutdown}, state) do IO.puts(\u0026#34;#{@prefix} child #{inspect(pid)} desligou ordenadamente\u0026#34;) {:noreply, state} end def handle_info({:EXIT, pid, reason}, state) do IO.puts(\u0026#34;#{@prefix} ⚠️ child #{inspect(pid)} morreu com #{inspect(reason)}\u0026#34;) # Decide aqui: re-spawnar? métrica? alerta? mas NÃO crashar. {:noreply, state} end # Defesa em profundidade — qualquer outra mensagem desconhecida vira log + segue def handle_info(msg, state) do IO.puts(\u0026#34;#{@prefix} ⚠️ mensagem desconhecida: #{inspect(msg)}\u0026#34;) {:noreply, state} end end ⚠️ Repare na ordem: clauses mais específicas primeiro, catch-all (handle_info(msg, state)) por último. Erlang/Elixir resolve por ordem textual no módulo.\nQuando NÃO ativar trap_exit Existe uma armadilha aqui também. trap_exit muda o ciclo de vida do seu processo de uma forma importante: ele também intercepta o sinal de shutdown vindo do supervisor pai. Isso significa que se você ativar trap_exit, precisa implementar terminate/2 corretamente, e o supervisor pai vai esperar :shutdown_timeout (default 5s) por você antes de matar com :kill. Isso pode atrasar rolling updates e graceful shutdowns se você não tomar cuidado.\nResumo prático:\nCenário Use Worker comum que despacha tasks paralelas Não ative trap_exit. Use Task.Supervisor.async_stream_nolink. GenServer que precisa fazer cleanup ordenado de recursos no shutdown Ative trap_exit e implemente terminate/2. Manager de processos (singleton, registry, pool) que precisa reagir a morte de filhos Ative trap_exit e trate todas as variantes de {:EXIT, _, _}. Vamos ver o catch-all em ação Volte ao projeto e crie o SingletonServer acima. Adicione na árvore de supervisão junto com um GenServer \u0026ldquo;irmão\u0026rdquo; que vai morrer de propósito:\ndefmodule NoisyChild do use GenServer def start_link(_), do: GenServer.start_link(__MODULE__, %{}) @impl true def init(_) do parent = Process.whereis(SingletonServer) Process.link(parent) # cria link manualmente com SingletonServer Process.send_after(self(), :die, 200) {:ok, %{}} end @impl true def handle_info(:die, state) do {:stop, :weird_reason, state} end end def start(_type, _args) do children = [ SingletonServer, NoisyChild ] opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor] Supervisor.start_link(children, opts) end Suba e veja o que acontece:\niex -S mix [SingletonServer] Initializing (PID #PID\u0026lt;0.156.0\u0026gt;) [SingletonServer] ⚠️ child #PID\u0026lt;0.157.0\u0026gt; morreu com :weird_reason [SingletonServer] ⚠️ child #PID\u0026lt;0.159.0\u0026gt; morreu com :weird_reason [SingletonServer] ⚠️ child #PID\u0026lt;0.161.0\u0026gt; morreu com :weird_reason [SingletonServer] ⚠️ child #PID\u0026lt;0.163.0\u0026gt; morreu com :weird_reason [error] GenServer #PID\u0026lt;0.157.0\u0026gt; terminating ** (stop) :weird_reason ... # E o supervisor encerra a app por causa do max_restarts do NoisyChild # MAS o SingletonServer nunca crashou O SingletonServer viu cada morte do NoisyChild, logou, e continuou rodando. Sem o catch-all e sem o clause de {:EXIT, _, _}, o SingletonServer teria caído junto na primeira morte do irmão, com um :function_clause. Foi exatamente esse pattern que o Highlander 0.2.1 falhou em proteger.\nA causa raiz que ninguém quer admitir: validação na origem Tudo que conversamos até aqui foi sobre conter o blast radius quando um erro acontece. Mas tem uma camada que vem antes: muitas vezes, o erro nunca deveria ter chegado a ser uma exception em primeiro lugar.\nEm um incidente real que motivou esse artigo, a sequência foi:\nAPI externa devolveu um payload de erro grande. O código fez \u0026quot;#{inspect(reason)}\u0026quot; para salvar na coluna error VARCHAR(255). A string passou de 255 chars → Postgrex.Error 22001 na hora do Repo.update/1. Como a Task estava linkada (vide acima), o exit cascateou. Note que os passos 2 e 3 são erros previsíveis e conhecidos. Tudo que precisaria existir era:\n# No changeset field |\u0026gt; cast(attrs, [:error, :failed_at]) |\u0026gt; validate_length(:error, max: 255) # OU truncar antes error_str = reason |\u0026gt; inspect() |\u0026gt; String.slice(0, 240) E o problema inteiro teria virado um {:error, changeset} que o caller trataria normalmente — sem exit, sem cascade, sem restart.\nA regra de ouro de processos supervisionados em Elixir é uma só:\n💎 Exceptions são para o inesperado. Erros conhecidos devem ser valores.\nTudo que você consegue antever — limites de tamanho de campos, formatos inválidos, indisponibilidade de APIs externas, timeouts — precisa virar {:error, reason} e ser tratado no fluxo normal. Exceptions e Process.exit/2 são reservados para o que você de fato não esperava.\nIsso conecta com o \u0026ldquo;Let it crash\u0026rdquo; do artigo anterior: a filosofia funciona porque você está tratando os erros conhecidos. Quando uma exception escapa, ela representa um estado realmente inesperado, e aí sim faz sentido deixar o supervisor restartar e tentar de novo. Se você deixa erros conhecidos virarem exceptions, o restart vira um retry sem ganho — o próximo request vai bater no mesmo bug, gerar a mesma exception, e a única coisa que muda é o contador de max_restarts subindo.\nDefesa em camadas — o checklist Toda vez que você for desenhar (ou revisar) um GenServer que faz trabalho externo, passe por essa checklist:\nCamada 1 — A operação em si Erros previsíveis (validações de schema, respostas de API, timeouts esperados) viram {:error, reason}, não exception? Inputs externos que vão para colunas de banco são truncados ou validados antes do Repo.update/insert? Chamadas para APIs externas têm timeout explícito e tratamento de {:error, ...} retornado? Camada 2 — Tasks e spawns Toda chamada de Task.async_stream/3 foi avaliada: ela deveria mesmo derrubar o caller em caso de falha? Workers que rodam em loop usam Task.Supervisor.async_stream_nolink/6 com on_timeout: :kill_task? O resultado do stream é consumido com pattern matching de {:ok, _} e {:exit, _}? Camada 3 — O próprio GenServer Existe handle_info(msg, state) catch-all como defesa contra mensagens desconhecidas? Se ativa trap_exit, tem clauses para {:EXIT, _, :normal}, {:EXIT, _, :shutdown} E {:EXIT, _, reason}? terminate/2 está implementado e não levanta exception (ele roda no caminho de morte e qualquer erro lá vira um log feio)? Camada 4 — O supervisor A restart policy do filho está correta (:permanent, :transient, :temporary)? O max_restarts está em um valor que faz sentido para a taxa esperada de falha (e não está mascarando um bug)? Os filhos estão agrupados em subárvores de supervisão pensando em blast radius — um worker barulhento não pode levar junto seu cache, seu pool de conexões e seu Phoenix? Camada 5 — Observabilidade Existe alerta para \u0026ldquo;taxa de exception por minuto\u0026rdquo;? Existe alerta para \u0026ldquo;restart count por pod\u0026rdquo;? O painel de métricas distingue restart in-place (BEAM saiu) de pod replacement (rolling update)? Resumo: o \u0026ldquo;Let it crash\u0026rdquo; maduro O \u0026ldquo;Let it crash\u0026rdquo; não é \u0026ldquo;deixe quebrar e o supervisor resolve\u0026rdquo;. É um contrato com várias partes:\nVocê tratou os erros conhecidos como valores no fluxo normal. Você isolou as fontes de falha imprevisível (Tasks, APIs externas, processos auxiliares) com nolink ou subárvores supervisadas. Você defendeu seus GenServers críticos com handle_info catch-all e clauses completas para {:EXIT, _, _} quando relevante. Você configurou o supervisor para um restart policy que combina com a natureza do trabalho. Você tem observabilidade para perceber quando algum desses pressupostos quebrou. Quando uma das camadas falha, é só uma camada. O blast radius fica contido. Quando todas falham — caso clássico de incidente em produção — você vê a versão dramática: um truncamento de string derrubando o BEAM inteiro através de cinco hops de propagação de exit.\nA boa notícia é que cada camada custa pouco quando você sabe que ela existe. A má notícia é que descobrir que faltava uma delas geralmente custa um post-mortem.\nReferências Task — async_stream/3 — comportamento de linkagem Task.Supervisor — async_stream_nolink/6 — variante sem link Process — flag/2 — trap_exit em detalhe Supervisor — restart values — :permanent vs :transient vs :temporary Erlang docs — Processes and Signals — o nível mais baixo de como links e exits funcionam GenServer the \u0026ldquo;rabbit hole\u0026rdquo; (parte 1) — parte 1 da série ","permalink":"https://daniel.ws/pt-br/posts/genserver-the-rabbit-hole-part-2-silent-cascade/","summary":"\u003cblockquote\u003e\n\u003cp\u003eContinuação de \u003ca href=\"https://daniel.ws/pt-br/posts/genserver-the-rabbit-hole/\"\u003eGenServer the \u0026ldquo;rabbit hole\u0026rdquo;\u003c/a\u003e. Se você ainda não leu, recomendo passar por lá antes — vamos partir do mesmo projeto \u003ccode\u003egenserver_study\u003c/code\u003e e dos mesmos conceitos de mailbox, supervisor e \u003ccode\u003emax_restarts\u003c/code\u003e.\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eNo artigo anterior nós exploramos como um GenServer \u0026ldquo;mal comportado\u0026rdquo; pode encerrar toda a árvore de supervisão quando estoura o \u003ccode\u003emax_restarts\u003c/code\u003e do supervisor. Mostramos como mensagens são perdidas no mailbox e como o \u0026ldquo;Let it crash\u0026rdquo; não é exatamente um cheque em branco para deixar processos quebrarem aleatoriamente.\u003c/p\u003e","title":"GenServer the \"rabbit hole\" — Parte 2: A cascata silenciosa"},{"content":"Toda vez que uma liderança técnica é questionada sobre performance do time, duas perguntas aparecem por baixo da conversa: \u0026ldquo;estamos entregando rápido?\u0026rdquo; e \u0026ldquo;estamos entregando bem?\u0026rdquo;. O problema é que, sem dado, a resposta vira sensação. E sensação varia conforme o humor de quem está respondendo, o último incidente que aconteceu na sexta-feira à noite, e a quantidade de café da reunião.\n📊 DORA Metrics \u0026ldquo;Um conjunto de quatro métricas que indicam o desempenho de uma equipe de desenvolvimento de software: frequência de implantação, tempo de entrega para mudanças, taxa de falha de mudanças e tempo para restaurar o serviço. Juntas, elas proporcionam um equilíbrio entre velocidade e estabilidade.\u0026rdquo;\n— Google Cloud / DORA\nO DORA (DevOps Research and Assessment) é um programa de pesquisa do Google Cloud que, há mais de uma década, vem estudando o que separa times de software que performam bem dos que não performam. O resultado mais conhecido desse trabalho está sintetizado no livro Accelerate e no State of DevOps Report anual. As DORA Metrics se tornaram, na prática, o padrão ouro pra medir entrega de software (DORA — Software delivery metrics).\nComo qualquer ferramenta que vira \u0026ldquo;padrão ouro\u0026rdquo;, elas também viraram alvo fácil de uso equivocado. É comum ver time adotando as métricas pelo motivo errado, configurando o dashboard errado, e tirando a conclusão errada. Esse artigo é uma tentativa de explicar o conceito, mostrar como adotar de forma saudável, e — talvez mais importante — mapear as armadilhas em que é fácil cair.\nAs 4 (na verdade 5) métricas O framework original do DORA tem 4 métricas, divididas em dois grupos: velocidade (throughput) e estabilidade. Em 2024, o DORA atualizou o conjunto para 5 métricas, trocando o nome de uma e adicionando outra (A history of DORA\u0026rsquo;s metrics). Vou focar nas 4 clássicas, que continuam sendo as mais usadas e referenciadas, e mencionar a evolução no fim da seção.\nVelocidade 1. Deployment Frequency — com que frequência o time sobe código pra produção. É a métrica mais visível e talvez a mais mal-interpretada (já chego nisso). Times elite fazem múltiplos deploys por dia; times low fazem um a cada poucos meses.\n2. Lead Time for Changes — quanto tempo leva entre um commit ser feito e essa mudança chegar em produção. Mede o \u0026ldquo;atrito\u0026rdquo; do pipeline de entrega: quanto demora um PR pra virar valor pro usuário.\nEstabilidade 3. Change Failure Rate — porcentagem de deploys que precisaram de intervenção urgente (rollback, hotfix, patch emergencial). É um proxy de qualidade do que está sendo entregue.\n4. Mean Time to Restore (MTTR) — quanto tempo o time leva pra restaurar o serviço depois de um incidente em produção. Mede capacidade de resposta a falha, não capacidade de evitar falha.\nCombinando os benchmarks publicados nos relatórios mais recentes (Octopus Deploy — DORA 2024/25, DORA report), os tiers de performance ficam mais ou menos assim:\nMétrica Elite High Medium Low Deployment Frequency Vários por dia 1/dia – 1/semana 1/semana – 1/mês \u0026lt; 1/mês Lead Time for Changes \u0026lt; 1 dia 1 – 7 dias 1 semana – 1 mês \u0026gt; 1 mês Change Failure Rate ~5% ~10% ~15% até 64% MTTR \u0026lt; 1 hora \u0026lt; 1 dia 1 dia – 1 semana \u0026gt; 1 semana Sobre a evolução para 5 métricas: o DORA renomeou MTTR para Failed Deployment Recovery Time (mais específico — mede só recuperação de deploy ruim, não qualquer incidente), e adicionou a Deployment Rework Rate (porcentagem de deploys não-planejados gerados por incidente em produção). A documentação oficial está em DORA\u0026rsquo;s software delivery performance metrics. Pra quem está começando, as 4 clássicas já cobrem 90% do valor — não precisa esperar adotar as 5 pra começar.\nUm ponto que o relatório DORA bate há anos e que muita gente ignora: velocidade e estabilidade não são trade-off. Os times elite vão bem nas quatro métricas simultaneamente. Quem ataca só deploy frequency e ignora change failure rate não vira time elite — vira time que produz incidente rápido.\nMétricas como meta (mas nem tanto 🫠) Esse é o ponto que precisa ser dito antes de qualquer dashboard ser criado: DORA Metrics não são metas. Elas são lagging indicators — termômetros que mostram como o sistema de entrega está performando ao longo do tempo. No momento em que viram OKR de time ou critério de avaliação individual, a coisa azeda muito rápido.\nExiste um princípio chamado Lei de Goodhart que resume bem o problema:\n\u0026ldquo;Quando uma medida se torna uma meta, ela deixa de ser uma boa medida.\u0026rdquo;\nNa prática, quando você diz pro time \u0026ldquo;precisamos chegar em 5 deploys por dia até o fim do trimestre\u0026rdquo;, o que acontece? O time encontra uma forma. E quase sempre essa forma envolve algum tipo de \u0026ldquo;trapaça\u0026rdquo; não-intencional. Vamos olhar alguns cenários reais que o pessoal do InfoQ documentou:\nInflar Deployment Frequency: o time quebra um PR único em 5 PRs artificiais, ou faz \u0026ldquo;deploys de no-op\u0026rdquo; só pra subir o número. Resultado: a métrica sobe, o valor entregue não muda, e o overhead de revisão e deploy cresce. Reduzir Lead Time pulando review: pra commit virar prod mais rápido, alguém afrouxa o review, ou pula testes em PRs \u0026ldquo;pequenos\u0026rdquo;. Lead Time melhora, Change Failure Rate piora — e como ninguém olha as duas juntas, parece que melhorou. Mascarar MTTR com rollback agressivo: rollback é rápido, então toda vez que algo dá ruim em prod, o time desfaz. MTTR fica ótimo. Só que rollback significa que o valor da release foi tirado do usuário — você não está mais entregando, está só não-quebrando. Diluir Change Failure Rate com deploys triviais: se você sobe 100 deploys de mudança de copy e 5 deploys que quebraram, sua taxa de falha despenca. A métrica mente, e o time acredita. Então qual é o ponto de medir, se mexer na métrica deixa ela mentirosa? O ponto é usar as métricas como gatilho de conversa, não como nota da prova. Quando o Lead Time sobe três sprints seguidas, isso é informação. Não é castigo nem crachá: é sinal pra abrir o capô e investigar. Pode ser CI lento, pode ser fila de review, pode ser PR muito grande, pode ser ambiente de staging instável. As métricas não te dizem o que fazer, elas te dizem onde olhar.\nMétrica de time vs. métrica individual Esse é um corolário tão importante que merece subseção própria: DORA Metrics são métricas de sistema, não de pessoa. A própria documentação oficial é explícita sobre isso (DORA — DORA metrics): elas devem ser aplicadas no nível de aplicação ou serviço, não comparadas entre times diferentes, e jamais usadas pra avaliar engenheiros individualmente.\nO motivo é direto: se eu, como dev, sei que minha promoção depende do meu Lead Time, eu vou otimizar pro meu Lead Time. Vou evitar PRs grandes mesmo quando faz sentido, vou evitar pegar bug difícil porque ele leva mais tempo, vou criar atrito político em revisões alheias pra acelerar as minhas. Métrica de time virando incentivo individual destrói o time e mente sobre a performance.\nPra avaliação individual de engenheiro, existe outro framework chamado SPACE (Satisfaction, Performance, Activity, Communication, Efficiency), que é explicitamente pensado pra capturar dimensões individuais e de bem-estar. Os dois são complementares. DORA mede o sistema; SPACE mede as pessoas e a colaboração. Não confunda.\nComo aplicar no time Beleza, então como adotar isso de forma saudável? A receita não é complicada, mas tem alguns pré-requisitos.\nPré-requisitos Existem várias plataformas que se propõem a calcular DORA Metrics em cima do seu fluxo de desenvolvimento — Swarmia, LinearB, Sleuth, entre outras. Independente da escolha de tooling, três coisas precisam estar no lugar antes:\nIntegrações conectadas: o ponto central costuma ser o app da plataforma instalado no GitHub/GitLab — ele puxa PRs, commits, reviews e eventos de deploy. Pra contexto de projeto/iniciativa, vale plugar também o issue tracker (Linear, Jira, etc). E pra fechar o loop de notificações com o time, conecta o Slack. Definições explícitas e acordadas: o que conta como \u0026ldquo;deploy\u0026rdquo;? Subir pra staging conta ou só prod? Toggle de feature flag conta? O que é \u0026ldquo;falha\u0026rdquo;? Bug reportado por usuário ou só incidente que paginou? Essas perguntas precisam ser respondidas antes de começar a medir, e por escrito. O Bryan Finster tem um whitepaper bom sobre essas ambiguidades. Na maioria das ferramentas, parte dessa decisão vira configuração concreta: você escolhe entre GitHub Deployments, GitHub Checks ou alguma Deployment API pra dizer à ferramenta o que ela deve contar como deploy de produção. Buy-in da liderança de que é métrica de sistema: se o head de eng vai usar isso pra ranking de time ou pra justificar PIP, melhor não começar. O dano de adotar mal é maior do que o benefício de não adotar. Comece pequeno Você não precisa medir as 4 métricas no dia 1. Conectar a plataforma no GitHub e ligar deployments já te dá Deployment Frequency e Lead Time for Changes funcionando praticamente sem trabalho — saem direto do histórico de PRs mergeados na main e dos eventos de deploy. Comece por elas, que juntas já desenham uma foto razoável da saúde do pipeline.\nChange Failure Rate vem em seguida, mas depende de um sinal explícito: a maioria das ferramentas detecta automaticamente quando um deploy é seguido de revert, rollback ou hotfix, e marca o deploy original como falha. Pra essa detecção ser confiável, o time precisa adotar uma convenção pra esses deploys — mensagem de commit padronizada, label de PR, ou tag específica. Sem convenção, a métrica vai subestimar a realidade silenciosamente.\nMTTR é onde algumas ferramentas têm uma limitação que vale conhecer: elas costumam calcular recovery a partir do tempo entre o deploy quebrado e o deploy de fix. Isso funciona bem pra falhas que são visíveis no pipeline, mas não captura incidentes detectados externamente (alerta de produção, bug reportado por usuário) — esse dado de incident management acaba ficando de fora da conta. Pra grande maioria dos times a aproximação por deploy basta; só vale ter clareza de que essa é a definição.\nSobre cadência de revisão: semanal ou por sprint é suficiente. Diário vira ruído (a variância natural do dia-a-dia engole o sinal). Mensal funciona pra time mais maduro com volume baixo de deploy.\nUm recurso comum que casa bem com essa cadência são as Working Agreements: você define alvos coletivos (ex.: \u0026ldquo;PRs abertos por menos de 24h\u0026rdquo;, \u0026ldquo;Lead Time abaixo de X dias\u0026rdquo;) e a ferramenta notifica no Slack quando o time se afasta. Útil pra automatizar parte do feedback sem virar microgerenciamento — o alerta vai pro time, não pro indivíduo.\nUm caso prático Vou pegar um exemplo real documentado pelo pessoal da BossaBox, que ilustra bem o ciclo virtuoso:\nAntes Depois Deployment Frequency 1 a cada 15 dias \u0026gt; 1 por dia Change Failure Rate ~100% reduzida significativamente MTTR ~90 horas ~90 minutos O time tinha deploy a cada 15 dias e quase todo deploy vinha seguido de hotfix (daí o failure rate beirando 100%). Recuperação levava em média 90 horas. Quando esses dados foram levantados e apresentados de forma quantitativa, abriu espaço pra discussão estrutural — o problema não era \u0026ldquo;esforço\u0026rdquo; do time, era arquitetura, processo de deploy, e cobertura de testes. Depois das mudanças, deploy diário com MTTR de 90 minutos.\nRepare no padrão: a métrica não consertou nada. Ela só tornou visível um problema que todo mundo sentia mas ninguém conseguia dimensionar. O conserto foi nas práticas (CI/CD, testes, arquitetura) e na cultura (deploy não é mais evento de risco). A ferramenta é o que torna esse \u0026ldquo;tornar visível\u0026rdquo; rápido — mas não é mágica.\nConversas de melhoria Quando o time olha as métricas em ritual recorrente (uma boa pedida é encaixar na retro de sprint), as perguntas certas a fazer são abertas:\n\u0026ldquo;Nosso Lead Time subiu 30% no último mês. O que mudou no nosso fluxo?\u0026rdquo; \u0026ldquo;Esse spike de Change Failure veio de qual área? É repetição ou foi isolado?\u0026rdquo; \u0026ldquo;Estamos com Deploy Frequency caindo. É decisão consciente ou está aparecendo gargalo?\u0026rdquo; A ferramenta ajuda nesse momento porque permite filtrar por repositório, time, autor ou janela de tempo — dá pra ver rápido se o sintoma está concentrado em um serviço ou se é sistêmico, e dá pra cruzar com dados do issue tracker pra entender se foi um tipo específico de mudança. Mas o ferramental não substitui a discussão. Note que nenhuma das perguntas acima é \u0026ldquo;quem foi o culpado?\u0026rdquo;. Métrica que culpabiliza vira métrica manipulada — e isso vale igual com ou sem dashboard bonito.\nResultado esperado O que esperar de adotar DORA Metrics direito? Em horizontes diferentes:\nCurto prazo (semanas): visibilidade. Você passa a ver onde o tempo se perde, qual etapa do pipeline é a mais lenta, e quanto o time gasta apagando incêndio vs. entregando feature. Médio prazo (meses): o time começa a usar as métricas como ferramenta de melhoria. Lead Time cai porque alguém percebe que o ambiente de CI estava lento. Change Failure Rate cai porque alguém percebe que faltava cobertura em uma área específica. Deploy Frequency sobe porque o medo de subir código diminuiu. Longo prazo (anos): o relatório DORA documenta correlação consistente entre alto desempenho nessas métricas e métricas de negócio (lucratividade, market share) e de pessoas (retenção, menor burnout). Não é mágica — é que time que entrega rápido e estável trabalha em cima de fundamentos saudáveis (CI, testes, trunk-based development, observabilidade), e esses fundamentos são bons em si. Mas é honesto fechar com o disclaimer: as métricas não dizem como melhorar. Elas só apontam onde olhar. O trabalho de fato — que envolve trunk-based development, continuous delivery, feature flags, testes automatizados, cultura de blameless postmortem, observabilidade — é o que move os números. O dashboard não, ele só conta a história.\nO recado final é o mesmo do começo: dashboard não conserta time. Conversa baseada em dado conserta. As DORA Metrics são uma ótima desculpa pra começar essa conversa de forma estruturada — desde que ninguém transforme a métrica em meta no caminho.\n","permalink":"https://daniel.ws/pt-br/posts/dora-metrics/","summary":"\u003cp\u003eToda vez que uma liderança técnica é questionada sobre performance do time, duas perguntas aparecem por baixo da conversa: \u0026ldquo;estamos entregando rápido?\u0026rdquo; e \u0026ldquo;estamos entregando bem?\u0026rdquo;. O problema é que, sem dado, a resposta vira sensação. E sensação varia conforme o humor de quem está respondendo, o último incidente que aconteceu na sexta-feira à noite, e a quantidade de café da reunião.\u003c/p\u003e\n\u003cblockquote\u003e\n\u003ch3 id=\"-dora-metrics\"\u003e📊 \u003cstrong\u003eDORA Metrics\u003c/strong\u003e\u003c/h3\u003e\n\u003cp\u003e\u003cem\u003e\u0026ldquo;Um conjunto de quatro métricas que indicam o desempenho de uma equipe de desenvolvimento de software: frequência de implantação, tempo de entrega para mudanças, taxa de falha de mudanças e tempo para restaurar o serviço. Juntas, elas proporcionam um equilíbrio entre velocidade e estabilidade.\u0026rdquo;\u003c/em\u003e\u003c/p\u003e","title":"DORA Metrics"},{"content":"Quando exploramos os pontos fortes do Elixir como linguagem de programação, dois se destacam em frente a outras linguagens do mercado:\n🚀 3. Concurrency and Scalability Uses actors (processes) for concurrency (each with its own memory and message queue). Millions of processes can run concurrently with low overhead. Built-in tools for distribution across multiple nodes. 🛠 4. Fault-Tolerance and Supervision Trees \u0026ldquo;Let it crash\u0026rdquo; philosophy: Failures are expected and isolated. Supervision trees: Automatically restart failing processes, making systems self-healing and resilient. GenServer é uma abstração sobre o modelo de processos da VM do Erlang que é entregue pelo core da linguagem e se conecta diretamente aos pontos de Concorrência e Árvore de Supervisão do Elixir. Por ser um pilar comum na arquitetura de sistemas construídos em Elixir, e por ser abordado \u0026ldquo;prematuramente\u0026rdquo; nas documentações oficiais (Client server communication, OTP Concurrency), é comum que programadores recém-chegados comecem a modelar suas primeiras soluções em Elixir com o uso da abstração fornecida pelo módulo GenServer. Demora um pouco até a doc oficial fazer o primeiro disclaimer para os novos desavisados: When (not) to use a GenServer.\nApesar do aviso sobre não usar GenServers para organização do código, um outro aviso que eu considero fundamental e não temos na doc oficial (ou pelo menos eu não cheguei nessa parte da doc ainda 😅) é evitar o uso do GenServer quando ele não for estritamente necessário (e/ou até que o desenvolvedor conheça todas as especificidades que envolvem o modelo de processos do Erlang).\nApesar de a primeira vista a API do GenServer parecer bem simples (pelo menos para alguém já acostumado à syntax da linguagem), todo o ciclo de vida de um processo em Elixir tem características que não são intuitivas e muitas vezes desconhecidas. Isso faz com que códigos que foram além dos contadores simples propostos na doc comecem a gerar erros e comportamentos imprevistos em produção. Problemas esses que são difíceis de debugar e entender, principalmente para quem ainda é novo no mundo de Elixir.\nEu queria aproveitar para trazer alguns pontos importantes de serem levados em consideração na hora de modelar sua solução com o uso dessa abstração.\nLet it crash (mas nem tanto 🫠) Esse mantra é repetido aos quatro ventos quando estamos falando das capacidades da linguagem. Ela parte do princípio que todos os processos em Elixir são isolados, erros que acontecem em um processo não interferem na execução dos demais processos, e caso esse processo que deu erro seja importante, o Supervisor vai cuidar de reiniciá-lo e tudo ficará bem. Será mesmo?\nDe fato os processos são isolados, e um erro que ocorra em um não afetará os demais (a menos que explicitamente o desenvolvedor o faça). Outro fato é que sim, o Supervisor vai restartar processos que estão na sua árvore de supervisão caso eles crashem. O que nem todos sabem entretanto é que existe um threshold de máximo de tentativas em um intervalo de tempo (Supervisor strategies and options). Na prática, a configuração padrão de um supervisor determina que caso um processo filho dê :exit mais de 3 vezes dentro de 5 segundos, o supervisor todo vai ser encerrado e assim encerrando junto toda a árvore de supervisão. Na prática isso significa que um GenServer em \u0026ldquo;mal estado\u0026rdquo; debaixo do mesmo servidor do Phoenix e do Ecto pode encerrar toda sua aplicação. Bom, parece que um processo problemático pode sim afetar outros processos afinal 🙃.\nVamos testar como isso funciona na prática (eu recomendo uma leitura inicial da doc do GenServer porque não vamos explicar os detalhes da API). Crie um novo projeto na sua máquina, usando a flag --sup para gerar uma app Elixir com uma árvore de supervisão já configurada:\nmix new genserver_study --sup Crie um módulo novo para representar um GenServer qualquer da sua aplicação:\ndefmodule SafeServer do use GenServer @prefix \u0026#34;[SafeServer]\u0026#34; def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__) @impl true def init(_) do IO.puts(\u0026#34;#{@prefix} Initializing GenServer\u0026#34;) {:ok, %{}, {:continue, :init}} end @impl true def handle_continue(:init, state) do IO.puts(\u0026#34;#{@prefix} Initialized with #{inspect(self())}\u0026#34;) {:noreply, state} end end Esse GenServer não processa nada, apenas loga sua inicialização com o seu PID.\nAgora vamos adicionar ele à árvore de supervisão da nossa app. Abra o arquivo lib/genserver_study/application.ex e inclua o SafeServer na lista de filhos do supervisor:\ndef start(_type, _args) do children = [ SafeServer # Adicione o módulo do SafeServer à lista de processos filhos ] opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor] Supervisor.start_link(children, opts) end Agora vamos iniciar o iex carregando nossa aplicação junto, com o comando iex -S mix:\niex -S mix Erlang/OTP 28 [erts-16.0] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit:ns] [SafeServer] Initializing GenServer Interactive Elixir (1.18.4) - press Ctrl+C to exit (type h() ENTER for help) [SafeServer] Initialized with #PID\u0026lt;0.142.0\u0026gt; Podemos ver pelo log que nosso SafeServer iniciou corretamente.\nAgora vamos criar um GenServer problemático e ver como ele afeta nosso supervisor e o SafeServer.\ndefmodule BrokenServer do use GenServer @prefix \u0026#34;[BrokenServer]\u0026#34; def start_link(behaviour), do: GenServer.start_link(__MODULE__, behaviour, name: __MODULE__) @impl true def init(behaviour) do IO.puts(\u0026#34;#{@prefix} Initializing GenServer for #{behaviour}\u0026#34;) {:ok, %{}, {:continue, {:init, behaviour}}} end @impl true def handle_continue({:init, behaviour}, state) do IO.puts(\u0026#34;#{@prefix} Initialized with #{inspect(self())}\u0026#34;) Process.send_after(self(), behaviour, 100) {:noreply, state} end @impl true def handle_info(:break, _state), do: raise(\u0026#34;Unhandled error\u0026#34;) def handle_info(:stop, state) do {:stop, :finish, state} end end Uma breve explicação sobre o funcionamento do BrokenServer: a função init/1 retorna uma tupla com a opção {:continue, {:init, behaviour}}. O callback handle_continue/2 vai agendar o envio de uma mensagem para o próprio processo em 100ms. Nós temos dois handle_info/2: um para o comportamento de stop e outro para gerar uma exception. Vamos falar sobre a diferença de ambos mais tarde, mas o efeito colateral vai ser um :exit do processo do GenServer para os dois casos.\nAgora vamos adicionar o BrokenServer à lista de filhos do supervisor, mas adicionando uma opção para a inicialização do server:\ndef start(_type, _args) do children = [ SafeServer, {BrokenServer, :break} # Estamos iniciando o GenServer com a opção :break ] opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor] Supervisor.start_link(children, opts) end Agora vamos iniciar nossa aplicação com o iex (eu limpei alguns logs para facilitar a leitura):\niex -S mix [SafeServer] Initializing GenServer [BrokenServer] Initializing GenServer for break [SafeServer] Initialized with #PID\u0026lt;0.156.0\u0026gt; [BrokenServer] Initialized with #PID\u0026lt;0.157.0\u0026gt; 15:40:22.511 [error] GenServer BrokenServer terminating ** (RuntimeError) Unhandled error (genserver_study 0.1.0) lib/genserver_study/broken_server.ex:22: BrokenServer.handle_info/2 ... Last message: :break State: %{} [BrokenServer] Initializing GenServer for break [BrokenServer] Initialized with #PID\u0026lt;0.161.0\u0026gt; 15:40:22.614 [error] GenServer BrokenServer terminating ** (RuntimeError) Unhandled error ... 15:40:22.617 [notice] Application genserver_study exited: shutdown Rodando na sua máquina você verá quatro vezes a exception gerada pelo nosso BrokenServer e ao final a mensagem Application genserver_study exited: shutdown.\nSe você leu a seção Supervisor strategies and options, provavelmente está pensando: \u0026ldquo;Bom, a solução no caso é subir o threshold do Supervisor para o \u0026lsquo;infinito\u0026rsquo; e impedir que ele mate toda a aplicação\u0026rdquo;. Minha resposta para você é: depende. Esse comportamento do supervisor existe para evitar que ao entrar em um estado de \u0026ldquo;não retorno\u0026rdquo; sua aplicação fique em um looping infinito de crash e restart do processo. O racional é que ao encerrar toda a app um processo externo (lifecycle do k8s, por exemplo) refaça todo o ambiente e isso eventualmente reestabeleça a saúde da sua aplicação. Obviamente você pode escolher valores mais permissivos do que os default, mas precisa ter muito cuidado para não \u0026ldquo;maquiar\u0026rdquo; um problema real. Afinal, se o processo não está se mantendo vivo, na prática sua aplicação não está funcionando, então qual seria o benefício de manter uma app zumbi?\nMas então como fica o \u0026ldquo;Let it crash\u0026rdquo;? Eu pessoalmente acho a escolha do slogan um equívoco. Ele induz a acreditar que deixar o código quebrar by design é a forma correta fazer as coisas em Elixir, e na prática não é bem assim. A ideia correta da tolerância a falhas no Elixir é entender que erros inesperados não vão inicialmente derrubar toda a sua aplicação. Existe uma camada de segurança na linguagem que isola os processos e vai tentar se recuperar de eventuais anomalias ocasionais ou temporárias. Sendo assim, é sempre importante tratar todos os casos de erro conhecidos dentro da execução de um GenServer (e qualquer processo supervisionado), e deixar que sejam geradas exceptions apenas para casos não previstos e inesperados.\nException vs Gracefully stop Você pode reparar que no exemplo de código do BrokenServer nós temos um fluxo de handle_info que retorna uma tupla de stop ao invés de gerar uma exception:\ndef handle_info(:stop, state) do {:stop, :finish, state} # Gracefully stop end Essa opção indica que o processo do GenServer deve ser encerrado. Essa opção normalmente é utilizada quando temos um fluxo onde não é mais necessário manter o processo do GenServer rodando, muitas vezes porque ele não será mais utilizado. Mas como o Supervisor se comporta quando o GenServer envia um sinal de exit de forma \u0026ldquo;controlada\u0026rdquo;? Vamos alterar a configuração na nossa árvore de supervisão para ativar o fluxo com o stop:\ndef start(_type, _args) do children = [ SafeServer, {BrokenServer, :stop} # Alteramos a opção :break para :stop ] opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor] Supervisor.start_link(children, opts) end Então temos os logs (algumas linhas foram omitidas para melhorar a leitura):\niex -S mix [SafeServer] Initializing GenServer [BrokenServer] Initializing GenServer for stop [BrokenServer] Initialized with #PID\u0026lt;0.157.0\u0026gt; [SafeServer] Initialized with #PID\u0026lt;0.156.0\u0026gt; 10:12:32.292 [error] GenServer BrokenServer terminating ** (stop) :finish Last message: :stop State: %{} [BrokenServer] Initializing GenServer for stop [BrokenServer] Initialized with #PID\u0026lt;0.159.0\u0026gt; ... 10:12:32.604 [notice] Application genserver_study exited: shutdown Você vai perceber que novamente após reiniciar o processo 4 vezes o supervisor encerra com a mensagem Application genserver_study exited: shutdown.\nIsso acontece porque a configuração padrão do Supervisor é permanentemente tentar restartar um processo quando ele encerra, independente dos motivos. Esse comportamento padrão pode ser alterado para um GenServer específico nas opções do use GenServer, restart: :transient (How to supervise), ou nas configurações \u0026ldquo;globais\u0026rdquo; do supervisor: Restart values (:restart).\nÉ importante entretanto ter em mente que caso o supervisor esteja configurado para restartar os processos de forma transient, caso um GenServer encerre a execução com um :stop e seja necessário reiniciar o mesmo, o processo precisa ser feito manualmente via start_child/2.\nFila de mensagens — mailbox Outro aspecto muito importante dos processos em Elixir e portanto também uma característica dos GenServers é que todas as mensagens (send/2, GenServer.call/2, GenServer.cast/2) são sempre enfileiradas na message queue (mailbox) de cada processo e podem ser lidas uma por vez. A existência da mailbox é citada brevemente na seção de envio de mensagens da doc do Elixir: Sending and receiving messages, mas é explicada de forma mais detalhada na documentação oficial do Erlang: Signals — Adding Messages to the Message Queue.\nEntender esse aspecto do funcionamento dos processos é importante porque normalmente ele se torna o motivo de duas falhas muito comuns em arquiteturas em Elixir: bottleneck e perda de mensagens. Imagine que você criou um GenServer responsável por controlar o rate limit de uso da API: cada request recebida o controller envia uma mensagem para esse GenServer com o IP de origem, o GenServer precisa salvar a data e hora que esse IP tentou acessar o endpoint, e buscar todas as últimas tentativas para calcular se o limite foi excedido. Agora imagine que sua API recebe milhões de requisições por segundo, todas elas enviam mensagens para esse GenServer que precisa salvar e calcular os limites para responder para o controller se deve aceitar ou não a request. Perceba que esse processo do GenServer virou um gargalo para toda a API — todas as requests ficam aguardando a resposta desse processo centralizado que é capaz de responder apenas uma mensagem por vez. E o mais preocupante: o que acontece com a mailbox caso esse GenServer dê um exit por conta de uma exception? Vamos testar.\nCrie um novo módulo de GenServer com o seguinte código:\ndefmodule MsgServer do use GenServer @prefix \u0026#34;[MsgServer]\u0026#34; def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__) @impl true def init(_) do IO.puts(\u0026#34;#{@prefix} Initializing GenServer\u0026#34;) {:ok, %{count: 0}} end def message(id), do: GenServer.call(__MODULE__, {:msg, id}) def stop, do: GenServer.call(__MODULE__, :stop) def break, do: GenServer.call(__MODULE__, :break) @impl true def handle_call({:msg, id}, _from, %{count: count}) do # Fake heavy computation :timer.sleep(200) IO.puts(\u0026#34;#{@prefix} Finish process msg #{id}\u0026#34;) {:reply, {:ok, id}, %{count: count + 1}} end @impl true def handle_call(:stop, _from, state) do IO.puts(\u0026#34;#{@prefix} Sending stop message\u0026#34;) send(self(), :restart_connection) {:reply, {:ok, :stoping}, state} end @impl true def handle_call(:break, _from, state) do raise(\u0026#34;Unhandled Error\u0026#34;) {:noreply, state} end @impl true def handle_info(:restart_connection, state) do IO.puts(\u0026#34;#{@prefix} Handling stop message\u0026#34;) {:stop, :publish_error, state} end @impl true def terminate(reason, state) do IO.puts(\u0026#34;#{@prefix} Terminating reason: #{inspect(reason)}, state: #{inspect(state)}\u0026#34;) :normal end end Esse GenServer tem uma API com apenas 3 funções: uma para simular um processamento \u0026ldquo;pesado\u0026rdquo; de uma mensagem, uma para simular um erro com exception e outra para simular um exit com stop do GenServer.\nVamos alterar nossa árvore de supervisão para iniciar esse novo GenServer:\ndef start(_type, _args) do children = [ MsgServer # Removemos os outros GenServers e adicionamos o MsgServer ] opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor] Supervisor.start_link(children, opts) end Vamos criar um módulo para simular o envio de mensagens para nosso GenServer:\ndefmodule MsgTest do def stop do Enum.each(0..15, fn index -\u0026gt; if index == 3 do spawn(fn -\u0026gt; MsgServer.stop() |\u0026gt; IO.inspect(label: \u0026#34;stop\u0026#34;) end) else spawn(fn -\u0026gt; message(index) end) end :timer.sleep(50) end) end def break do Enum.each(0..15, fn index -\u0026gt; if index == 3 do spawn(fn -\u0026gt; MsgServer.break() |\u0026gt; IO.inspect(label: \u0026#34;break\u0026#34;) end) else spawn(fn -\u0026gt; message(index) end) end :timer.sleep(50) end) end defp message(id) do IO.puts(\u0026#34;[Teste] Sending message #{id}\u0026#34;) case MsgServer.message(id) do {:ok, _count} -\u0026gt; IO.puts(\u0026#34;[Teste] Result for message #{id} is ok\u0026#34;) error -\u0026gt; IO.puts(\u0026#34;[Teste] Result for message #{id} is #{inspect(error)}\u0026#34;) end IO.puts(\u0026#34;[Teste] Gracefully ending message #{id}\u0026#34;) catch :exit, error -\u0026gt; IO.puts(\u0026#34;[Teste] Catch error #{inspect(error)}\u0026#34;) end end Esse módulo de teste implementa duas funções públicas: uma para simular um cenário onde o GenServer encerra com uma exception (break) e outro para o GenServer encerrar com um stop/exit. O mecanismo do teste é subir 16 processos com o spawn; desses processos, o quarto vai enviar uma mensagem de break ou stop, para simular uma quebra/encerramento do GenServer. A ideia é acompanhar pelos logs o que acontece com as mensagens que estavam na mailbox do GenServer quando ele encerrar.\nVamos subir a aplicação e rodar MsgTest.break para simular a interrupção via exception:\niex(1)\u0026gt; MsgTest.break() [Teste] Sending message 0 [Teste] Sending message 1 [Teste] Sending message 2 [MsgServer] Finish process msg 0 [Teste] Result for message 0 is ok [Teste] Gracefully ending message 0 [Teste] Sending message 4 [Teste] Sending message 5 [Teste] Sending message 6 ... [MsgServer] Finish process msg 2 [Teste] Result for message 2 is ok [Teste] Gracefully ending message 2 [Teste] Sending message 12 12:18:43.864 [error] GenServer MsgServer terminating ** (RuntimeError) Unhandled Error ... [MsgServer] Initializing GenServer [Teste] Catch error { {%RuntimeError{message: \u0026#34;Unhandled Error\u0026#34;}, ...}, {GenServer, :call, [MsgServer, {:msg, 6}, 5000]}} [Teste] Catch error { {%RuntimeError{message: \u0026#34;Unhandled Error\u0026#34;}, ...}, {GenServer, :call, [MsgServer, {:msg, 4}, 5000]}} ... (mais 7 mensagens semelhantes) [Teste] Sending message 13 [Teste] Sending message 14 [Teste] Sending message 15 [MsgServer] Finish process msg 13 [Teste] Result for message 13 is ok [Teste] Gracefully ending message 13 [MsgServer] Finish process msg 14 [Teste] Result for message 14 is ok [Teste] Gracefully ending message 14 [MsgServer] Finish process msg 15 [Teste] Result for message 15 is ok [Teste] Gracefully ending message 15 Nós podemos perceber pelos logs que, até acontecer a exception, foram enviadas as mensagens até o índice 12. Dessas mensagens, foram processadas as mensagens 0, 1 e 2: [Teste] Gracefully ending message 2. Todas as demais mensagens que estavam no mailbox foram logadas pelo catch (vamos falar melhor sobre esse ponto). Os processos das mensagens 13, 14, 15 que não foram enviadas inicialmente ficaram aguardando o reinicio do GenServer (comportamento padrão quando usamos GenServer.call/2) e assim que ele reiniciou elas foram enviadas e processadas corretamente. Em resumo, por conta da exception gerada durante a execução do GenServer, 9 mensagens que estavam aguardando execução na mailbox foram perdidas, mesmo com a recuperação posterior do GenServer pelo Supervisor.\nO que acontece com as mensagens que foram \u0026ldquo;descartadas\u0026rdquo; no encerramento do GenServer? Vamos imaginar o exemplo inicial, de um GenServer que controla o rate limit de uma API. Cada request feita para essa API é tratada em um processo isolado, e cada processo vai fazer um GenServer.call/2, então imagine que o controller da nossa API tem o seguinte código:\ndef show(conn, params) do with :ok \u0026lt;- GenServer.call(RateLimit, {:get, conn}) do process_show(params) end end Nesse código, quando o processo que está executando a função show/2 chamar a linha com o GenServer.call/2, ele vai enviar essa mensagem para o GenServer RateLimit e vai ficar aguardando por uma mensagem de resposta. Além de aguardar a resposta, ele vai ficar observando por sinais de exit do processo do GenServer. Quando a exception acontecer, o sinal de exit vai ser propagado para todos os processos que estão aguardando por uma mensagem. Como nesse código não tem um catch para tratar o sinal de exit, o processo do controller também vai dar um exit sem continuar a execução da próxima linha que seria o process_show(params). Esse exit seria capturado pelo Phoenix, e nós teríamos um erro 500 da API.\nO catch utilizado no módulo de teste MsgTest é uma forma de capturar esse sinal de exit e controlar o fluxo de execução. No caso do nosso exemplo, nós não terminamos o processo que chamou o GenServer.call/2, nós apenas logamos a informação e seguimos com a execução normal.\nVocê pode testar agora o que acontece chamando o fluxo de exit com o stop: MsgTest.stop(). Você vai notar que o comportamento é o mesmo. Mesmo sendo um encerramento controlado, todas as mensagens na mailbox são descartadas e os processos que chamaram o GenServer recebem o sinal de exit.\nMitigando bottlenecks e perda de mensagens com Poolboy Existem algumas estratégias que podem ser utilizadas para mitigar os problemas que mostramos. Uma delas é isolar a execução do GenServer do gerenciamento da fila e paralelizar a execução das mensagens em mais de um processo. Isso nem sempre é simples — no exemplo do rate limit seria um pouco mais complexo pelo fato de que você pode ter várias requests em paralelo do mesmo IP, seria necessária uma camada adicional de \u0026ldquo;roteamento\u0026rdquo; das mensagens para garantir a consistência dos dados. Mas para a maioria dos problemas mais simples, um aliado para ajustar essa arquitetura é a lib Poolboy, que te permite controlar essa fila e paralelizar a execução utilizando uma estratégia de pool de workers (muito similar ao que o Ecto faz com as conexões do banco). Utilizando o Poolboy, ele vai gerenciar a fila e vai enviar apenas uma mensagem para cada worker; dessa forma, quando um worker morrer, os demais podem continuar atendendo as demandas e nenhuma mensagem \u0026ldquo;extra\u0026rdquo; será perdida.\n","permalink":"https://daniel.ws/pt-br/posts/genserver-the-rabbit-hole/","summary":"\u003cp\u003eQuando exploramos os pontos fortes do Elixir como linguagem de programação, dois se destacam em frente a outras linguagens do mercado:\u003c/p\u003e\n\u003cblockquote\u003e\n\u003ch3 id=\"-3-concurrency-and-scalability\"\u003e🚀 3. \u003cstrong\u003eConcurrency and Scalability\u003c/strong\u003e\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eUses \u003cstrong\u003eactors (processes)\u003c/strong\u003e for concurrency (each with its own memory and message queue).\u003c/li\u003e\n\u003cli\u003eMillions of processes can run concurrently with \u003cstrong\u003elow overhead\u003c/strong\u003e.\u003c/li\u003e\n\u003cli\u003eBuilt-in tools for \u003cstrong\u003edistribution across multiple nodes\u003c/strong\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch3 id=\"-4-fault-tolerance-and-supervision-trees\"\u003e🛠 4. \u003cstrong\u003eFault-Tolerance and Supervision Trees\u003c/strong\u003e\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u0026ldquo;Let it crash\u0026rdquo; philosophy\u003c/strong\u003e: Failures are expected and isolated.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSupervision trees\u003c/strong\u003e: Automatically restart failing processes, making systems \u003cstrong\u003eself-healing\u003c/strong\u003e and resilient.\u003c/li\u003e\n\u003c/ul\u003e\u003c/blockquote\u003e\n\u003cp\u003eGenServer é uma abstração sobre o modelo de processos da VM do Erlang que é entregue pelo core da linguagem e se conecta diretamente aos pontos de \u003cstrong\u003eConcorrência e Árvore de Supervisão\u003c/strong\u003e do Elixir. Por ser um pilar comum na arquitetura de sistemas construídos em Elixir, e por ser abordado \u0026ldquo;prematuramente\u0026rdquo; nas documentações oficiais (\u003ca href=\"https://hexdocs.pm/elixir/1.18.4/genservers.html\"\u003eClient server communication\u003c/a\u003e, \u003ca href=\"https://elixirschool.com/en/lessons/advanced/otp_concurrency\"\u003eOTP Concurrency\u003c/a\u003e), é comum que programadores recém-chegados comecem a modelar suas primeiras soluções em Elixir com o uso da abstração fornecida pelo módulo GenServer. Demora um pouco até a doc oficial fazer o primeiro disclaimer para os novos desavisados: \u003ca href=\"https://hexdocs.pm/elixir/1.18.4/GenServer.html#module-when-not-to-use-a-genserver\"\u003eWhen (not) to use a GenServer\u003c/a\u003e.\u003c/p\u003e","title":"GenServer — the \"rabbit hole\""},{"content":"Este é um ritual milenar na arte de aprender novas tecnologias. Devs ao redor do mundo conhecem a verdade: se você ousar escrever suas primeiras linhas de código sem um Hello World, está convidando uma maldição para sua jornada.\nEspere bugs misteriosos infestando seu código, builds falhando sem motivo, e erros enigmáticos aparecendo na linha 23 — mesmo que essa linha não exista.\nEntão, para honrar os deuses da programação, estou começando minha primeira experiência com Hugo seguindo a tradição sagrada: um post Hello World.\n","permalink":"https://daniel.ws/pt-br/posts/hello-world/","summary":"\u003cp\u003eEste é um ritual milenar na arte de aprender novas tecnologias.\nDevs ao redor do mundo conhecem a verdade: se você ousar escrever suas primeiras\nlinhas de código sem um \u003ccode\u003eHello World\u003c/code\u003e, está convidando uma maldição para sua jornada.\u003c/p\u003e\n\u003cp\u003eEspere bugs misteriosos infestando seu código, builds falhando sem motivo,\ne erros enigmáticos aparecendo na linha 23 — mesmo que essa linha não exista.\u003c/p\u003e\n\u003cp\u003eEntão, para honrar os deuses da programação, estou começando minha primeira experiência\ncom \u003ca href=\"https://gohugo.io/\"\u003eHugo\u003c/a\u003e seguindo a tradição sagrada: um post \u003ccode\u003eHello World\u003c/code\u003e.\u003c/p\u003e","title":"Olá Mundo"}]