Skip to content

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.kmd define 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 regra C* 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):

LinguagemHome do bindingConsumidores típicosEstado
Goengines/sdk/go/obsbackends, daemons, gateways, CLIsreferência — C1–C6 sobre o OTel SDK
Rustengines/sdk/rust/koder_telemetryapps Kroma (o stack nativo go-forward), CLIs Rustslice 1: C1 · C2.2 · C3 · C5 + §2.5; C4 · C6 a seguir
Dartengines/sdk/koder_kitapps Flutterlegado — Flutter em sunset (stack-RFC-019); não iniciar binding novo aqui
JS/TSengines/sdk/jsweb (templ+HTMX islands), TVpendente
Pythonengines/sdk/pythontooling, ML, scripts de produçãopendente

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 em privacy/consent-record.kmd R5. 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):

CampoTipoOrigem
tsRFC3339 UTC msclock (fakeable via koder_test_clock)
levelenum error|warn|info|debugcall site (semântica C1.1)
msgstring curta, estáticacall site (sem interpolação de PII)
servicestringconfig do componente
versionsemver stringbuild
trace_id128-bit hex (W3C)context (C3) — vazio se fora de request
span_id64-bit hexcontext (C3)
tenant_idstring opacacontext (C3) — vazio se não-tenant
fieldsobjeto k→vcall 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:

CamadaO que éC2.1 se aplica
Exposiçãoo nome que o backend vê e que dashboard/alerta consultamsim, literalmente
Instrumento OTelmeter.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 slug registrado do componente, -_. Normativo: naming/forms.kmd §7 / R7.6; fonte do slug: registries/stack-manifest.kmd (gateado por koder-spec-audit naming, T1–T11). Slug jetjet_…; slug kdbkdb_…; slug koverkover_….

    Nota (2026-07-16, stack#437): sob a regra do owner o slug perdeu o prefixo em todo componente (R4.1 — koder-jet virou jet), e o prefixo koder- migrou pra forma nova package (R4b.1), que rege apt/.deb/catálogo do Hubnão a métrica. A R7.6 chegou a ser reapontada pro package e 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 label job, 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 emite koder_jet_* é o slug jet.

    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). (Sob package a 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 — exigia koder_kdb_* de um componente cujo slug é kdb por decisão ratificada, reintroduzindo o gagueio de K que o rename koder-kruzekruze eliminou (owner, 2026-07-12). A forms.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, mais kdb_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) e koder_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ê.

_seconds NÃO significa latência. koder_triage_repo_snapshot_age_seconds é um gauge terminado em _seconds e está certo: idade é valor pontual, não distribuição de duração. Os 3 gauges _seconds da Stack são todos idades; os 24 histogramas _seconds sã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 _total e 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.

AchadoNúmeroDisposição
_total ⇔ counter (exposição)234/239C2.1.2; a exceção é defeito → stack#430
Gauge terminando em _total1 (kdb_blob_chunks_total)defeito de código → stack#430
Nome com _duration_/_latency_ que não é histograma0/239C2.1.4 vale na população inteira
Gauges _seconds que são idade, não latência3/3motiva C2.1.4 checar token, não sufixo
Histogramas sem sufixo de unidade, corretos3mata a ideia de sufixo fechado (C2.1.3)
Instrumentos OTel com ponto e sem sufixo6conformes 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)5defeito 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_secondsseis 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 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 WithSpan carrega o trace_id do 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 fields não aparece no sink (C5).
  • CT5debug desligado 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_id daqui).
  • Audit estático (CT3/alert-runbook) — é KTOOLS-033.
  • Determinismo de repro de UIheadless-first R5.

Open questions

  1. 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.
  2. 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 via stack-manifest.kmd se virar componente nomeado.

References