Instrumentation Contract

draft

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.

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

Linguagem Home do binding Consumidores típicos
Go engines/sdk/go backends, daemons, gateways, CLIs
Dart engines/sdk/koder_kit apps Flutter (mobiledesktopweb/tv)
JS/TS engines/sdk/js web (templ+HTMX islands), TV
Python engines/sdk/python tooling, ML, scripts de produção

Rollout: Go primeiro (maior superfície backend, onde SLO/RED mais importam). Dart em seguida (bloqueado hoje por lock de sessão em koder_kit). JS/Python depois.

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: koder_<service>_<unit>_<suffix> snake_case, suffix Prometheus (_total, _seconds, _bytes). Latência sempre histograma.

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 (mobiledesktopweb) 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 C1C2.2C5 (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 component-names.md se virar componente nomeado.