Documentation/Build & deploy

Observability

Logs, metrics, traces, health, and readiness.

Every KUI process emits three things that are meant to be read together: traces (what a request did, and where the time went), metrics (how often, how long, how many) and logs (what happened, in words). This document is how an operator uses them.

The one field that ties everything together#

correlation.id is a 16-character value that appears in three places for the same request: in the error body the user is looking at, on every log line the request produced, and as the span id in the trace. So all three are one search away from each other.

Inside a request it is the span id, which means it can be pasted straight into a trace backend. Outside a request — a background scheduler, a startup path — there is no span to derive it from, so a random one is generated; it is still unique, it just does not point at a trace.

A caller may supply their own in the X-Kui-Correlation-Id header and KUI echoes it, so a caller who logged "I sent request X" can find X on this side too. A supplied value that is not 1 to 64 letters, digits and dashes is ignored and a fresh one is generated, because the value ends up in a response header and in log output, and an unchecked header is how a newline gets into a log file.

The context keys on every log line#

These five names are fixed (ARCHITECTURE.md §13). They are the same in every service, so one search works across all of them. They come from ContextKeys in libs/observability and never from a string typed at a call site.

Key On which entries What it is
service.name every entry which process wrote it — kui-gateway, kui-cluster, …
correlation.id every entry produced while serving a request or a stream the id above
user.id entries from an authenticated request the signed-in principal, hashed when kui.telemetry.hashUserIds is on (it is, by default)
cluster.id entries about one Kafka cluster the configured cluster slug
operation entries from an endpoint the endpoint's operation id, e.g. listTopics

A field that does not apply produces no key at all, rather than a key with a null value. "user.id": null on every line from every scheduler is noise that makes the real entries harder to find.

Inside a span, two more appear:

Key What it is
trace_id the trace this entry belongs to, 32 hex characters
span_id the span, 16 hex characters

Underscores rather than dots, because those are the names the OpenTelemetry logging conventions use and the names a trace-search backend already indexes. They are absent outside a span, so a startup line never carries a link to a trace that does not exist.

Log format#

kui.telemetry.logFormat is json (the default) or text.

json writes one JSON object per line, which is what a log system parses:

json{"timestamp":"2026-09-03T11:46:04.450Z","message":"listing topics","logger":"kui.topic.Api",
 "thread":"io-compute-4","level":"INFO","service.name":"kui-topic","correlation.id":"9f2c…",
 "cluster.id":"prod-eu","operation":"listTopics","trace_id":"4bf9…","span_id":"9f2c…"}

text is for a developer reading a terminal, where a line of JSON is unreadable. It keeps the message and the correlation id and drops the rest, because a terminal line has a width.

The configurations are logback.xml and logback-text.xml in libs/observability's resources. They include every MDC key rather than listing the ones they know about: a list here would mean adding a key in Scala and forgetting it in the XML, and the field would then be silently missing in production.

The metrics#

Every metric KUI emits is in this table. The names are constants in libs/observability's MetricNames, and MetricNamesSuite pins that list two ways. Be precise about which is which, because the previous wording overstated one of them by a hop:

  • Against a second hand-written copy of the list, inside the suite itself, in full and in order. That is the gate a rename hits: the name has to be changed in two places, and the second place is a test whose diff a reviewer reads. ARCHITECTURE.md §13 is where that copy came from, and it is named in a comment inside the suite; the suite does not read that file, so a §13 edit on its own breaks nothing.
  • Against the first column of the table below, both directions, since wave 10. A name in this table that the build does not declare is a panel that stays empty for ever; a name the build declares that this table omits is a number no operator will ever look at. The reader that finds those rows refuses to pass on fewer than twenty of them, so a table it stopped matching cannot go green by matching nothing.

This table was eleven rows short of MetricNames.all until wave 10, while the sentence above claimed otherwise and nothing compared the two. It is compared now. If you add a metric, add its row here — the build will tell you if you forget.

"Live from" is when the metric starts being emitted. Every name is declared from M0, so a later milestone cannot accidentally reuse one for something else.

