Monitoring lab
Module 09 listed the signals to watch but could not read them. This lab opens the brokers' metrics, reads each signal by its real name, runs Prometheus with alert rules, and stops a broker to watch the alerts fire and clear.
Try it: read the dashboard, name the problem
Pick a situation. The six tiles change to the values you would see. Decide what is wrong before you read the explanation.
What it is and why it exists
Kafka hides trouble well. A cluster with one broker down still serves every request. A follower that is falling behind causes no error. A disk that fills in six hours looks fine now. Without measurements you learn about each of these when the second fault arrives and something stops.
Monitoring means three things: metrics that the brokers publish, a system that collects them over time, and alerts that tell a person when a number means trouble. The skill is not in collecting everything. It is in choosing a few signals, knowing what each one means, and deciding which ones are worth waking someone for.
The one-sentence version. Read a short list of broker and controller metrics, collect them over time, and alert on lost availability at once and on lost redundancy during working hours.
Mental model
Sort every signal into one of three levels. The level decides the response.
| Level | Meaning | Examples | Response |
|---|---|---|---|
| Availability lost | Some clients are failing now. | Offline partitions. Partitions below the in-sync minimum. No active controller. | Page a person. |
| Redundancy lost | Everything works, with less protection. | Under-replicated partitions. A broker down. A fenced broker. | A ticket; fix in hours. |
| Capacity shrinking | Something will run out. | Disk filling. Request threads busy. Lag growing. | A ticket; fix in days. |
Use the tiles at the top of the page. In each situation, find the first tile that turned red and decide its level.
Two sources of numbers
- Inside the broker. Each broker publishes hundreds of metrics through the Java management interface, JMX. These describe what that broker sees and does: its request times, its thread use, the partitions it leads.
- From outside. An exporter connects like a client and asks the cluster about topics, partitions and offsets. It sees the cluster as applications see it.
The two can give different numbers for what sounds like the same thing. In the lab, with one broker stopped, the exporter counted 57 under-replicated partitions for the cluster, and broker 1's own metric said 31. Both were right: a broker counts only the partitions it leads.
How it works internally
Metric names
A broker metric has a domain, a type and a name, written like kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions. Some have more labels, such as the request type. Each has one or more attributes: a simple Value, or for rates and timings a Count, rates over 1, 5 and 15 minutes, and percentiles.
The signals, by their real names
Every name in this table was read from a lab broker with kafka-jmx.sh. The value column shows what a healthy lab cluster returned.
| Metric | Healthy value seen | Meaning |
|---|---|---|
kafka.controller:type=KafkaController,name=OfflinePartitionsCount | 0 | Partitions with no leader. |
kafka.controller:type=KafkaController,name=ActiveControllerCount | 0 on broker 1; 1 on the active controller | 1 on exactly one node. Add it up over all controllers: the sum must be 1. |
kafka.controller:type=KafkaController,name=FencedBrokerCount | 0 | Registered brokers that may not lead. |
kafka.server:type=ReplicaManager,name=UnderMinIsrPartitionCount | 0 | Partitions this broker leads that are below the in-sync minimum. |
kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions | 0 | Partitions this broker leads with fewer in-sync replicas than replicas. |
kafka.server:type=ReplicaManager,name=IsrShrinksPerSec | Count 0 | How often in-sync sets shrink. A steady rate with no broker failure means a broker at its limit. |
kafka.server:type=KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercent | about 0.98 | Share of time the request threads are idle, from 0 to 1. Low means saturated. |
kafka.network:type=SocketServer,name=NetworkProcessorAvgIdlePercent | 0.48 during a test | The same for network threads. |
kafka.network:type=RequestMetrics,name=TotalTimeMs,request=Produce | percentiles | Time to serve a produce request. Use the 99th percentile. The same exists for request=FetchConsumer. unverified: only Produce was read |
kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec | a rate | Bytes written to this broker. |
kafka.server:type=raft-metrics | current-leader=2, current-epoch=1, current-state=follower | The controller quorum as this node sees it. A rising epoch means elections. |
From metrics to alerts
- A collector, here Prometheus, reads the numbers every few seconds and stores them with a timestamp.
- A rule is an expression over those numbers, for example "the sum of under-replicated partitions is above 0".
- A rule has a for time. The condition must hold that long before the alert fires. This filters out a restart that lasts ten seconds.
- A firing alert carries a severity label, which decides if it pages a person or opens a ticket.
- When the condition stops, the alert clears by itself.
Lag is measured elsewhere
Consumer lag is not a broker metric. It is the difference between a partition's end offset and a group's committed offset, so something must read both. The command-line tool does this (module 03). An exporter can do it too; the one in this lab could not read groups from Kafka 4.3, so the lab has no lag metric.
Every relevant config
These are the settings of the lab's monitoring stack, in docker-compose.yml and the folder monitoring/. All of them ran as written.
| Where | Setting | Lab value | What it does |
|---|---|---|---|
| Broker container | KAFKA_JMX_PORT | 9101 | Opens the broker's metrics port. |
| Broker container | KAFKA_JMX_HOSTNAME | localhost | The name the metrics interface announces. With localhost, read it from inside the same container. |
| Exporter | --kafka.server | the three brokers | Where the exporter connects, as a client. |
| Exporter | --kafka.version | 3.6.0 | The protocol version its client library uses. |
| Prometheus | scrape_interval | 5 seconds | How often numbers are read. 15 to 60 seconds is usual in production. |
| Prometheus | evaluation_interval | 5 seconds | How often rules are checked. |
| Alert rule | for | 15 to 30 seconds | How long a condition must hold before the alert fires. Short here so that the lab is quick; minutes in production. |
| Alert rule | severity label | page or ticket | Routes the alert to the right response. |
| Grafana | data source file | points at Prometheus | Lets you build dashboards at http://localhost:3005. The lab ships no ready-made dashboard. |
The alert rules in the lab
- alert: KafkaBrokerDown
expr: kafka_brokers < 3
for: 15s
labels: { severity: page }
- alert: KafkaUnderReplicatedPartitions
expr: sum(kafka_topic_partition_under_replicated_partition) > 0
for: 30s
labels: { severity: ticket }
- alert: KafkaPartitionBelowMinimum
expr: sum(kafka_topic_partition_in_sync_replica < 2) > 0
for: 15s
labels: { severity: page }
In the lab, "broker down" pages, so that you see a page-level alert fire. On a real cluster with three copies and a minimum of 2, one broker down is redundancy lost: a ticket, not a page. Decide severity from what the alert means for clients.
Failure scenarios
The monitoring tool cannot read the new Kafka version
- You observe
- In the lab, the exporter logged "Cannot get consumer group: kafka: broker not connected" every few seconds and published no lag metric. Topic and broker metrics worked.
- Cause
- The exporter's client library and Kafka 4.3 do not agree on a request. Kafka 4 removed old protocol versions. unverified: I did not find the exact cause
- Lesson
- Test your monitoring against a new Kafka version before the upgrade, with the same care as an application. A dashboard that goes blank during an upgrade is the worst moment to lose it.
An alert rule that can never fire
- What breaks
- A rule refers to a metric that does not exist. It evaluates to nothing, shows as "inactive", and looks healthy for ever.
- Evidence
- My first version of the lab's rules included a lag alert on a metric the exporter never published. Prometheus loaded it without complaint.
- Fix
- Test every alert by causing the fault, as this lab does. Add an alert for "metric absent".
Counting a per-broker metric as a cluster total
- What breaks
- A dashboard shows one broker's "under-replicated partitions" and someone reads it as the cluster's number. Or it shows
ActiveControllerCountof 0 from one node and someone declares an emergency. - Evidence
- Broker 1 reported 31 while the cluster had 57. Broker 1 reported an active controller count of 0 while node 2 was the active controller.
- Fix
- Add per-broker metrics up over all brokers. For the controller count, alert when the sum is not exactly 1.
Alerts that nobody believes
- What breaks
- Every rolling restart fires ten alerts. People mute them. The real alert, a week later, is muted too.
- Fix
- Give each rule a
fortime longer than a normal restart. Silence alerts during planned maintenance. Remove any alert that has never led to an action.
Watching the average
- What breaks
- Average produce time is 4 ms and looks fine while one request in a hundred takes two seconds.
- Fix
- Alert on the 99th percentile. The timing metrics publish percentiles for this reason.
The metrics port is open to everyone
- What breaks
- The Java management interface can do more than read numbers. An open, unauthenticated port is a way into the broker.
- Fix
- Keep it on a private network, or require authentication for it. The lab opens it only inside the Docker network.
Hands-on lab
Every command below was run on 5 October 2026 on the main lab cluster. The monitoring containers are in the main docker-compose.yml but start only when you ask for the monitoring profile.
Step 1. Start the cluster with monitoring
cd ~/Desktop/kafka-knowledge-base
docker compose --profile monitoring up -d
k() { docker exec -i kafka-1 /opt/kafka/bin/"$@"; }
B=kafka-1:19092Prometheus is at http://localhost:9095 and Grafana at http://localhost:3005.
Step 2. Read broker metrics by name
J() { docker exec -i kafka-1 timeout 20 /opt/kafka/bin/kafka-jmx.sh \
--jmx-url service:jmx:rmi:///jndi/rmi://localhost:9101/jmxrmi \
--one-time true --report-format properties --object-name "$1"; }
J 'kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions'
J 'kafka.controller:type=KafkaController,name=ActiveControllerCount'
J 'kafka.controller:type=KafkaController,name=OfflinePartitionsCount'
J 'kafka.server:type=KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercent'
J 'kafka.server:type=raft-metrics'kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions:Value=0 kafka.controller:type=KafkaController,name=ActiveControllerCount:Value=0 kafka.controller:type=KafkaController,name=OfflinePartitionsCount:Value=0 kafka.server:type=KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercent:FifteenMinuteRate=0.9778922787520811 kafka.server:type=raft-metrics:current-leader=2 kafka.server:type=raft-metrics:current-epoch=1 kafka.server:type=raft-metrics:current-state=follower
Look at the second line. The active controller count on node 1 is 0, and that is healthy: the quorum's leader is node 2, as the last lines say. Run the same command in kafka-2 and you get 1.
Step 3. Check the collector
curl -s http://localhost:9095/api/v1/targets | python3 -c "import sys,json;[print(t['labels']['job'],t['health']) for t in json.load(sys.stdin)['data']['activeTargets']]"
curl -s http://localhost:9095/api/v1/rules | python3 -c "import sys,json;[print(r['name'],r['state']) for g in json.load(sys.stdin)['data']['groups'] for r in g['rules']]"kafka up KafkaBrokerDown inactive KafkaUnderReplicatedPartitions inactive KafkaPartitionBelowMinimum inactive
The exporter publishes these metric names: kafka_brokers, kafka_topic_partitions, kafka_topic_partition_current_offset, kafka_topic_partition_oldest_offset, kafka_topic_partition_leader, kafka_topic_partition_leader_is_preferred, kafka_topic_partition_replicas, kafka_topic_partition_in_sync_replica and kafka_topic_partition_under_replicated_partition.
Step 4. Cause a fault and watch the alerts
k kafka-topics.sh --bootstrap-server $B --create --topic watched --partitions 6 --replication-factor 3
q() { curl -s --data-urlencode "query=$1" http://localhost:9095/api/v1/query; echo; }
q 'kafka_brokers'
docker stop kafka-3
# wait 50 seconds
q 'kafka_brokers'
q 'sum(kafka_topic_partition_under_replicated_partition)'
q 'ALERTS{alertstate="firing"}'
J 'kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions'before: kafka_brokers = 3
after: kafka_brokers = 2
under-replicated partitions, cluster total = 57
firing: KafkaBrokerDown, KafkaUnderReplicatedPartitions
broker 1's own UnderReplicatedPartitions = 31
Two alerts fired within 50 seconds. KafkaPartitionBelowMinimum did not, and that is correct: with one broker down, every partition still has two in-sync replicas. The cluster total, 57, counts internal topics too. Broker 1 reports only the 31 partitions it leads.
Step 5. Repair and watch them clear
docker start kafka-3
# wait 40 seconds
q 'kafka_brokers'
q 'sum(kafka_topic_partition_under_replicated_partition)'
q 'ALERTS{alertstate="firing"}'kafka_brokers = 3 under-replicated partitions = 0 firing: (nothing)
Step 6. Look in the browser, then stop
Open http://localhost:9095/alerts to see the three rules and their state. In Grafana, the Prometheus data source is already connected: make a panel with the query kafka_brokers and repeat step 4 to watch the line drop.
docker compose --profile monitoring downCommon mistakes and misconceptions
| People think | What is true |
|---|---|
| More dashboards mean better monitoring. | A few signals that people understand beat fifty that nobody reads. |
ActiveControllerCount of 0 on a node is an emergency. | It is 1 on one node and 0 on the others. The sum must be 1. |
| A broker's under-replicated count is the cluster's. | Each broker counts the partitions it leads. Add them up. |
| An alert rule that loads without error works. | A rule on a missing metric loads and never fires. Test by causing the fault. |
| Every alert should page. | Page for lost availability. Lost redundancy is a ticket. |
| Lag is a broker metric. | It needs the end offset and the group's committed offset, read by a tool or an exporter. |
| Monitoring tools keep working across Kafka upgrades. | The lab's exporter lost its group metrics on Kafka 4.3. |
Staff-level questions
You may have five alerts for a Kafka cluster. Which five?
Offline partitions above 0. The sum of active controllers not equal to 1. Partitions below the in-sync minimum above 0. Disk predicted to fill within a day. Consumer lag in time above each critical application's limit.
Those five mean clients are failing or are about to. Everything else, such as under-replicated partitions or thread saturation, is a ticket or a dashboard.
How do you alert on a disk before it is full?
Not on a level alone. Alert on the predicted time to full, from the growth rate over the last hours: a disk at 70% that grows 5% each hour is urgent, and a disk at 85% that has been flat for a month is not. Keep a simple level alert as a backstop.
What is the right way to measure consumer lag?
In time, not in records: how old is the oldest record the group has not processed? Record counts mean different things on a busy and a quiet topic. Compare the time with what the business accepts for that application, and with the topic's retention, because lag beyond retention is data loss.
Also alert when a partition's committed offset has not moved while its end offset has. That catches a stuck consumer before the backlog is large.
Produce latency at the 99th percentile doubled. How do you find the cause from metrics?
The request time metric splits into parts: time waiting in the queue, time on the local disk, time waiting for followers, time to send the answer. Queue time points to saturated request threads. Local time points to this broker's disk. Follower wait points to a slow follower or the network between brokers.
Then compare brokers. One broker different from the rest is a hardware or placement problem. All brokers together is load. unverified: the lab read only the total time
How do you keep monitoring correct through a Kafka upgrade?
Treat the monitoring stack as a client of the cluster with its own compatibility check. Before the upgrade, run the exporters and dashboards against a test cluster on the new version and cause each fault. Add alerts for missing metrics, so that a silent gap is itself an alert. Keep the command-line checks from module 09 in the runbook as a fallback that needs no extra tooling.
What do you show application teams, and what do you keep for the platform team?
Application teams: their own consumer lag in time, their produce error rate and latency, the size and retention of their topics, their quota use. Platform team: broker and controller health, disk, threads, replication.
Teams act on what they can change. A team that sees broker thread metrics can only worry about them.
Self-test
1. Name the three levels of signal and the response to each.
Availability lost: page. Redundancy lost: ticket, hours. Capacity shrinking: ticket, days.
2. Which metric counts partitions with no leader?
kafka.controller:type=KafkaController,name=OfflinePartitionsCount.
3. What should the active controller count add up to?
Exactly 1 over all controllers.
4. Why did broker 1 report 31 under-replicated partitions when the cluster had 57?
A broker counts only the partitions it leads.
5. What does a rule's for time do?
The condition must hold that long before the alert fires, which filters out short events such as a restart.
6. With one of three brokers down, which lab alert stayed quiet, and why?
KafkaPartitionBelowMinimum. Every partition still had two in-sync replicas.
7. How do you know an alert rule works?
Cause the fault and watch it fire.
8. Where does a lag number come from?
From the partition's end offset minus the group's committed offset, read by a tool or an exporter.