Skip to content

Observability connectors

korvid can ask a Prometheus and a Loki backend a fixed set of resource-scoped questions about the workload you are looking at. It does not become an observability client: there is no free-form query surface, every call is bounded before it leaves the process, and both backends are read-only. Kubernetes state alone does not explain every incident — error rate and latency move before a pod fails, saturation lives at service level rather than in one pod, and centralized logs outlive the pod that wrote them — which is why these two connectors exist as opt-in add-ons rather than core panels. Configure neither and nothing changes: the two tools are not offered to the agent or to MCP hosts at all.

Install

Install the complete application variant in its own tool environment:

uv tool install 'korvid[agent,observability]'  # embedded agent tools
uv tool install 'korvid[mcp,observability]'    # external MCP tools

pipx install accepts the same requirement strings. Observability is a tool add-on for the agent or MCP surface, not a standalone base-TUI panel; the connector boundary itself is stdlib, and the extra provides the httpx client a plain MCP install otherwise lacks.

Configure

observability:
  prometheus:
    url: https://prometheus.internal/prometheus
    token_env: PROM_TOKEN          # or: token_file: /var/run/secrets/prom
    timeout_seconds: 10
    default_window_minutes: 60
    max_window_minutes: 360
    max_series: 50
    max_response_bytes: 1048576
    max_concurrency: 2             # in-flight requests to this backend

  loki:
    url: https://loki.internal
    token_file: /var/run/secrets/loki
    tenant: team-a                 # X-Scope-OrgID, for multi-tenant Loki
    max_lines: 200
    label_mappings:                # how your log shipper labels things
      namespace: namespace
      pod: pod
      workload: app
    mask_labels:                   # values always masked in results
      - tenant
      - customer

The two backends are independent — configure either, both, or neither. Every key is optional except url. A backend is disabled with a startup warning, never half-configured, when its endpoint is missing, malformed, or not http(s)://; when the url carries a username or password (an inline credential wearing a URL's clothes — use token_env/token_file instead); when a label_mappings name is not a valid label identifier; or when two scope fields map to the same backend label (a lost matcher, not a preference).

Independent reads, not the watch snapshot

flowchart LR
    WATCH["Watch-backed resource table"] --> UI["Korvid context"]
    UI --> METRICS["Bounded metrics query"] --> PROM[("Prometheus")]
    UI --> LOGS["Bounded log search"] --> LOKI[("Loki")]
    METRICS --> MASK["Bound + mask + provenance"]
    LOGS --> MASK

The resource table is watch-backed: every row is a live snapshot the cluster API keeps current. A metrics or logs call is a separate, independent read against its own backend at the moment it runs — not a replay of that watch snapshot, and not itself watched or cached. In the TUI, a call this triggers appears as an activity note rather than a screen navigation: there is no Prometheus view to mirror a query to, and no resource screen owns the result. Every answer carries its own provenance (endpoint, window, query) independent of whatever the resource table shows at that instant.

Provenance is what the two consumers share; a citation is not. The embedded agent records the answer into its evidence ledger and can cite it [E1] like any cluster read, but an MCP host receives the same bounded, masked text directly — korvid keeps no ledger for it and mints no citation, so the provenance carried inside the answer is all the host has to attribute it by.

Query examples

query_metrics reads one signal for a workload or pod, aggregated over a window — for example signal: cpu against workload: payment-worker resolves to a rate over container_cpu_usage_seconds_total, matching pods by name prefix (the only relationship visible in cAdvisor labels without a second lookup). A cluster whose metrics are named differently gets an empty result, not a wrong one — no series matched, not a guess.

search_logs searches centralized logs for a workload or pod over a window — for example contains: "connection refused" against pod: payment-worker-6c9f7d becomes a plain substring line filter, never a regular expression or LogQL, so it can narrow a result but never widen the label scope beyond what label_mappings resolved.

Bounds and masking

limit default behaviour at the limit
time window 60 min (max 360) refused, not shortened
result series / log lines 50 / 200 truncated, and the result says so
response bytes 1 MiB request aborted
JSON nesting depth 64 refused unparsed, as a backend error
concurrent requests (max_concurrency) 2 per backend excess calls queue on a semaphore; the wait is inside the request timeout
request timeout 10 s reported as a timeout, budgets the whole call

An over-long window is refused rather than clamped, because silently shrinking it would answer a different question from the one that was asked. Truncation is never silent: every result carries truncated: yes|no, the window it covers, the endpoint that answered, and the query that ran, so it participates in the agent's evidence citations like any cluster read.

The nesting bound is checked before parsing, by a single linear pass over the already byte-capped body that ignores brackets inside JSON strings. A body of 200,000 open brackets is a stack-exhaustion primitive, not a payload, and leaving it to the parser's own RecursionError makes the refusal depend on the interpreter's stack rather than on korvid. 64 levels is far past the six a Prometheus matrix or a Loki streams answer nests, and neither shape deepens with more data. A malformed body — an unterminated string, say — is still described by the JSON parser, not by the depth check.

Concurrency is a bound like the others, not a tuning hint: each backend admits max_concurrency requests at a time — 2 by default — and queues the rest on a semaphore, so a burst of tool calls cannot open an unbounded number of connections to your Prometheus. That wait happens inside the request timeout rather than before it: a call that spends nine seconds queued and two in flight is reported as a timeout, because the 10 s budget covers the whole call, not just the socket.

Results are masked before they leave korvid, in two passes — a host receives them directly, so masking cannot depend on a downstream provider. Credential-shaped text (an API key a workload logged, a bearer token echoed by a backend error) is scrubbed from every result and every error the call can raise. mask_labels covers what shape alone cannot recognise — a tenant id, a customer name — replacing those label values, case-insensitive, everywhere they would otherwise appear, including in the echoed query and provenance, not only in response labels.

Credentials and TLS

A token is named in config, never stored there — exactly one of token_env (an environment variable name) or token_file (a path to a regular file; a FIFO or device is refused since opening one can block forever). Setting both, or an inline token:/password:/api_key:, disables the backend when the configuration is loaded rather than guessing. The token is read at call time and its header safety is validated then — any control character or non-ASCII byte refuses that call with a config error instead of sending an invalid header value. The already-constructed backend stays configured, so correcting or rotating the value restores subsequent calls without restarting korvid. A valid token is used in one Authorization header, and dropped — never in a tool result, error, audit record, or log line. If a url carries userinfo, only the host is ever reported.

TLS verification cannot be disabled — there is no insecure option, and a config that sets one disables the backend with a warning instead. A plaintext http:// endpoint is accepted, but configuring a credential for one warns, since the token would then cross the network in the clear. Corporate CAs use the same network.ca_bundle setting the agent providers use (see air-gapped operation); the connectors get their HTTP client from the same composition-root trust builder as the rest of korvid.

Errors

kind meaning
config the URL, credential source, or signal name is wrong
auth / permission the backend rejected (401) or denied (403) the credential
network / timeout the endpoint is unreachable, or did not answer in time
limit the request or the answer exceeded a configured bound
backend the backend refused or malformed the query

The kind travels with the message: ERROR: [permission] loki.internal refused the query ….

What this is not

korvid does not host Prometheus, Loki, or an OpenTelemetry collector, does not write alerts, dashboards, or recording rules, and does not accept free-form queries. Tracing and cloud-provider connectors are not part of this surface.