Metric Labels Live from What it tells you
kui.http.server.duration service, route, status M0 How long KUI took to answer, in seconds. The one metric that answers "is KUI slow".
kui.upstream.duration service, upstream, outcome M0 Histogram in seconds for one logical call, including retries and failover, and how it ended.
kui.upstream.attempt.duration service, upstream, outcome M0 Histogram in seconds for individual transport attempts; distinguishes retry traffic from logical calls.
kui.upstream.circuit.state upstream M0 Whether an upstream's circuit breaker is closed, open or half-open.
kui.kafka.admin.duration cluster, operation, outcome M1 How long an admin call to a broker took.
kui.kafka.consume.records cluster, topic M3 Records read while browsing messages.
kui.kafka.consume.bytes cluster, topic M3 Bytes read while browsing messages.
kui.cache.hits cache M1 Cache hits, by cache.
kui.cache.misses cache M1 Cache misses, by cache.
kui.prometheus.query.duration source, query, operation, outcome M8 End-to-end logical Prometheus query latency in seconds, including cache and coalescing.
kui.prometheus.query.requests source, query, operation, outcome M8 Logical Prometheus query calls, counted once per caller.
kui.prometheus.response.bytes source, query, operation M8 Bounded response bytes accepted from the Prometheus API.
kui.prometheus.response.series source, query, operation M8 Series returned by accepted Prometheus responses.
kui.prometheus.response.samples source, query, operation M8 Samples returned by accepted Prometheus responses.
kui.prometheus.query.inflight source, query, operation M8 Logical Prometheus callers currently in flight.
kui.prometheus.query.cache.access source, query, operation, state M8 Query cache access by its bounded loaded/hit/coalesced and fresh/stale state.
kui.prometheus.query.coalesced source, query, operation M8 Callers that joined an identical in-flight query.
kui.prometheus.query.limit.rejected source, query, operation, limit M8 Prometheus responses rejected at a named structural or byte limit.
kui.prometheus.query.diagnostics source, query, operation, kind M8 Warning and info entries reported by Prometheus, counted without retaining their text.
kui.capability.state service, cluster, state M0 What the UI is allowed to show, per service and cluster.
kui.stream.events service, stream, event M0 Events pushed down an open stream, by event name.
kui.stream.active service, stream M0 Open streams. A gauge that never returns to zero is a leak.
kui.cursor.rejected reason M3 Paging cursors refused, by why.
kui.principal.rejected reason M0 Signed principal headers refused, by why.
kui.config.version section M1 The version of each configuration section in use.
kui.gateway.aggregation.section aggregation, section, status M2 How often each section of an aggregated response is served in each state. It is the number that answers "how often do operators see a degraded topic page, and which part of it is degrading" — which no per-endpoint metric can, because an aggregation that answers 200 with four missing sections looks healthy to a status-code histogram.
kui.cluster.profile.fetch outcome M2 Cluster-profile fetches by a Kafka-facing service, by how they ended (ADR-046).
kui.cluster.profile.subscribed (none) M2 Whether that service's change subscription is open. With the counter above it answers the question asked when a cluster edit does not take effect: is this service being told about changes, or polling because the stream is broken? Those look identical in a latency graph.
kui.serde.deserialize.failures serde, target, topic M3 Records whose intended serde could not read them, so the fallback rendered them instead. The metric that says a default serde is wrong for a topic; without it that misconfiguration is visible only as a screen full of mojibake nobody reports.
kui.serde.autodetected serde M3 Payloads decoded by an auto-detected serde: what the topics nobody configured actually contain.
kui.serde.serialize.failures serde, topic, reason M3 Produce payloads that could not become bytes, split by whose problem it is.
kui.serde.registry.built cluster, reason M3 Serde registries built for a cluster. A number that climbs on a stable configuration means profile churn is rebuilding them, and each rebuild throws away every cached schema.
kui.serde.registry.requests cluster, outcome M3 Calls to a Schema Registry, by how they ended.
kui.serde.registry.up cluster M3 Whether a cluster's Schema Registry is answering: the number the capability fold reads (ADR-039).
kui.filter.compile outcome M3 Smart-filter compilations, by outcome (ADR-017).
kui.filter.evaluate.duration (none) M3 How long one record's filter evaluation took. Its p99 is what says a user's filter, and not Kafka, is why browsing is slow.
kui.filter.errors kind M3 Records a filter could not decide on, split into runtime errors and timeouts. The same number the consumed stream event reports as filterErrors.
kui.masking.applied cluster, topic, target M3 Reads on which a masking rule was in force, one per read per half of the record (DM-001, ADR-023). Deliberately not a count of fields or records masked: that number is a function of the payload, so a per-field series would publish the shape of protected data onto a dashboard that is routinely less protected than the data itself. A topic whose card-number field is present on 3% of records would say so.

Reading outcome

kui.upstream.duration groups by outcome rather than by HTTP status, because each outcome calls for a different action. Canceled callers are recorded too, rather than disappearing from the latency distribution; durations use a monotonic clock.

outcome What happened Where to look
success 2xx or 3xx —
client_error 4xx KUI sent something the upstream did not accept — usually configuration
server_error 5xx the upstream is unwell
timeout KUI gave up waiting the upstream is slow, or the configured timeout is too tight
circuit_open KUI did not even try the breaker is open; see below
unreachable no connection DNS, network policy, or the upstream is down
canceled the caller stopped waiting client disconnection, shutdown, or a canceled parent operation

A dashboard grouped by status cannot tell timeout from unreachable, and those have different causes and different fixes.

What an open circuit looks like

