Pular para o conteúdo

Especificação §19

Observabilidade

language-design.md §19 · 109 linhas · 8 min de leitura

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.

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 o error que 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).

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).

Tudo vive no módulo runtime:

procs := runtime.processes() // snapshot: lista de handles de processo
p := 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 index
chans := runtime.channels() // handles de channel
stats := runtime.stats() // agregados globais do runtime

Um 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 liga um fluxo de eventos:

runtime.trace(alvo, eventos, sink, opções)
  • alvo: um nome de processo, um nome de grupo, ou .all (e runtime.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/.return e .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, o set_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 o loop/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.

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.

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.

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).

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.