Instrumentation Contract
observability specs/observability/instrumentation-contract.kmd
Contrato único que todo binding de instrumentação Koder (Go, Dart, JS, Python, …) DEVE satisfazer. Define o schema de log, a convenção de métrica + deny-list de cardinalidade, o context object propagado implicitamente, a propagação W3C, a redação de PII e o export OTLP. É o "como" implementável da policy `observability-first.kmd` (o "o quê" normativo). Um único contrato → N bindings idênticos em comportamento.
Specification body
Spec — Instrumentation Contract
Esta spec é o contrato que os bindings implementam. A policy
observability-first.kmddefine o que é exigido (regras R*); esta spec define a forma exata (schemas, assinaturas, deny-lists) pra que Go, Dart, JS e Python produzam telemetria idêntica em comportamento. Toda regraC*abaixo é testável (conformance tests no fim).
Escopo
Aplica a todo binding de instrumentação consumido por componentes
Koder. Os bindings vivem nos SDKs de cada linguagem (não se reinventa por
componente — reuse-first):
| Linguagem | Home do binding | Consumidores típicos | Estado |
|---|---|---|---|
| Go | engines/sdk/go/obs | backends, daemons, gateways, CLIs | referência — C1–C6 sobre o OTel SDK |
| Rust | engines/sdk/rust/koder_telemetry | apps Kroma (o stack nativo go-forward), CLIs Rust | slice 1: C1 · C2.2 · C3 · C5 + §2.5; C4 · C6 a seguir |
| Dart | engines/sdk/koder_kit | apps Flutter | legado — Flutter em sunset (stack-RFC-019); não iniciar binding novo aqui |
| JS/TS | engines/sdk/js | web (templ+HTMX islands), TV | pendente |
| Python | engines/sdk/python | tooling, ML, scripts de produção | pendente |
Rollout: Go primeiro (maior superfície backend, onde SLO/RED mais importam) — feito, e é a referência de paridade. Rust em seguida: a tabela original não o listava, e
koder-app/behaviors.kmd§2 nomeava bindings só de Go e Dart — então o stack nativo go-forward (Kroma) ficou sob um mandato que não tinha como cumprir, com o único binding de app apontando pro Flutter que está sendo desligado (stack#429). JS/Python depois.
Paridade não é aspiracional. "Divergência de comportamento entre bindings = bug de paridade" só é verdade se algo medir. A allow-list C5 e a deny-list C2.2 do binding Rust são diffadas contra a fonte Go por
engines/sdk/rust/koder_telemetry/tests/parity.test.sh(77 = não consegui comparar, nunca "idênticos"). Sem isso o drift é silencioso na pior direção: um binding redigindo menos que o outro é um vazamento de PII que nenhum teste do próprio binding enxerga — todos concordam com a lista dele. Estender uma lista = estender ambas, no mesmo commit.
Gate de consentimento (
behaviors.kmd§2.5). Todo binding DEVE oferecer como gatar uma emissão de product analytics emprivacy/consent-record.kmdR5. Um binding sem esse gate torna o §2.5 inimplementável pra todo app que o usa — a obrigação é do SDK, não de cada app reinventar. O binding Rust nasce com ele; o Go ainda não tem (§2.5 é posterior) → gap de paridade registrado em stack#429.
C1 — Schema do evento de log
Todo log é um objeto com campos obrigatórios (tipos fixos):
| Campo | Tipo | Origem |
|---|---|---|
ts | RFC3339 UTC ms | clock (fakeable via koder_test_clock) |
level | enum error|warn|info|debug | call site (semântica C1.1) |
msg | string curta, estática | call site (sem interpolação de PII) |
service | string | config do componente |
version | semver string | build |
trace_id | 128-bit hex (W3C) | context (C3) — vazio se fora de request |
span_id | 64-bit hex | context (C3) |
tenant_id | string opaca | context (C3) — vazio se não-tenant |
fields | objeto k→v | call site (passa por redação C5) |
C1.1 — Semântica de level (idêntica a observability-first R1.2):
error = "alguém precisa olhar eventualmente"; erro esperado/tratado é
info/warn. debug off em prod por default, ligável em runtime sem
redeploy (R1.3).
C2 — Métricas: naming + cardinalidade
C2.1 — Naming. Forma: <prefixo>_<name…>_<suffix?>, snake_case, onde o
prefixo é o slug do componente (-→_) — ver C2.1.1. <name…> é um ou
mais tokens (subsistema + grandeza) — não a forma de 4 tokens que este contrato
dizia antes: koder_flow_backups_scheduler_ticks_total é conforme e sempre foi.
C2.1.0 — a regra governa o nome de EXPOSIÇÃO, não o do instrumento. Esta é a distinção que faltava, e sem ela todo o resto reprova código correto:
| Camada | O que é | C2.1 se aplica |
|---|---|---|
| Exposição | o nome que o backend vê e que dashboard/alerta consultam | sim, literalmente |
| Instrumento OTel | meter.u64_counter("kdb.rpc.count").with_unit("By") | não — dots + unidade como metadado, per convenções semânticas OTel |
Num registro Prometheus direto os dois nomes coincidem, então a distinção some.
Num binding sobre OTel não: o exportador OTLP→Prometheus converte ponto em
underscore e deriva o sufixo a partir do tipo e do with_unit — pôr _total
no nome do instrumento produz ..._total_total na exposição. Conformidade se
avalia no nome derivado. kdb-RFC-002 §6.1 (aprovada) nomeia os instrumentos
do kdb assim de propósito e emite em paralelo pro Prometheus "same numbers, same
labels, so existing dashboards keep working" — não é desvio, é a outra camada.
Cada regra abaixo é decidível a partir de (nome de exposição, tipo) — é o que
a torna implementável no construtor, e não uma convenção que vive em comentário.
Todas foram medidas contra as 239 métricas (tipo, nome) que existem hoje
(§C2.1-M); uma regra que reprovasse métrica correta seria pior que nenhuma.
C2.1.1 — o prefixo é o
slugregistrado do componente,-→_. Normativo:naming/forms.kmd§7 / R7.6; fonte do slug:registries/stack-manifest.kmd(gateado porkoder-spec-audit naming, T1–T11). Slugjet⇒jet_…; slugkdb⇒kdb_…; slugkover⇒kover_….Nota (2026-07-16, stack#437): sob a regra do owner o slug perdeu o prefixo em todo componente (R4.1 —
koder-jetviroujet), e o prefixokoder-migrou pra forma novapackage(R4b.1), que rege apt/.deb/catálogo do Hub — não a métrica. A R7.6 chegou a ser reapontada propackagee foi revertida no mesmo dia: o prefixo compra desambiguação só onde o namespace não é nosso, e o Prometheus self-hosted raspa componentes Koder + exporters padrão — proveniência ali se responde pelo labeljob, não pelo nome. O que este contrato mede abaixo (86 de 125 conformes) segue valendo, e as séries emitidas hoje não mudam — exceto as que já divergiam (stack#430). O que muda é só o exemplo: o prefixo de quem hoje emitekoder_jet_*é o slugjet.E isso TEM custo — medido, não estimado (650 métricas distintas na árvore). Com o slug desprefixado, as séries que hoje começam por
koder_deixam de ser conformes:koder_jet_*(37 famílias),koder_flow_*(17),koder_ai_*(17),koder_lease_*(10),koder_id_*(5) — ~86 famílias. As que já eram bare seguem conformes e não se mexe nelas:kdb_*(71),kode_*(30),kortex_*(19). (Sobpackagea conta se inverteria e seria pior: ~120.)Renomear métrica quebra dashboard e alerta, então isto não é um flip de spec: é migração, regida pela
always-on.kmd(emitir as duas séries, migrar consumidor, retirar a antiga) → stack#430 parte B (o ticket que já mede a população). Até essa migração rodar,koder_<slug>_*legado é conhecido e tolerado, não conforme-por-omissão.koderjet_*(7) não é conforme sob regra nenhuma — nunca teve o_— e entra na mesma migração.Este contrato não re-enuncia a regra de naming, e a versão anterior errou exatamente aí: dizia
koder_<service>_e, com isso, criava um segundo sistema de identidade — exigiakoder_kdb_*de um componente cujo slug ékdbpor decisão ratificada, reintroduzindo o gagueio de K que o renamekoder-kruze→kruzeeliminou (owner, 2026-07-12). Aforms.kmd§7 é a SSOT de superfície→forma e existe para que specs consumidoras apontem pra ela em vez de reinventar (reuse-first).Sob R7.6, 86 das 125 supostas violações eram conformes o tempo todo (
kdb_*83 +kover_*2 +karavan_*1, maiskdb_gateway_*= slug + subsistema); ver §C2.1-M. O que sobra é divergência real → stack#430.C2.1.2 —
_total⇔ counter, na exposição. Counter DEVE terminar em_total; não-counter NÃO PODE. Vale em 234/239; a única exceção ékdb_blob_chunks_total, um gauge em_total— defeito de código, não da regra (stack#430). Num binding OTel o_totalé derivado pelo exportador a partir do sum monotônico: não escrever no nome do instrumento (C2.1.0).C2.1.3 — o sufixo de unidade segue a UNIDADE, não o tipo. Valor com unidade física ⇒ unidade-base Prometheus:
_seconds,_bytes. Nunca_ms,_usec,_mib,_kb(4 violações hoje — §C2.1-M). Valor adimensional ⇒ substantivo plural, sem sufixo de unidade:_iterations,_connections,_pids,_processes. Não existe conjunto fechado de sufixos, e não deve existir:koder_ai_test_repair_iterations(histograma) ekoder_fleet_workload_pids(gauge) estão corretos e nenhuma lista fechada plausível os aceitaria.C2.1.4 — latência é sempre histograma; o gate é o token do nome. Nome contendo
_duration_ou_latency_DEVE ser registrado como histogram (vale nos 156). Este gate é necessário, não suficiente, e o contrato diz isso na cara: ele pega a latência nomeada honestamente; uma latência escondida atrás de outro substantivo é matéria de review, não de lint. Prometer cobertura total aqui seria vender um gate que certifica o que não vê.
_secondsNÃO significa latência.koder_triage_repo_snapshot_age_secondsé um gauge terminado em_secondse está certo: idade é valor pontual, não distribuição de duração. Os 3 gauges_secondsda Stack são todos idades; os 24 histogramas_secondssão todos durações. Por isso C2.1.4 checa o token do nome + o tipo declarado, e nenhum check de sufixo pode implementar "latência sempre histograma" — foi a tentativa de escrever esse check que revelou que a regra era indecidível como estava escrita.
C2.1-M — a medição que fundamenta C2.1 (2026-07-16)
239 pares (tipo, nome) — Go 156 + Rust 83 — de 6 mecanismos de registro:
engines/sdk/go/obs; client Prometheus (Go); coletor próprio do Jet; coletor próprio
do ai/runtime; constantes do ai/tools; crate prometheus + instrumentos OTel em
Rust (infra/data/kdb/crates/kdb-obs). O contrato antes descrevia um.
Correção da primeira versão desta seção (mesmo dia). Ela dizia "156 métricas, 5 mecanismos" e derivava daí um bicondicional exato. Estava medindo só
*.go— a população Rust inteira (83, todas do kdb) ficou fora, e com ela um gauge em_totale 6 instrumentos OTel com ponto no nome. Medir uma população e chamá-la de a população é o mesmo defeito que esta seção existe pra evitar, um nível acima. Os números abaixo são da união.
| Achado | Número | Disposição |
|---|---|---|
_total ⇔ counter (exposição) | 234/239 | C2.1.2; a exceção é defeito → stack#430 |
Gauge terminando em _total | 1 (kdb_blob_chunks_total) | defeito de código → stack#430 |
Nome com _duration_/_latency_ que não é histograma | 0/239 | C2.1.4 vale na população inteira |
Gauges _seconds que são idade, não latência | 3/3 | motiva C2.1.4 checar token, não sufixo |
| Histogramas sem sufixo de unidade, corretos | 3 | mata a ideia de sufixo fechado (C2.1.3) |
| Instrumentos OTel com ponto e sem sufixo | 6 | conformes por C2.1.0 (outra camada) |
Nomes fora do slug registrado (R7.6) | ~34 — dos 125 fora de koder_, 86 eram conformes (slug kdb, kover, karavan) | divergência real → stack#430 |
Sufixo fora de unidade-base (_ms, _usec, _mib) | 5 | defeito de código → stack#430 |
Por que a regra antiga reprovava código certo. Lida ao pé da letra
(suffix ∈ {_total,_seconds,_bytes} + "latência sempre histograma"), reprovava
koder_fleet_workload_pids, _processes, koder_notify_channels_active,
koder_iris_scp_associations_inflight, koder_fleet_workload_cpu_usec e
koder_triage_repo_snapshot_age_seconds — seis métricas corretas — e, sem o
C2.1.0, mais 6 instrumentos OTel que seguem uma RFC aprovada. Um gate assim não
faz cumprir a regra: fabrica falha em código certo, o autor aprende a contorná-lo, e
o próximo gate legítimo já nasce sem crédito.
A regra do prefixo não era deste contrato pra escrever. A primeira versão desta
seção mediu 125/239 (52%) "fora de koder_" e concluiu que a Stack estava 52% errada.
Estava lendo a regra errada: a forms.kmd §7 — SSOT normativa de superfície→forma —
nunca ligou o namespace de métrica a forma nenhuma, e este contrato preencheu o
silêncio inventando koder_<service>_. Fechado o buraco na fonte certa (§7/R7.6,
2026-07-16), 86 dos 125 já eram conformes pelo slug registrado e a divergência real
caiu pra ~34. Uma regra inventada por uma spec consumidora não vira verdade por estar
escrita: vira um segundo sistema de identidade.
Onde o gate mora — no construtor, não num grep. 6 mecanismos; um auditor que varre
o repo sempre perderá o próximo — este extrator começou em 114, foi a 156 e só chegou a
239 quando parou de varrer só Go. C2.1 é validado por cada API de registro sobre o
nome de exposição que ela produz (C2.1.0). Isso inverte a fatia 2b como estava
planejada ("validador nos dois bindings"): obs + koder_telemetry cobrem uma fração
— Jet, ai/runtime, ai/tools, edictus e kdb registram por fora → stack#432.
C2.2 — Deny-list de label (cardinalidade ilimitada — hard fail): o binding rejeita em build/lint qualquer label em:
user_id, tenant_id, trace_id, span_id, request_id, session_id,
email, path-com-id, error_message, ip, url-com-query
Atribuição por tenant/usuário vai em exemplar de trace ou em log
(C1), nunca em série temporal. (Exceção allow-listed: tenant_tier
bounded.) Mapeia observability-first R2.3.
C3 — Context object & propagação implícita
C3.1 — O binding carrega um context object da linguagem (context. Context em Go, zona/Zone ou equivalente em Dart, contextvars em
Python, AsyncLocalStorage em JS) contendo {trace_id, span_id, tenant_id, request_id}.
C3.2 — Log e span herdam esses campos automaticamente do context
— o call site não passa trace_id na mão (R4.2). Passar manualmente
é violação de contrato.
C3.3 — Assinatura mínima que cada binding expõe:
WithSpan(ctx, name) -> (ctx, span) // abre span filho
Log(ctx, level, msg, fields) // log herda ctx
Metric counters/histograms via registry com guarda C2.2
Inject(ctx, carrier) / Extract(carrier) -> ctx // C4
C4 — Propagação W3C
Toda borda de saída injeta traceparent/tracestate (W3C Trace
Context); toda entrada extrai. Um request cross-service é um trace
(R3.1). Clientes de surface (mobile/desktop/web) iniciam o trace e
propagam pro backend (R3.4).
C5 — Redação de PII (allow-list, não deny-list)
C5.1 — fields (C1) e atributos de span passam por um redator que
só deixa passar chaves explicitamente allow-listed; o resto é
elidido ("[redacted]"). Default seguro: desconhecido → redigido.
C5.2 — Proibido em qualquer sinal: senha, token, chave, email, CPF,
conteúdo de mensagem/documento de usuário (R8.1 + security.kmd).
Request body inteiro nunca é logado.
C6 — Export OTLP
O binding exporta os 3 sinais em OTLP (vendor-neutral) pro collector
self-hosted de infra/observe/ (OBS-061). Sem SDK proprietário de vendor
(R7.2 / reversibilidade D9). Sampling: head default + tail 100% em
erro/latência>p99 (R3.3), decisão coerente entre log e trace.
Requisitos por binding (paridade)
Cada binding é conforme quando expõe C3.3, aplica C2.2 em build-time,
redige por C5.1, propaga C4, e exporta C6 — e passa os conformance tests.
Divergência de comportamento entre bindings = bug de paridade (registry
chat-channels-parity-style a criar se necessário).
Conformance tests
Mapeiam os T* da observability-first.kmd:
- CT1 (=T2) — log emitido dentro de
WithSpancarrega otrace_iddo context; recuperável por ele. - CT2 (=T1) — request cross-binding produz um trace, parent-child correto.
- CT3 (=T3) — registrar métrica com label da deny-list C2.2 falha em build/lint.
- CT4 (=T4) — campo sensível em
fieldsnão aparece no sink (C5). - CT5 —
debugdesligado por default; ligável em runtime sem redeploy (C1.1 / R1.3).
Non-goals
- Backend de storage/dashboards — é OBS-061, não esta spec.
- Symbolication de crash — é OBS-063 (consome o
trace_iddaqui). - Audit estático (CT3/alert-runbook) — é KTOOLS-033.
- Determinismo de repro de UI —
headless-firstR5.
Open questions
- Reusar OpenTelemetry SDK por linguagem como base (vendor-neutral, já OTLP) vs binding fino próprio? Default proposto: usar o OTel SDK como substrato e encapsular num thin Koder layer que impõe C1/C2.2/C5 (não reinventar wire/propagação) — decidir no início do binding Go.
- Home canônico do thin layer compartilhado (cross-language) — provável
engines/sdk/<lang>por linguagem, com o contrato (esta spec) como fonte única. Confirmar naming viastack-manifest.kmdse virar componente nomeado.
References
policies/observability-first.kmdpolicies/multi-tenant-by-default.kmdpolicies/security.kmdpolicies/reuse-first.kmdpolicies/identity-data-retention.kmd