When an upstream fails failureThreshold times in a row, KUI stops calling it for resetTimeout and then lets one probe through. While that is happening:

  • kui.upstream.circuit.state{upstream} is 1 (open) or 2 (half-open) rather than 0;
  • kui.upstream.duration{outcome="circuit_open"} keeps counting, so the calls that were refused are visible as data rather than as an absence of data;
  • exactly one INFO log line is written per transition, naming the upstream and the last error. There is deliberately no log line per failed call: a dead upstream that logs on every attempt floods the log exactly when you most need to read it;
  • the capability that depends on that upstream is reported as degraded, which is what greys the corresponding part of the UI out instead of letting a user click into an error.

Labels never carry user data

A route label is the path template — /clusters/{clusterId}/topics — and never the actual path. A label whose values multiply (a cluster id, a topic name, a URL) turns one metric into thousands of time series, which is a well-known way to take a monitoring system down. A topic name is acceptable where it is deliberately chosen and bounded, such as kui.kafka.consume.*; a message payload never is.

The Prometheus-query metrics use source only for the bounded configured cluster/source ID and query only for a validated, server-owned QueryId. Neither label is a source URL, PromQL expression, topic, consumer group, arbitrary Prometheus label, response text, or credential. Those values are intentionally absent even when a query fails.

Every endpoint is traced, and nobody instruments one#

Spans are named kui.<context>.<operation> — kui.topic.list — derived from the endpoint's own operation id, the same name the generated OpenAPI document uses. An endpoint that declares no operation id falls back to GET /clusters/{clusterId}/topics, which works but reads like a URL rather than like an operation. The fallback is a safety net, not a supported state: KuiInterceptors.missingOperationIds is the check each service runs over its own endpoints so the fallback stays unused.

Outbound calls get a client span of their own inside the request's span, with traceparent injected, so when a page is slow the trace shows whether the time went in KUI or in the system it called.

Health endpoints: what to probe, and what a 503 means#

Every KUI service serves three endpoints, and the first thing to get right is that the first two answer different questions:

Endpoint The question Configure it as
GET /health/live should this process be restarted? the liveness probe
GET /health/ready can this serve requests now? the readiness probe
GET /capabilities what can it currently do, per cluster? not a probe; the gateway polls it

/health/live never depends on an upstream. A service whose schema registry is down is not broken — it has a broken dependency, and restarting it will not fix the registry. Wiring liveness to an upstream turns one outage into two, because every replica restart-loops for as long as the upstream is down. It returns 200 unless the process should be restarted, and its body carries a flag and a timestamp and nothing else, deliberately, so that nobody can make a restart decision depend on anything more.

/health/ready returning 503 means "take me out of rotation", not "restart me". The body is a ReadinessReport listing every check, not only the failing ones: knowing that one upstream out of four is down is a different situation from knowing the only check there is has failed.

json{"ready":false,
 "checks":[{"name":"config","healthy":true,"detail":null},
           {"name":"schema-registry","healthy":false,"detail":"connection refused"},
           {"name":"connect","healthy":false,"detail":"timeout"}],
 "at":"2026-09-03T10:11:12.000Z"}

"detail":"timeout" is reserved and means the check did not answer inside its own budget, which is a different cause from a check that answered no. Every check has a timeout — two seconds unless the service says otherwise — and the endpoint has a total budget of three seconds on top. Checks run in parallel, so three one-second checks answer in about one second rather than three. A check that hangs, or one that throws, is reported as a failed check; it never makes the endpoint itself fail, because a broken probe is indistinguishable from a broken service.

All three endpoints are unauthenticated and allow-listed (ARCHITECTURE.md §13): a probe has no credentials and cannot be given any. They are also excluded from kui.http.server.duration and from request logging — a probe every second would dominate the histogram and drown the log, and neither would have told anyone anything.

A service whose readiness flips reports it immediately. It is the gateway's registry that applies a debounce, not the service, so that "is it up" and "should the UI grey this out" stay two separate decisions.

Two Prometheus endpoints, and why they are different#

This confuses people, so it is worth being explicit (ADR-009):

kui.telemetry.prometheusPort the metrics service's /metrics
What it exposes KUI's own telemetry: request durations, upstream latency, circuit state your Kafka clusters' metrics, which KUI collects on your behalf
Who reads it your monitoring system, watching KUI's health your monitoring system, or KUI's own dashboards
When it exists M0 M8 (kui-metrics-service)

They are never the same endpoint and one is never a substitute for the other. If KUI is slow, the first one tells you; if a broker is slow, the second one does.

What happens when telemetry itself fails#

Nothing that affects a request.

  • If the OTLP collector is unreachable at startup, the process starts anyway with an exporter that drops spans. Telemetry is never a startup dependency: a monitoring outage must not take KUI down with it.
  • If the SDK cannot be configured at all, KUI falls back to recording nothing rather than failing to boot.
  • Exporter errors are logged at WARN and swallowed. They never fail a request.

This is a deliberate asymmetry against the configuration loader, which does fail the start (see configuration.md). A wrong configuration means KUI would behave wrongly; a missing collector only means you cannot watch it.

View this guide on GitHub

Search documentation

Search features, guides, and deployment.

Open full-size image