Monitoring and Operating Kafka Connect Clusters
Running Apache Kafka Connect in production demands disciplined observability, lifecycle management, and failure remediation. This guide targets platform and SRE engineers responsible for distributed Kafka Connect clusters. It covers the metrics, logs, REST operations, and automation patterns that keep data flowing reliably, with every MBean name, config key, and REST endpoint verified against the Apache Kafka and Confluent documentation. We assume familiarity with Kafka Connect Architecture and Deployment Modes, including distributed vs. standalone modes, the role of workers, and internal topic-based configuration storage.
Instrumenting the Connect Cluster
Kafka Connect exposes metrics via Java Management Extensions (JMX). The metric groups that matter operationally are worker health, rebalance behavior, and per-task throughput. The exact MBean names below come from the Connect metrics reference (KIP-196); using the wrong domain or attribute name is a common cause of silently broken dashboards.
Worker and Rebalance Metrics
Worker-level health and group-coordination behavior live in two separate MBeans. Note that rebalance timing is under connect-worker-rebalance-metrics, not a connect-coordinator-metrics bean — there is no such bean in Connect.
| MBean | Key Attributes |
|---|---|
kafka.connect:type=connect-worker-metrics |
connector-count, task-count, connector-startup-attempts-total, task-startup-failure-total, task-startup-success-total |
kafka.connect:type=connect-worker-rebalance-metrics |
rebalance-avg-time-ms, rebalance-max-time-ms, completed-rebalances-total, time-since-last-rebalance-ms, rebalancing |
java.lang:type=Memory |
HeapMemoryUsage, NonHeapMemoryUsage |
java.lang:type=GarbageCollector,name=* |
CollectionCount, CollectionTime |
A drop in task-count without a matching rise in completed-rebalances-total indicates tasks moving to FAILED rather than being reassigned. Frequent full GCs cause worker unresponsiveness, missed group heartbeats, and avoidable rebalances.
Connector and Task Metrics
| MBean | Key Attributes |
|---|---|
kafka.connect:type=connector-task-metrics,connector="{c}",task="{t}" |
status, running-ratio, pause-ratio, batch-size-avg, batch-size-max, offset-commit-avg-time-ms, offset-commit-failure-percentage |
kafka.connect:type=sink-task-metrics,connector="{c}",task="{t}" |
sink-record-read-rate, sink-record-send-rate, partition-count, put-batch-avg-time-ms, sink-record-lag-max |
kafka.connect:type=source-task-metrics,connector="{c}",task="{t}" |
source-record-poll-rate, source-record-write-rate, poll-batch-avg-time-ms |
The status attribute on connector-task-metrics is a string (running, paused, failed, unassigned, destroyed); alert when it is not running. For sink connectors, a persistent gap between sink-record-read-rate (records read from Kafka) and sink-record-send-rate (records sent downstream) signals an under-provisioned or stalled sink.
For end-to-end sink lag, sink-task-metrics exposes sink-record-lag-max (the maximum lag in records for any topic partition the task consumes). Cluster-wide consumer lag is still best confirmed out-of-band with kafka-consumer-groups, because the per-task gauge disappears when a task is FAILED:
kafka-consumer-groups --bootstrap-server kafka1:9092 \
--describe --group connect-pg-source-inventory
Connect names each sink connector’s consumer group connect-<connector-name>.
Exposing Metrics to Prometheus
There is no built-in Prometheus reporter in Apache Kafka Connect. The standard approach is the Prometheus JMX Exporter Java agent, attached via KAFKA_OPTS so it loads in the same JVM:
export KAFKA_OPTS="-javaagent:/opt/jmx_prometheus_javaagent.jar=8084:/opt/connect-jmx-exporter.yml"
The agent serves Prometheus metrics on port 8084; no remote JMX port or com.sun.management.jmxremote flags are required, since the agent reads MBeans in-process. A minimal exporter config:
rules:
- pattern: "kafka.connect<type=connect-worker-metrics><>(connector-count|task-count)"
name: kafka_connect_worker_$1
- pattern: "kafka.connect<type=connect-worker-rebalance-metrics><>(rebalance-avg-time-ms|completed-rebalances-total)"
name: kafka_connect_rebalance_$1
- pattern: "kafka.connect<type=connector-task-metrics, connector=(.+), task=(.+)><>(status|batch-size-avg|offset-commit-avg-time-ms)"
name: kafka_connect_task_$3
labels:
connector: "$1"
task: "$2"
- pattern: "kafka.connect<type=sink-task-metrics, connector=(.+), task=(.+)><>(sink-record-read-rate|sink-record-send-rate|sink-record-lag-max)"
name: kafka_connect_sink_$3
labels:
connector: "$1"
task: "$2"
The status attribute is a string, so JMX Exporter emits it as a labeled metric value of 1; alert on its presence/absence per state rather than a numeric threshold.
Alerting Rules
| Alert | Condition | For |
|---|---|---|
| Task failed | kafka_connect_task_status{status="failed"} > 0 |
2m |
| Sink lag high | kafka_connect_sink_sink_record_lag_max > 10000 |
5m |
| Task count drop | kafka_connect_worker_task_count < <expected> |
5m |
| Rebalance churn | rate(kafka_connect_rebalance_completed_rebalances_total[5m]) > 0.1 |
5m |
groups:
- name: kafka-connect
rules:
- alert: ConnectTaskFailed
expr: kafka_connect_task_status{status="failed"} > 0
for: 2m
labels:
severity: critical
annotations:
summary: "Connect task {{ $labels.connector }}/{{ $labels.task }} is FAILED"
- alert: ConnectSinkLagHigh
expr: kafka_connect_sink_sink_record_lag_max > 10000
for: 5m
labels:
severity: warning
annotations:
summary: "Sink connector {{ $labels.connector }} lag exceeds 10k records"
Pick the lag threshold from the connector’s own throughput, not a round number: if a sink sustains 2,000 records/s, a 10k backlog is ~5 s of catch-up and rarely worth a page, whereas the same 10k on a 50 records/s connector is over three minutes behind. Express the threshold as sink-record-lag-max > N * sink-record-send-rate where N is your tolerated seconds-behind.
Logging Configuration and Diagnostics
Apache Kafka migrated its logging backend from Log4j 1.x to Log4j2 in Kafka 4.0 (KIP-653); the default Connect config file became connect-log4j2.properties. Releases before 4.0 use Log4j 1.x with connect-log4j.properties. Match the syntax to your running version. (Confluent Platform ships a YAML variant, connect-log4j2.yaml.)
Routing Connector Logs (Log4j 1.x, Kafka < 4.0)
# connect-log4j.properties
log4j.rootLogger=INFO, stdout
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d{ISO8601} [%t] %-5p %c{2} - %m%n
log4j.logger.org.apache.kafka.connect.runtime.WorkerSinkTask=DEBUG
log4j.logger.org.apache.kafka.connect.runtime.WorkerSourceTask=DEBUG
For Kafka >= 4.0, the equivalent in connect-log4j2.properties:
# connect-log4j2.properties
appender.console.type = Console
appender.console.name = STDOUT
appender.console.layout.type = PatternLayout
appender.console.layout.pattern = %d{ISO8601} [%t] %-5p %c{2} - %m%n
rootLogger.level = INFO
rootLogger.appenderRef.console.ref = STDOUT
logger.sinktask.name = org.apache.kafka.connect.runtime.WorkerSinkTask
logger.sinktask.level = DEBUG
logger.sourcetask.name = org.apache.kafka.connect.runtime.WorkerSourceTask
logger.sourcetask.level = DEBUG
Dynamic Log Levels via REST
Change levels at runtime without restarting the worker (the admin/loggers endpoint exists since Kafka 2.4, KIP-495):
curl -s -X PUT -H "Content-Type: application/json" \
-d '{"level": "DEBUG"}' \
"http://connect-worker:8083/admin/loggers/org.apache.kafka.connect.runtime.WorkerSinkTask"
The response lists the loggers whose level changed. By default the change applies to the local worker only (scope Worker); pass ?scope=cluster (Kafka 3.7+, KIP-976) to propagate it to every worker via the config topic — a cluster-scoped request returns 204 No Content. Verify with:
curl -s "http://connect-worker:8083/admin/loggers/org.apache.kafka.connect.runtime.WorkerSinkTask"
Worker-scoped changes are in-memory and do not survive a restart; cluster-scoped changes persist in the config topic. Use cluster scope for a deliberate, lasting change and worker scope for a one-off debug session on a single misbehaving worker — then revert to INFO to avoid drowning the log pipeline, since DEBUG on WorkerSinkTask logs per-batch detail.
Log Patterns Worth Alerting On
Task threw an uncaught and unrecoverable exception(ERROR) — permanent task failure; the task moves toFAILEDand will not retry on its own.CommitFailedException(ERROR) — the consumer left the group during an offset commit, usually because processing exceededmax.poll.interval.ms; transient but worth tracking if frequent.RetriableException(WARN) repeated > 5 times in 10 minutes — a persistent downstream problem the connector keeps retrying.TimeoutExceptionduring offset commits — broker or network issue.
Correlate spikes in these against offset-commit-failure-percentage and put-batch-avg-time-ms in dashboards.
Connector Lifecycle Management
Treat connector configurations as code: version-control the JSON, review changes in PRs, and apply through CI/CD. Manual creation via the REST API or a UI does not scale or audit.
Example: Debezium PostgreSQL Source
{
"name": "pg-source-inventory",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"tasks.max": "1",
"database.hostname": "pg-primary.internal",
"database.port": "5432",
"database.user": "debezium",
"database.password": "${file:/etc/connect/secrets.properties:pg-password}",
"database.dbname": "inventory",
"topic.prefix": "dbserver1",
"table.include.list": "public.orders,public.customers",
"plugin.name": "pgoutput",
"slot.name": "debezium_inventory",
"publication.autocreate.mode": "filtered",
"key.converter": "org.apache.kafka.connect.json.JsonConverter",
"value.converter": "org.apache.kafka.connect.json.JsonConverter",
"key.converter.schemas.enable": "false",
"value.converter.schemas.enable": "false",
"transforms": "unwrap",
"transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
"transforms.unwrap.drop.tombstones": "false"
}
}
topic.prefix is the modern Debezium 2.x key; older database.server.name is deprecated. A PostgreSQL CDC source runs a single task (tasks.max: 1) because the logical replication slot is read sequentially.
Idempotent Deployment Script
PUT /connectors/{name}/config creates the connector if it does not exist and updates it otherwise, so a probe-then-branch is optional but keeps logs clear:
#!/bin/bash
set -euo pipefail
CONNECT_URL="http://connect-cluster:8083"
CONNECTOR_NAME="pg-source-inventory"
CONFIG_FILE="connectors/${CONNECTOR_NAME}.json"
# Apply just the "config" object via the idempotent PUT endpoint.
jq '.config' "${CONFIG_FILE}" | curl -s -X PUT \
-H "Content-Type: application/json" \
-d @- \
"${CONNECT_URL}/connectors/${CONNECTOR_NAME}/config" | jq .
PUT /connectors/{name}/config expects the bare config object (the inner {...} of config), not the wrapper containing name/config, which is why the script extracts .config with jq.
Externalizing Secrets with Config Providers
Connect resolves ${provider:path:key} placeholders at task startup via pluggable ConfigProviders (KIP-297). The built-in FileConfigProvider needs no extra dependencies:
# worker config
config.providers=file
config.providers.file.class=org.apache.kafka.common.config.provider.FileConfigProvider
"database.password": "${file:/etc/connect/secrets.properties:pg-password}"
Apache Kafka ships FileConfigProvider and DirectoryConfigProvider; there is no built-in Vault provider. For HashiCorp Vault, install a community plugin such as jcustenborder/kafka-config-provider-vault — note the package is kafka.config.vault, not kafka.connect.vault:
config.providers=vault
config.providers.vault.class=com.github.jcustenborder.kafka.config.vault.VaultConfigProvider
config.providers.vault.param.vault.address=https://vault.internal:8200
config.providers.vault.param.vault.token=${file:/etc/connect/vault-token.properties:token}
Confluent also publishes a Vault provider; pick one implementation and follow its documented reference syntax, since the placeholder format differs between plugins.
Error Handling and Dead Letter Queues
By default a sink connector is intolerant of bad records: errors.tolerance defaults to none, so the first record that fails to convert, deserialize, or pass through a transform throws the task straight to FAILED. One malformed message — a null Avro payload, a schema the converter cannot read, a value an SMT rejects — then stops the entire partition. KIP-298 added per-record error tolerance and a dead letter queue (DLQ) so a poison record is routed aside instead of halting the pipeline:
# Sink connector config
errors.tolerance=all
errors.deadletterqueue.topic.name=connect-dlq-inventory
errors.deadletterqueue.topic.replication.factor=3
errors.deadletterqueue.context.headers.enable=true
errors.log.enable=true
errors.log.include.messages=false
errors.retry.timeout=0
errors.retry.delay.max.ms=60000
The defaults and behavior that bite operators:
| Key | Default | Operational meaning |
|---|---|---|
errors.tolerance |
none |
none fails the task on the first error; all skips/DLQs the record and continues |
errors.deadletterqueue.topic.name |
"" (disabled) |
Empty means failed records are silently dropped when tolerance=all — always set this |
errors.deadletterqueue.topic.replication.factor |
3 |
Lower it on a small dev cluster or topic creation fails |
errors.deadletterqueue.context.headers.enable |
false |
When true, the original topic/partition/offset and exception are written as record headers — essential for triage |
errors.log.enable |
false |
Logs failed-record context to the worker log |
errors.log.include.messages |
false |
Logs the record key/value too — keep false if records carry PII |
errors.retry.timeout |
0 |
Total time to retry a failed operation; 0 means no retry, -1 means retry forever |
errors.retry.delay.max.ms |
60000 |
Cap on the exponential backoff between retries |
The combination to understand: the DLQ and the retry/log settings apply only to converter and transform errors and (for sinks) errors the framework surfaces from put() — they do not catch logic errors inside a connector’s own delivery code, which still fail the task. The biggest silent-data-loss footgun is errors.tolerance=all with no DLQ topic set: records that fail conversion vanish with no trace. Always pair tolerance=all with a DLQ.
Detect the failure mode in metrics rather than waiting for downstream to notice missing data. Watch the DLQ topic’s own production rate — a non-zero rate() on records into connect-dlq-inventory means records are being diverted — and alert on it; a connector that is running while quietly DLQ-ing every record looks healthy on status alone.
Handling Rebalances and Task Failures
Connect distributes tasks across workers. Since version 2.3.0, distributed workers use incremental cooperative rebalancing by default (KIP-415): a worker joining or leaving only reassigns the affected connectors and tasks instead of stopping the world. This is built into the Connect group protocol — it is not the consumer partition.assignment.strategy/CooperativeStickyAssignor setting, which governs ordinary consumer groups, not Connect’s worker group. There is no config to opt into it; it is the default behavior.
Tuning Rebalance Behavior
# Worker config
group.id=connect-cluster
session.timeout.ms=30000
heartbeat.interval.ms=10000
# How long a leader waits before reassigning a departed worker's tasks.
# Lets a worker restart (e.g. a rolling upgrade) without churning assignments.
scheduled.rebalance.max.delay.ms=300000
scheduled.rebalance.max.delay.ms is a real Connect worker config (default 300000, i.e. 5 minutes) introduced with incremental cooperative rebalancing. Raise it to tolerate longer restarts; lower it to recover from a lost worker sooner at the cost of more reassignments.
Restarting Failed Connectors and Tasks
Connect does not automatically restart a task that has moved to FAILED. The key gotcha: POST /connectors/{name}/restart without query parameters restarts only the Connector instance, not its tasks — so a failed task stays failed. KIP-745 (Kafka 3.0) added includeTasks and onlyFailed to restart failed tasks in one call.
Before blindly restarting, read the failure. The status endpoint returns a trace field on any FAILED connector or task containing the exception stack trace — capturing it is what lets the escalation step decide whether a restart can possibly help:
#!/bin/bash
set -euo pipefail
CONNECT_URL="http://connect-cluster:8083"
# Find connectors with any FAILED connector or task instance, and log the trace.
curl -s "${CONNECT_URL}/connectors?expand=status" | jq -r '
to_entries[]
| select(
.value.status.connector.state == "FAILED"
or (.value.status.tasks[]?.state == "FAILED")
)
| .key as $name
| ( [ .value.status.connector, .value.status.tasks[]? ]
| map(select(.state == "FAILED"))[]
| "\($name)\t\(.id // "connector")\t\(.trace | split("\n")[0])" )' \
| tee /var/log/connect-failures.tsv \
| cut -f1 | sort -u | while read -r connector; do
echo "Restarting failed instances of: ${connector}"
# includeTasks=true + onlyFailed=true restarts only the FAILED task/connector instances.
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"${CONNECT_URL}/connectors/${connector}/restart?includeTasks=true&onlyFailed=true"
done
A successful restart returns 202 Accepted; restarting during an active rebalance returns 409 Conflict, so the loop should treat 409 as retryable. On Kafka < 3.0, restart each failed task individually instead:
curl -s -X POST "${CONNECT_URL}/connectors/${connector}/tasks/0/restart"
Escalation Policy
The first line of the captured trace usually classifies the failure, and the class determines whether a restart is futile:
| Trace contains | Likely cause | Will a restart help? |
|---|---|---|
SerializationException / DataException |
Schema break or poison record | No — fix the schema or enable a DLQ |
ConnectException: ... too many tasks |
tasks.max exceeds source partitions |
No — reconfigure |
ConnectException wrapping a SQL/IO error |
Transient downstream outage | Often yes, once the dependency recovers |
OutOfMemoryError |
Heap or batch sizing | No — resize before restart |
Track restart count per connector. If a connector accumulates more than 3 restarts in an hour, stop auto-restarting and page on-call — repeated failures usually mean a poison record, a schema break, or a dead upstream, none of which a restart fixes. A Kubernetes CronJob running the script every few minutes is a common pattern; integrate the escalation with PagerDuty or Opsgenie.
Capacity Planning and Performance Tuning
Worker Sizing
Each task runs in its own thread; total in-flight concurrency is the sum of running tasks (bounded by each connector’s tasks.max) across the cluster. Scale out by adding workers — incremental cooperative rebalancing redistributes tasks with minimal disruption.
# Workers above 80% CPU
rate(process_cpu_seconds_total{job="kafka-connect"}[5m]) > 0.8
Two ceilings cap useful parallelism regardless of worker count. A sink connector cannot run more useful tasks than the source topic has partitions — extra tasks sit idle with zero assigned partitions. A source connector is capped by what the connector itself reports: a Debezium CDC source reads one replication slot sequentially, so tasks.max above 1 is wasted. Size tasks.max to min(target parallelism, partition count) for sinks, and to the connector’s documented maximum for sources.
Sink Connector Tuning
Client settings for a sink connector’s embedded consumer go in the connector config with the consumer.override. prefix (KIP-458, Kafka 2.3+), and require the worker to allow it via connector.client.config.override.policy=All (or Principal). The same keys without a prefix in the worker config (consumer.max.poll.records) set the cluster-wide default.
| Connector key | Default | Suggested | Notes |
|---|---|---|---|
consumer.override.max.poll.records |
500 | 500–5000 | Larger batches raise throughput and per-batch latency |
consumer.override.max.poll.interval.ms |
300000 | >= worst-case batch processing time | Exceeding it triggers CommitFailedException and a rebalance |
consumer.override.fetch.min.bytes |
1 | up to 1048576 (1 MB) | Fewer, larger fetches under low throughput |
consumer.override.fetch.max.wait.ms |
500 | 500 | Caps how long a fetch waits to reach fetch.min.bytes |
The max.poll.records/max.poll.interval.ms pair is the one to get right together: raising max.poll.records for throughput also raises the time to process one batch, and if that processing time can exceed max.poll.interval.ms the consumer is evicted mid-batch, the offset commit fails, and the task rebalances — often in a loop. Set max.poll.interval.ms from a measured worst-case batch time with margin, not the 5-minute default, whenever you raise max.poll.records past a few thousand.
Source Connector Tuning (Debezium)
| Parameter | Default | Suggested | Notes |
|---|---|---|---|
max.batch.size |
2048 | 1024–4096 | Records per poll batch; memory vs. throughput |
max.queue.size |
8192 | >= 2× max.batch.size |
In-memory buffer between the DB reader and Kafka producer |
poll.interval.ms |
1000 | 500–1000 | Wait when no new change events are available |
These are Debezium connector properties; confirm exact defaults against your connector version, since they drift between releases.
Schema Registry Caching
When using Avro or Protobuf converters with Schema Registry, schema lookups add latency. Size the in-memory cache so steady-state operation never re-fetches:
key.converter=io.confluent.connect.avro.AvroConverter
value.converter=io.confluent.connect.avro.AvroConverter
key.converter.schema.registry.url=http://schema-registry:8081
value.converter.schema.registry.url=http://schema-registry:8081
key.converter.schema.registry.cache.capacity=1000
value.converter.schema.registry.cache.capacity=1000
The cache holds distinct schema IDs, so size it above the number of schema versions in flight, not the record rate — under the default capacity of 1000, a topic cycling through more than 1,000 schema versions thrashes and re-fetches on a cache miss, adding a Registry round-trip to the hot path. Incompatible schema changes surface as serialization failures that fail the task. Enforce compatibility checks in CI/CD before deploying connector or producer changes — see Schema Evolution and Compatibility.
Backup, Disaster Recovery, and Upgrades
Internal Topics
Distributed Connect persists all cluster state in three compacted internal topics:
| Topic | Contents |
|---|---|
connect-configs |
Connector configurations |
connect-offsets |
Source-connector offsets (sink connectors commit to the regular __consumer_offsets) |
connect-status |
Connector and task status transitions |
A clarification worth internalizing: only source offsets live in connect-offsets. Sink connectors are ordinary consumers and commit their progress to __consumer_offsets under the connect-<connector> group, which is why sink recovery is governed by consumer offset retention, not these topics.
Back up connect-configs (the only one you cannot reconstruct from Git if you practice GitOps):
kafka-console-consumer --bootstrap-server kafka1:9092 \
--topic connect-configs \
--from-beginning \
--timeout-ms 10000 \
--property print.key=true \
--property print.value=true \
> connect-configs-backup.txt
Without --timeout-ms, kafka-console-consumer blocks indefinitely after draining the topic. The output is line-delimited key\tvalue, not valid JSON — restore by replaying, or simply re-apply connector configs from version control.
Disaster Recovery
- Ensure the Kafka cluster is healthy and the three internal topics exist (same names,
cleanup.policy=compact). - Re-apply connector configs from Git (preferred) or restore
connect-configs/connect-offsetsfrom backup. - Start workers with the original
group.id. - Workers read configs and resume sources from
connect-offsets; sinks resume from their consumer group offsets.
The recovery RPO differs by connector type and is easy to get wrong: a source connector resumes from connect-offsets, so restoring a stale copy of that topic re-reads — and re-emits — every change since the snapshot (at-least-once, with duplicates downstream). A sink resumes from __consumer_offsets; if those are lost or expired, recovery falls back to auto.offset.reset and can silently skip or replay data. Back up connect-offsets on the same cadence as connect-configs, and confirm __consumer_offsets retention (offsets.retention.minutes) outlasts your worst-case outage so a long incident does not expire a sink’s progress.
Rolling Upgrades
Connect supports rolling upgrades; scheduled.rebalance.max.delay.ms is what makes a one-worker-at-a-time restart cheap, since the leader holds the departed worker’s tasks rather than reshuffling immediately.
- Stop one worker.
- Upgrade binaries and connector plugins.
- Restart it and wait for it to rejoin (
rebalancingreturns tofalse). - Watch
connect-worker-rebalance-metricsrebalance-avg-time-msandcompleted-rebalances-totalfor stability before moving on. - Repeat per worker.
Pre-Upgrade Validation
- Test in staging with a representative workload.
- The public Connect plugin API (
SourceTask.poll(),SinkTask.put()) is stable, but connectors that reach into framework internals can break across versions. - Check each connector’s release notes for compatibility with the target Kafka version.
Runbook
Maintain a runbook in the same repository as your connector configs:
- Topology: workers, brokers, Schema Registry endpoints.
- Connector inventory: name, class,
tasks.max, owner. - Recovery procedures with the exact, tested commands for restart, restore, and scale-out.
Automate provisioning with Terraform or Ansible so the documented state and the running state cannot drift.