Observabilidade
O que observabilidade cobre aqui
Seção intitulada “O que observabilidade cobre aqui”A linguagem tem processos isolados e supervisionados (seção 8), e um sistema desses precisa ser observável em produção sem ser derrubado pra isso. Observabilidade aqui cobre duas coisas: introspecção do runtime (quais processos existem, quanto consomem, como está a árvore, o estado dos channels) e tracing ao vivo (o fluxo de eventos, mensagens, mortes, GC e agendamento, ao longo do tempo). O que ela não cobre é telemetria de aplicação (logs, métricas e spans que você emite): isso é biblioteca e stdlib, resolvido como em qualquer linguagem, e fica fora daqui.
O princípio que governa tudo é o teste do opt-in (seção 1) aplicado a custo: observabilidade é pull e default-off, e você não paga pelo que não liga. Ler o estado (introspecção) é barato e sob demanda; rastrear eventos (trace) custa, então fica desligado e você liga só o que quer, na frequência que quer. Não usou, não pagou; quis tudo o tempo todo, pagou tudo; precisa de três coisas, paga só elas. É controle e transparência, não mágica sempre ligada.
O que a arquitetura já expõe de graça
Seção intitulada “O que a arquitetura já expõe de graça”Boa parte da observabilidade você já produziu sem chamar de observabilidade. A arquitetura gera os dados como subproduto, e a introspecção só os colhe:
- Memória por processo, com estratégia: cada processo tem seu
@mm(seção 5), seja arena, gc, none ou c. O runtime já sabe exatamente quanta memória cada processo usa e de qual estratégia, porque precisa disso pra operar. É uma métrica que cai no colo, e que a maioria das plataformas não tem tão limpa (um heap compartilhado não te diz “este processo usa X”). Você tem isolamento de memória, logo tem contabilidade de memória por processo. - A árvore de supervisão: ela é a estrutura do teu código (seção 8). Quem supervisiona quem, quem spawnou quem, taxa de restart, tudo já existe estruturalmente, não precisa ser reconstruído por instrumentação.
- O motivo da morte: o
|e|da seção 8 já é um evento observável estruturado, porque cada morte carrega oerrorque a causou. “Por que o processo X morreu” não exige tracing, é um dado que o modelo de supervisão já entrega.
A introspecção não inventa esses dados; ela os expõe com uma interface uniforme. O trace adiciona a dimensão temporal (o fluxo, não só o retrato).
Duas APIs: introspecção e trace
Seção intitulada “Duas APIs: introspecção e trace”Observabilidade se divide em duas APIs porque elas têm custos opostos:
- Introspecção (snapshot): lê estado que já existe. Barata, pull a qualquer hora, sem overhead contínuo, um retrato do agora, tirado quando você pede. Listar processos, ver memória, profundidade de um channel: nada disso precisa de instrumentação ligada.
- Trace (stream): captura um fluxo de eventos ao longo do tempo. Custa (o runtime intercepta e registra continuamente), então é opt-in e default-off, e você paga só o que ligar.
Snapshot é ler o que está lá (grátis); trace é instrumentar pra capturar o que passa (paga enquanto ligado).
Introspecção: snapshot do estado
Seção intitulada “Introspecção: snapshot do estado”Tudo vive no módulo runtime:
procs := runtime.processes() // snapshot: lista de handles de processop := runtime.process("db_writer") // um processo por NOME registrado (restart-estável, seção 3)grp := runtime.group("handlers") // um grupo registrado → coleção iterável, [i] pelo indexchans := runtime.channels() // handles de channelstats := runtime.stats() // agregados globais do runtimeUm snapshot de processo rodando é tirado num safepoint: o runtime sincroniza pra ler estado consistente, sem corrida com o próprio processo. O dado já existe (a arquitetura o produz, seção 19 acima), e a consistência é só o ponto de leitura.
Cada handle de processo (Process) carrega o máximo, modelado no process_info do Erlang (a referência em introspecção) mais o teu diferencial de @mm:
id (identidade interna do runtime, pra display, não é chave de lookup e não sobrevive a restart), name (o nome registrado, se houver, a chave estável; não-registrados aparecem sem name), state (rodando, runnable, esperando num recv, suspenso, saindo ou coletando), current_fn (mais stacktrace), spawn_point (com que função foi criado), memory (mais heap/stack), mm_strategy (gc/arena/none/c) mais objetos alocados e rastreados, work (trabalho feito, o reductions), supervisor mais children (a árvore), restarts, uptime, priority, gc_stats.
Cada handle de channel: id, depth (enfileirados agora), capacity, blocked_senders / blocked_receivers (quem espera, valioso pra deadlock), sent / received, closed, tipo e direção.
O global (stats): process_count, channel_count, workers (schedulers), memory_total mais memory_by_strategy, run_queue, gc_stats, uptime.
Lookup é por nome (runtime.process("nome") devolve um Process) ou por grupo (runtime.group("nome") devolve uma coleção, [i] pelo index), nunca por id cru: o id é ponto no tempo e envelhece no restart, o nome atravessa (seção 3). De dentro de um processo, runtime.self() devolve a própria referência. Como tudo é pull, essa superfície máxima custa zero até você chamar, então “mais dado sempre melhor” sai de graça na introspecção.
Trace: captura barata, consumo configurável
Seção intitulada “Trace: captura barata, consumo configurável”Trace liga um fluxo de eventos:
runtime.trace(alvo, eventos, sink, opções)- alvo: um nome de processo, um nome de grupo, ou
.all(eruntime.self()pra auto-trace). - eventos:
.send/.receive(mensagens),.spawn/.exit(ciclo de vida),.schedule_in/.schedule_out(agendamento),.gc,.restart,.channel_block(processo travou num channel), e os caros e opcionais.call/.returne.alloc/.free. (É a taxonomia das trace flags do Erlang.) - opções:
sample: N(1 em N),duration: T(time-boxed),inherit(a trace herda pros filhos do alvo, oset_on_spawn),timestamp.
A captura é sempre um ring buffer no runtime: cada evento escreve um registro fixo, sem agendar ninguém. É o piso de overhead (todo nanosegundo conta na captura; é o que o erl_tracer nativo do Erlang faz pra não pagar mensagem por evento). O que muda é como você consome o buffer, e aqui entram os destinos, agora como escolha só de consumo:
.buffer(default): eventos se acumulam no buffer circular e você os lê sob demanda (um snapshot da janela recente). É o mais barato, porque a captura escreve e você lê quando quiser. Ideal pra “deixa rodando, e se algo der errado eu inspeciono o histórico”..channel: um processo consumidor dreina o buffer e streama num channel, consumido com oloop/match/<-de sempre. A transmissão (mesmo com fila) não é crítica em nanosegundos como a captura, então pode pagar o channel..callback(fn): cada evento chama uma função tua; a app reage ao próprio trace..endpoint: eventos drenam pra porta nativa, pro consumo externo.
ev := runtime.trace("worker", [.send, .receive, .channel_block], sink: .channel, sample: 100)loop e in ev { match e { Send(m) => ... Receive(m) => ... ChannelBlock(ch) => alert("worker travou em {{ch.id}}") }}O tipo do elemento do stream é Stream[Event{events...}]: ele segue a lista comptime de eventos, então o match cobre exatamente o que você pediu (um conjunto de variantes, seção 7), exaustivo sem _. Lista escolhida em runtime não estreita: aí e é o Event cheio.
A captura barata (buffer) desacopla do consumo (sink), e essa é a chave do “paga só o que liga”: ligar a captura é barato e uniforme; o custo de transmitir e formatar fica no sink, que você escolhe. Os três sinks ativos (.channel/.callback/.endpoint) opõem auto-observação a debug externo; só muda quem consome, não o mecanismo.
Proveniência e filtros
Seção intitulada “Proveniência e filtros”Cada evento (e cada frame de stack) carrega sua proveniência: user (teu código), runtime (scheduler, GC, maquinaria de channel) ou tracer (o custo do próprio tracing). É uma etiqueta que o runtime atribui na captura, e ela destrava filtros no consumidor:
hide_runtime: esconde os frames internos do runtime; você vê só o teu programa, sem o ruído da maquinaria.hide_tracer: esconde o custo do próprio tracing; você não confunde o overhead de medir com o comportamento do programa (o anti-heisenbug na leitura, em que o profile mostra teu app, não o observador).
Os dois são toggles de quem consome (no servidor ou no app que lê), não de quem captura: a captura pega tudo (é barata), o consumidor filtra. Sobre formatos: a capacidade de stack-sampling (amostrar a pilha mais os eventos de agendamento) é do runtime; o desenho (flamegraph, call graph) é do consumidor e do tooling. O runtime entrega o dado cru etiquetado; a ferramenta renderiza e filtra.
Heisenbug: mitigável, não eliminável
Seção intitulada “Heisenbug: mitigável, não eliminável”Uma honestidade que o desenho assume: observar um sistema concorrente o altera, e isso não se elimina, só se reduz. Medir tem custo, o custo muda o timing, e o timing muda bugs de concorrência. Ninguém resolve isso (nem a BEAM, nem o Go, nem profilers de kernel). O design minimiza a perturbação por quatro vias: amostragem (sample: N perturba menos que medir tudo), ring buffer (escrever no buffer perturba menos que formatar e transmitir por evento, então o caro fica fora do caminho quente), custo previsível (overhead uniforme é menos traiçoeiro que variável), e default-off (em produção normal não há perturbação; você paga o heisenbug só durante a investigação, conscientemente). Não é uma feature anti-heisenbug; é um conjunto de escolhas que reduzem a perturbação, e o desenho diz isso em vez de fingir que resolve.
Endpoint nativo e adaptadores
Seção intitulada “Endpoint nativo e adaptadores”O endpoint serve introspecção e trace na serialização nativa da linguagem, sem acoplar a Prometheus, OpenTelemetry ou qualquer formato de terceiro. A razão é a de sempre (controle, não dependência): a linguagem não morre se um formato de tooling sair de moda, porque a API crua sobrevive a qualquer um.
E o ganho é que, com a API em código, o programador constrói o adaptador pro que ele usa. Quer Prometheus? Um handler lê runtime.processes()/runtime.stats() e cospe no formato Prometheus. Quer OpenTelemetry? Mesma coisa, outro formato. Adaptadores são biblioteca de usuário sobre a API crua, não responsabilidade do runtime. A linguagem não casa com nenhum formato, mas não impede nenhum: Prometheus vira uma lib que alguém escreve (mesmo princípio do cdeps no FFI, que não acopla a nenhuma lib C e deixa usar todas).
Fronteira: runtime agora, tooling depois
Seção intitulada “Fronteira: runtime agora, tooling depois”Respeitando que tooling vem depois, o que entra agora no runtime e na linguagem (o que esta seção define) é a API de introspecção (funções e campos), a primitiva de trace (runtime.trace, captura em buffer, sinks, proveniência), e o endpoint servindo os dados na serialização nativa. O que fica pra fase de tooling é o observer (a UI/CLI que conecta no endpoint e mostra tudo, o dbg/:observer do Erlang, o render de flamegraph e grafo) e o formato-de-fio exato. Você ganha o trace (a primitiva) agora; o dbg (a ferramenta) na fase de tooling.