This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 8302ba498ae Add a DagRun and TaskInstance event listener under the 
Kafka provider (#68082)
8302ba498ae is described below

commit 8302ba498aeb46d5f715389cfd51fb0461481704
Author: Christos Bisias <[email protected]>
AuthorDate: Mon Jul 13 13:58:45 2026 +0300

    Add a DagRun and TaskInstance event listener under the Kafka provider 
(#68082)
---
 airflow-core/tests/integration/otel/test_otel.py   | 200 +-------
 .../core_api/routes/public/test_plugins.py         |  15 +-
 .../tests/unit/plugins/test_plugins_manager.py     |   2 +-
 .../tests_common/test_utils/integration_setup.py   | 201 ++++++++
 providers/apache/kafka/docs/configurations-ref.rst | 106 ++++
 providers/apache/kafka/docs/index.rst              |   1 +
 providers/apache/kafka/provider.yaml               | 126 +++++
 providers/apache/kafka/pyproject.toml              |   3 +
 .../providers/apache/kafka/get_provider_info.py    | 104 ++++
 .../airflow/providers/apache/kafka/hooks/base.py   |  16 +
 .../providers/apache/kafka/hooks/consume.py        |   7 +-
 .../providers/apache/kafka/plugins/__init__.py     |  16 +
 .../providers/apache/kafka/plugins/listener.py     | 470 ++++++++++++++++++
 .../integration/apache/kafka/plugins/__init__.py   |  16 +
 .../apache/kafka/plugins/dags/__init__.py          |  16 +
 .../apache/kafka/plugins/dags/demo_dag.py          |  45 ++
 .../apache/kafka/plugins/test_listener.py          | 167 +++++++
 .../tests/unit/apache/kafka/hooks/test_base.py     |  29 ++
 .../tests/unit/apache/kafka/hooks/test_consume.py  |  28 +-
 .../tests/unit/apache/kafka/hooks/test_produce.py  |  16 +
 .../tests/unit/apache/kafka/plugins/__init__.py    |  16 +
 .../unit/apache/kafka/plugins/test_listener.py     | 538 +++++++++++++++++++++
 scripts/ci/docker-compose/integration-kafka.yml    |   4 +
 23 files changed, 1948 insertions(+), 194 deletions(-)

diff --git a/airflow-core/tests/integration/otel/test_otel.py 
b/airflow-core/tests/integration/otel/test_otel.py
index ec6a7a6c94c..39d7ee6bdab 100644
--- a/airflow-core/tests/integration/otel/test_otel.py
+++ b/airflow-core/tests/integration/otel/test_otel.py
@@ -16,34 +16,35 @@
 # under the License.
 from __future__ import annotations
 
-import json
 import logging
 import os
 import socket
 import subprocess
 import time
-from typing import Any
+from typing import TYPE_CHECKING, Any
 
 import pytest
 import requests
-from sqlalchemy import func, select
+from sqlalchemy import select
 
-from airflow._shared.timezones import timezone
-from airflow.dag_processing.bundles.manager import DagBundlesManager
-from airflow.dag_processing.dagbag import DagBag
-from airflow.models import DagRun
-from airflow.models.serialized_dag import SerializedDagModel
 from airflow.models.taskinstance import TaskInstance
-from airflow.serialization.definitions.dag import SerializedDAG
 from airflow.utils.session import create_session
 from airflow.utils.state import State
 
-from tests_common.test_utils.dag import create_scheduler_dag
+from tests_common.test_utils.integration_setup import (
+    serialize_and_get_dags,
+    start_scheduler,
+    terminate_process,
+    unpause_trigger_dag_and_get_run_id,
+    wait_for_dag_run,
+)
 from tests_common.test_utils.otel_utils import (
     dump_airflow_metadata_db,
     extract_metrics_from_output,
 )
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_1_PLUS
+
+if TYPE_CHECKING:
+    from airflow.serialization.definitions.dag import SerializedDAG
 
 log = logging.getLogger("integration.otel.test_otel")
 
@@ -86,62 +87,6 @@ def wait_for_otel_collector(host: str, port: int, timeout: 
int = 120) -> None:
     )
 
 
-def unpause_trigger_dag_and_get_run_id(dag_id: str, conf: dict | None = None) 
-> str:
-    unpause_command = ["airflow", "dags", "unpause", dag_id]
-
-    # Unpause the dag using the cli.
-    subprocess.run(unpause_command, check=True, env=os.environ.copy())
-
-    execution_date = timezone.utcnow()
-    run_id = f"manual__{execution_date.isoformat()}"
-
-    trigger_command = [
-        "airflow",
-        "dags",
-        "trigger",
-        dag_id,
-        "--run-id",
-        run_id,
-        "--logical-date",
-        execution_date.isoformat(),
-    ]
-
-    if conf:
-        import json
-
-        trigger_command += ["--conf", json.dumps(conf)]
-
-    # Trigger the dag using the cli.
-    subprocess.run(trigger_command, check=True, env=os.environ.copy())
-
-    return run_id
-
-
-def wait_for_dag_run(dag_id: str, run_id: str, max_wait_time: int):
-    # max_wait_time, is the timeout for the DAG run to complete. The value is 
in seconds.
-    start_time = timezone.utcnow().timestamp()
-
-    while timezone.utcnow().timestamp() - start_time < max_wait_time:
-        with create_session() as session:
-            dag_run = session.scalar(
-                select(DagRun).where(
-                    DagRun.dag_id == dag_id,
-                    DagRun.run_id == run_id,
-                )
-            )
-
-            if dag_run is None:
-                time.sleep(5)
-                continue
-
-            dag_run_state = dag_run.state
-            log.debug("DAG Run state: %s.", dag_run_state)
-
-            if dag_run_state in [State.SUCCESS, State.FAILED]:
-                break
-    return dag_run_state
-
-
 def print_ti_output_for_dag_run(dag_id: str, run_id: str):
     breeze_logs_dir = "/root/airflow/logs"
 
@@ -189,19 +134,6 @@ class TestOtelIntegration:
     use_otel = os.getenv("use_otel", default="false")
     log_level = os.getenv("log_level", default="none")
 
-    scheduler_command_args = [
-        "airflow",
-        "scheduler",
-    ]
-
-    apiserver_command_args = [
-        "airflow",
-        "api-server",
-        "--port",
-        "8080",
-        "--daemon",
-    ]
-
     dags: dict[str, SerializedDAG] = {}
 
     @classmethod
@@ -251,67 +183,7 @@ class TestOtelIntegration:
         migrate_command = ["airflow", "db", "migrate"]
         subprocess.run(migrate_command, check=True, env=os.environ.copy())
 
-        cls.dags = cls.serialize_and_get_dags()
-
-    @classmethod
-    def serialize_and_get_dags(cls) -> dict[str, SerializedDAG]:
-        log.info("Serializing Dags from directory %s", cls.dag_folder)
-        # Load DAGs from the dag directory.
-        dag_bag = DagBag(dag_folder=cls.dag_folder)
-
-        dag_ids = dag_bag.dag_ids
-        assert len(dag_ids) == 1
-
-        dag_dict: dict[str, SerializedDAG] = {}
-        with create_session() as session:
-            for dag_id in dag_ids:
-                dag = dag_bag.get_dag(dag_id)
-                assert dag is not None, f"DAG with ID {dag_id} not found."
-                # Sync the DAG to the database.
-                if AIRFLOW_V_3_0_PLUS:
-                    from airflow.models.dagbundle import DagBundleModel
-
-                    count = session.scalar(
-                        select(func.count())
-                        .select_from(DagBundleModel)
-                        .where(DagBundleModel.name == "testing")
-                    )
-                    if count == 0:
-                        session.add(DagBundleModel(name="testing"))
-                        session.commit()
-                    SerializedDAG.bulk_write_to_db(
-                        bundle_name="testing", bundle_version=None, 
dags=[dag], session=session
-                    )
-                    dag_dict[dag_id] = create_scheduler_dag(dag)
-                else:
-                    dag.sync_to_db(session=session)
-                    dag_dict[dag_id] = dag
-                # Manually serialize the dag and write it to the db to avoid a 
db error.
-                if AIRFLOW_V_3_1_PLUS:
-                    from airflow.serialization.serialized_objects import 
LazyDeserializedDAG
-
-                    SerializedDagModel.write_dag(
-                        LazyDeserializedDAG.from_dag(dag), 
bundle_name="testing", session=session
-                    )
-                else:
-                    SerializedDagModel.write_dag(dag, bundle_name="testing", 
session=session)
-
-            session.commit()
-
-        TESTING_BUNDLE_CONFIG = [
-            {
-                "name": "testing",
-                "classpath": 
"airflow.dag_processing.bundles.local.LocalDagBundle",
-                "kwargs": {"path": f"{cls.dag_folder}", "refresh_interval": 1},
-            }
-        ]
-
-        os.environ["AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST"] = 
json.dumps(TESTING_BUNDLE_CONFIG)
-        # Initial add
-        manager = DagBundlesManager()
-        manager.sync_bundles_to_db()
-
-        return dag_dict
+        cls.dags = serialize_and_get_dags(dag_folder=cls.dag_folder)
 
     def dag_execution_for_testing_metrics(self, capfd):
         # Metrics.
@@ -327,7 +199,7 @@ class TestOtelIntegration:
         try:
             # Start the processes here and not as fixtures or in a common 
setup,
             # so that the test can capture their output.
-            scheduler_process, apiserver_process = 
self.start_scheduler(capture_output=True)
+            scheduler_process, apiserver_process = 
start_scheduler(capture_output=True)
 
             dag_id = "otel_test_dag"
 
@@ -357,13 +229,13 @@ class TestOtelIntegration:
         finally:
             # Terminate the processes.
 
-            self._terminate_process(scheduler_process)
+            terminate_process(scheduler_process)
             scheduler_status = scheduler_process.poll()
             assert scheduler_status is not None, (
                 "The scheduler_1 process status is None, which means that it 
hasn't terminated as expected."
             )
 
-            self._terminate_process(apiserver_process)
+            terminate_process(apiserver_process)
             apiserver_status = apiserver_process.poll()
             assert apiserver_status is not None, (
                 "The apiserver process status is None, which means that it 
hasn't terminated as expected."
@@ -491,7 +363,7 @@ class TestOtelIntegration:
         try:
             # Start the processes here and not as fixtures or in a common 
setup,
             # so that the test can capture their output.
-            scheduler_process, apiserver_process = self.start_scheduler()
+            scheduler_process, apiserver_process = start_scheduler()
 
             dag_id = "otel_test_dag"
 
@@ -521,13 +393,13 @@ class TestOtelIntegration:
                     dump_airflow_metadata_db(session)
 
             # Terminate the processes.
-            self._terminate_process(scheduler_process)
+            terminate_process(scheduler_process)
             scheduler_status = scheduler_process.poll()
             assert scheduler_status is not None, (
                 "The scheduler_1 process status is None, which means that it 
hasn't terminated as expected."
             )
 
-            self._terminate_process(apiserver_process)
+            terminate_process(apiserver_process)
             apiserver_status = apiserver_process.poll()
             assert apiserver_status is not None, (
                 "The apiserver process status is None, which means that it 
hasn't terminated as expected."
@@ -568,37 +440,3 @@ class TestOtelIntegration:
 
         nested = get_span_hierarchy()
         assert nested == expected_hierarchy
-
-    @staticmethod
-    def _terminate_process(proc: subprocess.Popen, timeout: int = 30) -> None:
-        # Grace period covers OTel atexit flush (force_flush default: 10s);
-        # SIGKILL is the fallback if the process is still alive after timeout.
-        proc.terminate()
-        try:
-            proc.wait(timeout=timeout)
-        except subprocess.TimeoutExpired:
-            proc.kill()
-            proc.wait()
-
-    def start_scheduler(self, capture_output: bool = False):
-        stdout = None if capture_output else subprocess.DEVNULL
-        stderr = None if capture_output else subprocess.DEVNULL
-
-        scheduler_process = subprocess.Popen(
-            self.scheduler_command_args,
-            env=os.environ.copy(),
-            stdout=stdout,
-            stderr=stderr,
-        )
-
-        apiserver_process = subprocess.Popen(
-            self.apiserver_command_args,
-            env=os.environ.copy(),
-            stdout=stdout,
-            stderr=stderr,
-        )
-
-        # Wait to ensure both processes have started.
-        time.sleep(10)
-
-        return scheduler_process, apiserver_process
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
index 6bca49d8052..e995cb4b137 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
@@ -34,7 +34,7 @@ class TestGetPlugins:
             # Filters
             (
                 {},
-                18,
+                19,
                 [
                     "InformaticaProviderPlugin",
                     "MetadataCollectionPlugin",
@@ -45,6 +45,7 @@ class TestGetPlugins:
                     "edge_executor",
                     "hitl_review",
                     "hive",
+                    "kafka_listener",
                     "plugin-a",
                     "plugin-b",
                     "plugin-c",
@@ -58,14 +59,14 @@ class TestGetPlugins:
             ),
             (
                 {"limit": 3, "offset": 3},
-                18,
+                19,
                 [
                     "business_day_window_plugin",
                     "databricks_workflow",
                     "decreasing_priority_weight_strategy_plugin",
                 ],
             ),
-            ({"limit": 1}, 18, ["InformaticaProviderPlugin"]),
+            ({"limit": 1}, 19, ["InformaticaProviderPlugin"]),
         ],
     )
     def test_should_respond_200(
@@ -158,17 +159,17 @@ class TestGetPlugins:
         # Verify warning was logged
         assert any("Skipping invalid plugin due to error" in rec.message for 
rec in caplog.records)
 
-        response = test_client.get("/plugins", params={"limit": 7, "offset": 
9})
+        response = test_client.get("/plugins", params={"limit": 8, "offset": 
9})
         assert response.status_code == 200
 
         body = response.json()
         plugins_page = body["plugins"]
 
-        # Even though limit=7, only 6 valid plugins should come back
-        assert len(plugins_page) == 7
+        # Invalid plugins are filtered before pagination, so the page is full.
+        assert len(plugins_page) == 8
         assert "test_plugin_invalid" not in [p["name"] for p in plugins_page]
 
-        assert body["total_entries"] == 18
+        assert body["total_entries"] == 19
 
 
 @skip_if_force_lowest_dependencies_marker
diff --git a/airflow-core/tests/unit/plugins/test_plugins_manager.py 
b/airflow-core/tests/unit/plugins/test_plugins_manager.py
index ce55db1e269..0dcac96fe0a 100644
--- a/airflow-core/tests/unit/plugins/test_plugins_manager.py
+++ b/airflow-core/tests/unit/plugins/test_plugins_manager.py
@@ -408,7 +408,7 @@ class TestPluginsManager:
         # Mock/skip loading from plugin dir
         with 
mock.patch("airflow.plugins_manager._load_plugins_from_plugin_directory", 
return_value=([], [])):
             plugins = plugins_manager._get_plugins()[0]
-        assert len(plugins) == 6
+        assert len(plugins) == 7
 
 
 class TestWindowPluginRegistration:
diff --git a/devel-common/src/tests_common/test_utils/integration_setup.py 
b/devel-common/src/tests_common/test_utils/integration_setup.py
new file mode 100644
index 00000000000..59476d8c4d0
--- /dev/null
+++ b/devel-common/src/tests_common/test_utils/integration_setup.py
@@ -0,0 +1,201 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+import logging
+import os
+import subprocess
+import time
+
+from sqlalchemy import func, select
+
+from airflow.dag_processing.bundles.manager import DagBundlesManager
+from airflow.dag_processing.dagbag import DagBag
+from airflow.models import DagRun
+from airflow.models.serialized_dag import SerializedDagModel
+from airflow.serialization.definitions.dag import SerializedDAG
+from airflow.utils.session import create_session
+from airflow.utils.state import State
+
+from tests_common.test_utils.dag import create_scheduler_dag
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_1_PLUS
+
+try:
+    from airflow.sdk import timezone
+except ImportError:
+    from airflow.utils import timezone  # type: ignore[no-redef,attr-defined]
+
+log = logging.getLogger(__name__)
+
+
+def unpause_trigger_dag_and_get_run_id(dag_id: str, conf: dict | None = None) 
-> str:
+    unpause_command = ["airflow", "dags", "unpause", dag_id]
+
+    # Unpause the dag using the cli.
+    subprocess.run(unpause_command, check=True, env=os.environ.copy())
+
+    execution_date = timezone.utcnow()
+    run_id = f"manual__{execution_date.isoformat()}"
+
+    trigger_command = [
+        "airflow",
+        "dags",
+        "trigger",
+        dag_id,
+        "--run-id",
+        run_id,
+        "--logical-date",
+        execution_date.isoformat(),
+    ]
+
+    if conf:
+        trigger_command += ["--conf", json.dumps(conf)]
+
+    # Trigger the dag using the cli.
+    subprocess.run(trigger_command, check=True, env=os.environ.copy())
+
+    return run_id
+
+
+def start_scheduler(capture_output: bool = False):
+    stdout = None if capture_output else subprocess.DEVNULL
+    stderr = None if capture_output else subprocess.DEVNULL
+
+    scheduler_command_args = [
+        "airflow",
+        "scheduler",
+    ]
+
+    apiserver_command_args = [
+        "airflow",
+        "api-server",
+        "--port",
+        "8080",
+        "--daemon",
+    ]
+
+    scheduler_process = subprocess.Popen(
+        scheduler_command_args,
+        env=os.environ.copy(),
+        stdout=stdout,
+        stderr=stderr,
+    )
+
+    apiserver_process = subprocess.Popen(
+        apiserver_command_args,
+        env=os.environ.copy(),
+        stdout=stdout,
+        stderr=stderr,
+    )
+
+    # Wait to ensure both processes have started.
+    time.sleep(10)
+
+    return scheduler_process, apiserver_process
+
+
+def serialize_and_get_dags(dag_folder) -> dict[str, SerializedDAG]:
+    log.info("Serializing Dags from directory %s", dag_folder)
+    # Load DAGs from the dag directory.
+    dag_bag = DagBag(dag_folder=dag_folder)
+
+    dag_ids = dag_bag.dag_ids
+
+    dag_dict: dict[str, SerializedDAG] = {}
+    with create_session() as session:
+        for dag_id in dag_ids:
+            dag = dag_bag.get_dag(dag_id)
+            assert dag is not None, f"DAG with ID {dag_id} not found."
+            # Sync the DAG to the database.
+            if AIRFLOW_V_3_0_PLUS:
+                from airflow.models.dagbundle import DagBundleModel
+
+                count = session.scalar(
+                    
select(func.count()).select_from(DagBundleModel).where(DagBundleModel.name == 
"testing")
+                )
+                if count == 0:
+                    session.add(DagBundleModel(name="testing"))
+                    session.commit()
+                SerializedDAG.bulk_write_to_db(
+                    bundle_name="testing", bundle_version=None, dags=[dag], 
session=session
+                )
+                dag_dict[dag_id] = create_scheduler_dag(dag)
+            else:
+                dag.sync_to_db(session=session)
+                dag_dict[dag_id] = dag
+            # Manually serialize the dag and write it to the db to avoid a db 
error.
+            if AIRFLOW_V_3_1_PLUS:
+                from airflow.serialization.serialized_objects import 
LazyDeserializedDAG
+
+                SerializedDagModel.write_dag(
+                    LazyDeserializedDAG.from_dag(dag), bundle_name="testing", 
session=session
+                )
+            else:
+                SerializedDagModel.write_dag(dag, bundle_name="testing", 
session=session)
+
+        session.commit()
+
+    TESTING_BUNDLE_CONFIG = [
+        {
+            "name": "testing",
+            "classpath": "airflow.dag_processing.bundles.local.LocalDagBundle",
+            "kwargs": {"path": f"{dag_folder}", "refresh_interval": 1},
+        }
+    ]
+
+    os.environ["AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST"] = 
json.dumps(TESTING_BUNDLE_CONFIG)
+    # Initial add
+    manager = DagBundlesManager()
+    manager.sync_bundles_to_db()
+
+    return dag_dict
+
+
+def wait_for_dag_run(dag_id: str, run_id: str, max_wait_time: int):
+    # max_wait_time, is the timeout for the DAG run to complete. The value is 
in seconds.
+    start_time = timezone.utcnow().timestamp()
+
+    while timezone.utcnow().timestamp() - start_time < max_wait_time:
+        with create_session() as session:
+            dag_run = session.scalar(
+                select(DagRun).where(
+                    DagRun.dag_id == dag_id,
+                    DagRun.run_id == run_id,
+                )
+            )
+
+            if dag_run is None:
+                time.sleep(5)
+                continue
+
+            dag_run_state = dag_run.state
+            log.debug("DAG Run state: %s.", dag_run_state)
+
+            if dag_run_state in [State.SUCCESS, State.FAILED]:
+                break
+    return dag_run_state
+
+
+def terminate_process(proc: subprocess.Popen, timeout: int = 30) -> None:
+    # SIGKILL is the fallback if the process is still alive after timeout.
+    proc.terminate()
+    try:
+        proc.wait(timeout=timeout)
+    except subprocess.TimeoutExpired:
+        proc.kill()
+        proc.wait()
diff --git a/providers/apache/kafka/docs/configurations-ref.rst 
b/providers/apache/kafka/docs/configurations-ref.rst
new file mode 100644
index 00000000000..80d0d373f8f
--- /dev/null
+++ b/providers/apache/kafka/docs/configurations-ref.rst
@@ -0,0 +1,106 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+ ..   http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+
+.. _configuration:kafka:
+
+.. include:: 
/../../../../devel-common/src/sphinx_exts/includes/providers-configurations-ref.rst
+.. include:: 
/../../../../devel-common/src/sphinx_exts/includes/sections-and-options.rst
+
+
+Highlighted configurations
+===========================
+
+The ``[kafka_listener]`` section configures the ``KafkaListenerPlugin``,
+which publishes Airflow DagRun and TaskInstance state-change events to a
+Kafka topic. DagRun and TaskInstance events are separated and enabled by
+distinct flags. Both event-type flags default to ``False``.
+
+.. _configuration_kafka_listener_activation:kafka:
+
+Activating the listener
+-----------------------
+
+To enable event publishing you need to
+
+  * enable at least one event-type flag
+  * point the listener at an Airflow Kafka connection via ``kafka_config_id``
+    (defaults to ``kafka_default``) that carries the broker address and any
+    other confluent-kafka client options on its extras
+  * have a pre-existing kafka topic
+
+.. code-block:: ini
+
+    [kafka_listener]
+    dag_run_events_enabled = True
+    task_instance_events_enabled = True
+    kafka_config_id = kafka_events
+    topic = airflow.events
+
+The connection's ``extra`` JSON accepts the full confluent-kafka client
+configuration — including SASL/TLS options and callbacks (e.g. ``error_cb``,
+``oauth_cb``) given as dotted-path strings, which are resolved to callables
+before the producer is built.
+
+.. code-block:: json
+
+    {
+        "bootstrap.servers": "broker:9092",
+        "security.protocol": "SASL_SSL",
+        "sasl.mechanisms": "OAUTHBEARER",
+        "oauth_cb": "my_company.auth.oauth_cb"
+    }
+
+Environment-variable equivalents:
+
+.. code-block:: ini
+
+    AIRFLOW__KAFKA_LISTENER__DAG_RUN_EVENTS_ENABLED=True
+    AIRFLOW__KAFKA_LISTENER__TASK_INSTANCE_EVENTS_ENABLED=True
+    AIRFLOW__KAFKA_LISTENER__KAFKA_CONFIG_ID=kafka_events
+    AIRFLOW__KAFKA_LISTENER__TOPIC=airflow.events
+
+The two event flags are independent, users can opt-in to get only DagRun
+event messages or only TaskInstance event messages or both.
+
+The topic must already exist on the broker, it's not auto-created. On a missing
+topic, broker connection failure, or any other producer init error, the 
listener
+doesn't fail, instead it logs a warning and retries the init after 
``topic_check_retry_interval``
+seconds (default ``60``). Once the topic is created on the broker the listener 
will pick it up.
+
+.. _configuration_kafka_listener_filtering:kafka:
+
+Filtering events
+----------------
+
+DagRun and TaskInstance events are filtered separately. Each filter is a
+comma-separated list of ``fnmatch`` glob patterns; an empty list means
+"allow all", and **deny takes precedence over allow**.
+
+.. code-block:: ini
+
+    [kafka_listener]
+    dag_run_dag_id_allowlist = sales_*,marketing_*
+    dag_run_dag_id_denylist = sales_internal_*
+
+    task_instance_dag_id_allowlist = sales_*
+    task_instance_dag_id_denylist =
+    task_instance_task_id_allowlist = load_*,extract_*
+    task_instance_task_id_denylist = *_cleanup
+
+TaskInstance events must pass *both* the dag-id and task-id filters.
+Mapped task instances share their parent's ``task_id``, so a single
+``task_id`` pattern covers every map index.
diff --git a/providers/apache/kafka/docs/index.rst 
b/providers/apache/kafka/docs/index.rst
index cee81d3a021..d9db2b715de 100644
--- a/providers/apache/kafka/docs/index.rst
+++ b/providers/apache/kafka/docs/index.rst
@@ -47,6 +47,7 @@
     :maxdepth: 1
     :caption: References
 
+    Configuration <configurations-ref>
     Python API <_api/airflow/providers/apache/kafka/index>
 
 .. toctree::
diff --git a/providers/apache/kafka/provider.yaml 
b/providers/apache/kafka/provider.yaml
index f43f76ce0b8..8e23df57309 100644
--- a/providers/apache/kafka/provider.yaml
+++ b/providers/apache/kafka/provider.yaml
@@ -129,3 +129,129 @@ connection-types:
 
 queues:
   - airflow.providers.apache.kafka.queues.kafka.KafkaMessageQueueProvider
+
+plugins:
+  - name: kafka_listener
+    plugin-class: 
airflow.providers.apache.kafka.plugins.listener.KafkaListenerPlugin
+
+config:
+  kafka_listener:
+    description: |
+      Settings for the Kafka listener that publishes Airflow DagRun and
+      TaskInstance state-change events to a Kafka topic.
+    options:
+      dag_run_events_enabled:
+        description: |
+          Publish DagRun state-change events (``dag_run.running``,
+          ``dag_run.success``, ``dag_run.failed``). When False the
+          DagRun listener is not registered.
+        version_added: 1.14.1
+        type: boolean
+        example: ~
+        default: "False"
+      task_instance_events_enabled:
+        description: |
+          Publish TaskInstance state-change events (``task_instance.running``,
+          ``task_instance.success``, ``task_instance.failed``,
+          ``task_instance.skipped``). When False the TaskInstance listener
+          is not registered.
+        version_added: 1.14.1
+        type: boolean
+        example: ~
+        default: "False"
+      kafka_config_id:
+        description: |
+          Airflow connection used to build the listener's Kafka producer.
+          When unset, the producer hook falls back to its default
+          connection (``kafka_default``).
+        version_added: 1.14.1
+        type: string
+        example: "kafka_default"
+        default: ""
+      topic:
+        description: |
+          Topic the listener publishes events to. The topic must already
+          exist on the broker; the listener will not auto-create it.
+        version_added: 1.14.1
+        type: string
+        example: ~
+        default: "airflow.events"
+      source:
+        description: |
+          Identifier added to every emitted message under the ``source``
+          field so consumers can distinguish Airflow installations that
+          share the same topic. When unset, falls back to the hostname
+          of the Airflow component that emits the event (scheduler,
+          worker, etc.).
+        version_added: 1.14.1
+        type: string
+        example: "af-prod-eu"
+        default: ""
+      dag_run_dag_id_allowlist:
+        description: |
+          Comma-separated glob patterns. When set, DagRun events are only
+          emitted for dag_ids matching at least one pattern. Empty = all dags.
+        version_added: 1.14.1
+        type: string
+        example: "demo_*,test_dag1"
+        default: ""
+      dag_run_dag_id_denylist:
+        description: |
+          Comma-separated glob patterns. DagRun events for dag_ids matching
+          any pattern are skipped. Deny takes precedence over allow.
+        version_added: 1.14.1
+        type: string
+        example: "demo_*"
+        default: ""
+      task_instance_dag_id_allowlist:
+        description: |
+          Comma-separated glob patterns. When set, TaskInstance events are
+          only emitted for dag_ids matching at least one pattern.
+          Empty = all dags.
+        version_added: 1.14.1
+        type: string
+        example: "demo_*,test_dag1"
+        default: ""
+      task_instance_dag_id_denylist:
+        description: |
+          Comma-separated glob patterns. TaskInstance events for dag_ids
+          matching any pattern are skipped. Deny takes precedence over allow.
+        version_added: 1.14.1
+        type: string
+        example: "demo_*"
+        default: ""
+      task_instance_task_id_allowlist:
+        description: |
+          Comma-separated glob patterns. When set, TaskInstance events are
+          only emitted for task_ids matching at least one pattern. Applied
+          in addition to ``task_instance_dag_id_allowlist`` — both must pass.
+          Mapped task instances share the same task_id so a single pattern
+          covers all map indices.
+        version_added: 1.14.1
+        type: string
+        example: "load_*,extract_*"
+        default: ""
+      task_instance_task_id_denylist:
+        description: |
+          Comma-separated glob patterns. TaskInstance events for task_ids
+          matching any pattern are skipped. Deny takes precedence over allow.
+        version_added: 1.14.1
+        type: string
+        example: "*_cleanup"
+        default: ""
+      topic_check_timeout:
+        description: |
+          How long (in seconds) each topic existence check is allowed
+          to block waiting for a response from the broker.
+        version_added: 1.14.1
+        type: integer
+        example: ~
+        default: "10"
+      topic_check_retry_interval:
+        description: |
+          How long (in seconds) to wait before retrying a topic check,
+          in case it failed.
+        version_added: 1.14.1
+        type: integer
+        example: ~
+        default: "60"
diff --git a/providers/apache/kafka/pyproject.toml 
b/providers/apache/kafka/pyproject.toml
index b43f5a60790..3c5ead9a4b8 100644
--- a/providers/apache/kafka/pyproject.toml
+++ b/providers/apache/kafka/pyproject.toml
@@ -130,6 +130,9 @@ apache-airflow-providers-standard = {workspace = true}
 [project.entry-points."apache_airflow_provider"]
 provider_info = 
"airflow.providers.apache.kafka.get_provider_info:get_provider_info"
 
+[project.entry-points."airflow.plugins"]
+kafka_listener = 
"airflow.providers.apache.kafka.plugins.listener:KafkaListenerPlugin"
+
 [tool.flit.module]
 name = "airflow.providers.apache.kafka"
 
diff --git 
a/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py
 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py
index 1a68a7b9167..d8c8510cb5d 100644
--- 
a/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py
+++ 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py
@@ -101,4 +101,108 @@ def get_provider_info():
             }
         ],
         "queues": 
["airflow.providers.apache.kafka.queues.kafka.KafkaMessageQueueProvider"],
+        "plugins": [
+            {
+                "name": "kafka_listener",
+                "plugin-class": 
"airflow.providers.apache.kafka.plugins.listener.KafkaListenerPlugin",
+            }
+        ],
+        "config": {
+            "kafka_listener": {
+                "description": "Settings for the Kafka listener that publishes 
Airflow DagRun and\nTaskInstance state-change events to a Kafka topic.\n",
+                "options": {
+                    "dag_run_events_enabled": {
+                        "description": "Publish DagRun state-change events 
(``dag_run.running``,\n``dag_run.success``, ``dag_run.failed``). When False 
the\nDagRun listener is not registered.\n",
+                        "version_added": "1.14.1",
+                        "type": "boolean",
+                        "example": None,
+                        "default": "False",
+                    },
+                    "task_instance_events_enabled": {
+                        "description": "Publish TaskInstance state-change 
events (``task_instance.running``,\n``task_instance.success``, 
``task_instance.failed``,\n``task_instance.skipped``). When False the 
TaskInstance listener\nis not registered.\n",
+                        "version_added": "1.14.1",
+                        "type": "boolean",
+                        "example": None,
+                        "default": "False",
+                    },
+                    "kafka_config_id": {
+                        "description": "Airflow connection used to build the 
listener's Kafka producer.\nWhen unset, the producer hook falls back to its 
default\nconnection (``kafka_default``).\n",
+                        "version_added": "1.14.1",
+                        "type": "string",
+                        "example": "kafka_default",
+                        "default": "",
+                    },
+                    "topic": {
+                        "description": "Topic the listener publishes events 
to. The topic must already\nexist on the broker; the listener will not 
auto-create it.\n",
+                        "version_added": "1.14.1",
+                        "type": "string",
+                        "example": None,
+                        "default": "airflow.events",
+                    },
+                    "source": {
+                        "description": "Identifier added to every emitted 
message under the ``source``\nfield so consumers can distinguish Airflow 
installations that\nshare the same topic. When unset, falls back to the 
hostname\nof the Airflow component that emits the event (scheduler,\nworker, 
etc.).\n",
+                        "version_added": "1.14.1",
+                        "type": "string",
+                        "example": "af-prod-eu",
+                        "default": "",
+                    },
+                    "dag_run_dag_id_allowlist": {
+                        "description": "Comma-separated glob patterns. When 
set, DagRun events are only\nemitted for dag_ids matching at least one pattern. 
Empty = all dags.\n",
+                        "version_added": "1.14.1",
+                        "type": "string",
+                        "example": "demo_*,test_dag1",
+                        "default": "",
+                    },
+                    "dag_run_dag_id_denylist": {
+                        "description": "Comma-separated glob patterns. DagRun 
events for dag_ids matching\nany pattern are skipped. Deny takes precedence 
over allow.\n",
+                        "version_added": "1.14.1",
+                        "type": "string",
+                        "example": "demo_*",
+                        "default": "",
+                    },
+                    "task_instance_dag_id_allowlist": {
+                        "description": "Comma-separated glob patterns. When 
set, TaskInstance events are\nonly emitted for dag_ids matching at least one 
pattern.\nEmpty = all dags.\n",
+                        "version_added": "1.14.1",
+                        "type": "string",
+                        "example": "demo_*,test_dag1",
+                        "default": "",
+                    },
+                    "task_instance_dag_id_denylist": {
+                        "description": "Comma-separated glob patterns. 
TaskInstance events for dag_ids\nmatching any pattern are skipped. Deny takes 
precedence over allow.\n",
+                        "version_added": "1.14.1",
+                        "type": "string",
+                        "example": "demo_*",
+                        "default": "",
+                    },
+                    "task_instance_task_id_allowlist": {
+                        "description": "Comma-separated glob patterns. When 
set, TaskInstance events are\nonly emitted for task_ids matching at least one 
pattern. Applied\nin addition to ``task_instance_dag_id_allowlist`` — both must 
pass.\nMapped task instances share the same task_id so a single pattern\ncovers 
all map indices.\n",
+                        "version_added": "1.14.1",
+                        "type": "string",
+                        "example": "load_*,extract_*",
+                        "default": "",
+                    },
+                    "task_instance_task_id_denylist": {
+                        "description": "Comma-separated glob patterns. 
TaskInstance events for task_ids\nmatching any pattern are skipped. Deny takes 
precedence over allow.\n",
+                        "version_added": "1.14.1",
+                        "type": "string",
+                        "example": "*_cleanup",
+                        "default": "",
+                    },
+                    "topic_check_timeout": {
+                        "description": "How long (in seconds) each topic 
existence check is allowed\nto block waiting for a response from the broker.\n",
+                        "version_added": "1.14.1",
+                        "type": "integer",
+                        "example": None,
+                        "default": "10",
+                    },
+                    "topic_check_retry_interval": {
+                        "description": "How long (in seconds) to wait before 
retrying a topic check,\nin case it failed.\n",
+                        "version_added": "1.14.1",
+                        "type": "integer",
+                        "example": None,
+                        "default": "60",
+                    },
+                },
+            }
+        },
     }
diff --git 
a/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/base.py 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/base.py
index b3b76ff1421..32ceef972b8 100644
--- a/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/base.py
+++ b/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/base.py
@@ -22,8 +22,13 @@ from typing import Any
 
 from confluent_kafka.admin import AdminClient
 
+from airflow.providers.common.compat.module_loading import import_string
 from airflow.providers.common.compat.sdk import BaseHook
 
+# librdkafka config options whose values are callables. They can be provided 
as dotted-path
+# strings on the connection extra and are resolved to callables before the 
client is built.
+CALLBACK_CONFIG_KEYS = ("error_cb", "throttle_cb", "stats_cb", "log_cb", 
"oauth_cb", "on_commit")
+
 # Amazon MSK bootstrap servers follow a predictable naming scheme, e.g.
 #   b-1.demo.abcde1.c2.kafka.us-east-1.amazonaws.com:9098            
(provisioned)
 #   boot-abcde1.c2.kafka-serverless.us-east-1.amazonaws.com:9098     
(serverless)
@@ -85,10 +90,18 @@ class KafkaBaseHook(BaseHook):
     def _get_client(self, config) -> Any:
         return AdminClient(config)
 
+    def _resolve_callbacks(self, config: dict[str, Any]) -> None:
+        """Resolve callback options provided as dotted-path strings into 
callables."""
+        for key in CALLBACK_CONFIG_KEYS:
+            value = config.get(key)
+            if isinstance(value, str):
+                config[key] = import_string(value)
+
     @cached_property
     def get_conn(self) -> Any:
         """Get the configuration object."""
         config = self.get_connection(self.kafka_config_id).extra_dejson
+        self._resolve_callbacks(config)
 
         if not (config.get("bootstrap.servers", None)):
             raise ValueError("config['bootstrap.servers'] must be provided.")
@@ -160,6 +173,9 @@ class KafkaBaseHook(BaseHook):
         """Test Connectivity from the UI."""
         try:
             config = self.get_connection(self.kafka_config_id).extra_dejson
+            # Resolve callbacks so that configured dotted-path strings
+            # (e.g. oauth_cb) are exercised by the test as well.
+            self._resolve_callbacks(config)
             t = AdminClient(config).list_topics(timeout=10)
             if t:
                 return True, "Connection successful."
diff --git 
a/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/consume.py 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/consume.py
index fa2f5844927..a420378be1e 100644
--- a/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/consume.py
+++ b/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/consume.py
@@ -21,7 +21,6 @@ from collections.abc import Sequence
 from confluent_kafka import Consumer, KafkaError
 
 from airflow.providers.apache.kafka.hooks.base import KafkaBaseHook
-from airflow.providers.common.compat.module_loading import import_string
 
 
 class KafkaAuthenticationError(Exception):
@@ -51,10 +50,10 @@ class KafkaConsumerHook(KafkaBaseHook):
 
     def _get_client(self, config) -> Consumer:
         config_shallow = config.copy()
-        if config.get("error_cb") is None:
+        # KafkaBaseHook resolves user-provided callbacks. Set a default if
+        # none was provided for ``error_cb``.
+        if config_shallow.get("error_cb") is None:
             config_shallow["error_cb"] = error_callback
-        else:
-            config_shallow["error_cb"] = import_string(config["error_cb"])
         return Consumer(config_shallow)
 
     def get_consumer(self) -> Consumer:
diff --git 
a/providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/__init__.py 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git 
a/providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/listener.py 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/listener.py
new file mode 100644
index 00000000000..342b37139a0
--- /dev/null
+++ 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/listener.py
@@ -0,0 +1,470 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import atexit
+import json
+import logging
+import os
+import time
+from datetime import datetime, timezone
+from fnmatch import fnmatch
+from functools import lru_cache
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.apache.kafka.version_compat import AIRFLOW_V_3_0_PLUS
+from airflow.providers.common.compat.sdk import AirflowPlugin, conf, hookimpl
+from airflow.utils.net import get_hostname
+
+if TYPE_CHECKING:
+    from confluent_kafka import Producer
+    from sqlalchemy.orm import Session
+
+    from airflow.models import DagRun, TaskInstance
+    from airflow.sdk.execution_time.task_runner import RuntimeTaskInstance
+    from airflow.utils.state import TaskInstanceState
+
+log = logging.getLogger(__name__)
+
+CONFIG_SECTION = "kafka_listener"
+SCHEMA_VERSION = 1
+
+
+@lru_cache(maxsize=1)
+def _dag_run_events_enabled() -> bool:
+    return conf.getboolean(CONFIG_SECTION, "dag_run_events_enabled", 
fallback="False")
+
+
+@lru_cache(maxsize=1)
+def _task_instance_events_enabled() -> bool:
+    return conf.getboolean(CONFIG_SECTION, "task_instance_events_enabled", 
fallback="False")
+
+
+@lru_cache(maxsize=1)
+def _get_kafka_config_id() -> str:
+    return conf.get(CONFIG_SECTION, "kafka_config_id", fallback="").strip()
+
+
+@lru_cache(maxsize=1)
+def _get_topic() -> str:
+    return conf.get(CONFIG_SECTION, "topic", fallback="airflow.events")
+
+
+@lru_cache(maxsize=1)
+def _get_source() -> str:
+    source_from_conf = conf.get(CONFIG_SECTION, "source", fallback="")
+    # Default to the current hostname, if un-set.
+    return source_from_conf or get_hostname()
+
+
+def _parse_filter_patterns_to_tuple(raw: str) -> tuple[str, ...]:
+    return tuple(p.strip() for p in raw.split(",") if p.strip())
+
+
+@lru_cache(maxsize=1)
+def _get_dag_run_dag_id_allowlist() -> tuple[str, ...]:
+    return _parse_filter_patterns_to_tuple(conf.get(CONFIG_SECTION, 
"dag_run_dag_id_allowlist", fallback=""))
+
+
+@lru_cache(maxsize=1)
+def _get_dag_run_dag_id_denylist() -> tuple[str, ...]:
+    return _parse_filter_patterns_to_tuple(conf.get(CONFIG_SECTION, 
"dag_run_dag_id_denylist", fallback=""))
+
+
+@lru_cache(maxsize=1)
+def _get_task_instance_dag_id_allowlist() -> tuple[str, ...]:
+    return _parse_filter_patterns_to_tuple(
+        conf.get(CONFIG_SECTION, "task_instance_dag_id_allowlist", fallback="")
+    )
+
+
+@lru_cache(maxsize=1)
+def _get_task_instance_dag_id_denylist() -> tuple[str, ...]:
+    return _parse_filter_patterns_to_tuple(
+        conf.get(CONFIG_SECTION, "task_instance_dag_id_denylist", fallback="")
+    )
+
+
+@lru_cache(maxsize=1)
+def _get_task_instance_task_id_allowlist() -> tuple[str, ...]:
+    return _parse_filter_patterns_to_tuple(
+        conf.get(CONFIG_SECTION, "task_instance_task_id_allowlist", 
fallback="")
+    )
+
+
+@lru_cache(maxsize=1)
+def _get_task_instance_task_id_denylist() -> tuple[str, ...]:
+    return _parse_filter_patterns_to_tuple(
+        conf.get(CONFIG_SECTION, "task_instance_task_id_denylist", fallback="")
+    )
+
+
+@lru_cache(maxsize=1)
+def _get_topic_check_timeout() -> int:
+    return conf.getint(CONFIG_SECTION, "topic_check_timeout", fallback="10")
+
+
+@lru_cache(maxsize=1)
+def _get_topic_check_retry_interval() -> int:
+    return conf.getint(CONFIG_SECTION, "topic_check_retry_interval", 
fallback="60")
+
+
+def _id_is_allowed(id_to_check: str, allowlist: tuple[str, ...], denylist: 
tuple[str, ...]) -> bool:
+    """Deny takes precedence; empty allowlist means 'allow all'."""
+    if denylist and any(fnmatch(id_to_check, id_pattern) for id_pattern in 
denylist):
+        return False
+    if allowlist and not any(fnmatch(id_to_check, id_pattern) for id_pattern 
in allowlist):
+        return False
+    return True
+
+
+def _dag_run_event_allowed(dag_id: str) -> bool:
+    return _id_is_allowed(
+        dag_id,
+        _get_dag_run_dag_id_allowlist(),
+        _get_dag_run_dag_id_denylist(),
+    )
+
+
+def _task_instance_event_allowed(dag_id: str, task_id: str) -> bool:
+    return _id_is_allowed(
+        dag_id,
+        _get_task_instance_dag_id_allowlist(),
+        _get_task_instance_dag_id_denylist(),
+    ) and _id_is_allowed(
+        task_id,
+        _get_task_instance_task_id_allowlist(),
+        _get_task_instance_task_id_denylist(),
+    )
+
+
+# Listener-owned producer and topic state for the lifetime of the process.
+# The producer is built once via KafkaProducerHook and kept for the process 
lifetime;
+# the topic flags track whether the topic exists on the broker and whether 
we're
+# currently in a back-off window after a failed topic check.
+_producer: Producer | None = None
+_topic_exists: bool = False
+_topic_check_retry_after: float = 0.0
+
+
+def _reset_state_after_fork() -> None:
+    """
+    Reset the producer and the topic state in forked children processes.
+
+    confluent_kafka.Producer is not fork-safe — its background thread and 
broker sockets
+    do not survive ``os.fork``. The child re-initializes its own producer on 
first use
+    and re-verifies the topic.
+    """
+    global _producer, _topic_exists, _topic_check_retry_after
+    _producer = None
+    _topic_exists = False
+    _topic_check_retry_after = 0.0
+
+
+os.register_at_fork(after_in_child=_reset_state_after_fork)
+
+
+def _get_producer() -> Producer | None:
+    """Build (once) and return the listener's producer, or ``None`` on init 
failure."""
+    global _producer
+    if _producer is not None:
+        return _producer
+
+    # Deferred import: KafkaProducerHook pulls in confluent_kafka.Producer, 
which is heavy
+    # and shouldn't be loaded by every Airflow process at plugin import time.
+    from airflow.providers.apache.kafka.hooks.produce import KafkaProducerHook
+
+    try:
+        hook_kwargs: dict[str, Any] = {}
+        if _get_kafka_config_id():
+            hook_kwargs["kafka_config_id"] = _get_kafka_config_id()
+        producer = KafkaProducerHook(**hook_kwargs).get_producer()
+    except Exception as exc:
+        log.warning("Kafka listener: failed to initialize producer (%s).", exc)
+        return None
+
+    atexit.register(_flush_producer_at_exit, producer)
+    _producer = producer
+    return _producer
+
+
+def _check_topic_exists() -> bool:
+    """
+    Verify the configured topic exists on the broker, with a 
retry-after-failure cooldown.
+
+    Once the topic has been confirmed, the result is kept for the process 
lifetime; until
+    then, failed checks are retried on a configured interval.
+    """
+    global _topic_exists, _topic_check_retry_after
+    if _topic_exists:
+        return True
+    if time.monotonic() < _topic_check_retry_after:
+        return False
+
+    producer = _get_producer()
+    if producer is None:
+        _topic_check_retry_after = time.monotonic() + 
_get_topic_check_retry_interval()
+        return False
+
+    try:
+        topics = 
producer.list_topics(timeout=_get_topic_check_timeout()).topics
+    except Exception as exc:
+        log.warning(
+            "Kafka listener: topic check failed (%s). Will retry after %ds.",
+            exc,
+            _get_topic_check_retry_interval(),
+        )
+        _topic_check_retry_after = time.monotonic() + 
_get_topic_check_retry_interval()
+        return False
+
+    if _get_topic() not in topics:
+        log.warning(
+            "Kafka listener: topic %r not found on the broker. Will retry 
after %ds. "
+            "Create the topic on the broker to enable publishing.",
+            _get_topic(),
+            _get_topic_check_retry_interval(),
+        )
+        _topic_check_retry_after = time.monotonic() + 
_get_topic_check_retry_interval()
+        return False
+
+    log.info(
+        "Kafka listener attached: pid=%s source=%r topic=%r",
+        os.getpid(),
+        _get_source(),
+        _get_topic(),
+    )
+    _topic_exists = True
+    return True
+
+
+def _flush_producer_at_exit(producer: Producer) -> None:
+    try:
+        producer.flush(5)
+    except Exception:
+        log.debug("Kafka listener: error flushing producer on exit", 
exc_info=True)
+
+
+def _on_delivery(err, _msg) -> None:
+    if err is None:
+        return
+    log.warning("Kafka listener: delivery failed: %s", err)
+    # If the broker or local metadata says the topic is gone, the confirmation 
cached at
+    # attach-time is stale. Flip it back so the next _check_topic_exists 
re-verifies with
+    # the broker, gated by the same cooldown used elsewhere. Lets the listener 
auto-recover
+    # if the topic gets recreated instead of requiring a component restart.
+    from confluent_kafka import KafkaError
+
+    if err.code() in (KafkaError.UNKNOWN_TOPIC_OR_PART, 
KafkaError._UNKNOWN_TOPIC):
+        global _topic_exists, _topic_check_retry_after
+        _topic_exists = False
+        _topic_check_retry_after = time.monotonic() + 
_get_topic_check_retry_interval()
+
+
+def _now_iso() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
+
+# DagRun
+def _produce_dr_message(event: str, dag_run: DagRun, msg: str) -> None:
+    dag_id = dag_run.dag_id
+    if not _dag_run_event_allowed(dag_id):
+        return
+    try:
+        _produce_message(
+            event,
+            dag_id,
+            dag_run.run_id,
+            _get_dr_payload(dag_run, msg),
+        )
+    except Exception:
+        log.exception("Kafka listener: %s failed", event)
+
+
+def _get_dr_payload(dag_run, msg) -> dict[str, Any]:
+    return {
+        "dag_id": getattr(dag_run, "dag_id", None),
+        "run_id": getattr(dag_run, "run_id", None),
+        "run_type": str(getattr(dag_run, "run_type", "") or ""),
+        "logical_date": str(getattr(dag_run, "logical_date", "") or ""),
+        "start_date": str(getattr(dag_run, "start_date", "") or ""),
+        "end_date": str(getattr(dag_run, "end_date", "") or ""),
+        "msg": msg or "",
+    }
+
+
+# TaskInstance
+def _produce_ti_message(
+    event: str,
+    previous_state: TaskInstanceState,
+    task_instance: RuntimeTaskInstance | TaskInstance,
+    error=None,
+) -> None:
+    dag_id = task_instance.dag_id
+    task_id = task_instance.task_id
+    if not _task_instance_event_allowed(dag_id, task_id):
+        return
+    try:
+        _produce_message(
+            event,
+            dag_id,
+            task_instance.run_id,
+            _get_ti_payload(task_instance, previous_state, error=error),
+        )
+    except Exception:
+        log.exception("Kafka listener: %s failed", event)
+
+
+def _get_ti_payload(ti, previous_state, error=None) -> dict[str, Any]:
+    payload: dict[str, Any] = {
+        "dag_id": getattr(ti, "dag_id", None),
+        "task_id": getattr(ti, "task_id", None),
+        "run_id": getattr(ti, "run_id", None),
+        "try_number": getattr(ti, "try_number", None),
+        "map_index": getattr(ti, "map_index", -1),
+        "previous_state": str(previous_state) if previous_state is not None 
else None,
+    }
+    if error is not None:
+        payload["error"] = str(error)
+    return payload
+
+
+def _produce_message(event: str, dag_id: str, run_id: str, payload: dict[str, 
Any]) -> None:
+    if not _dag_run_events_enabled() and not _task_instance_events_enabled():
+        return
+    if not _check_topic_exists():
+        return
+    producer = _get_producer()
+    if producer is None:
+        return
+
+    # When changing any field in the body or the payload,
+    # also update the SCHEMA_VERSION to avoid breaking someone's workflow.
+    body = {
+        "schema_version": SCHEMA_VERSION,
+        "source": _get_source(),
+        "event": event,
+        "timestamp": _now_iso(),
+        **payload,
+    }
+    # Key groups all events from a single DagRun (DagRun + TaskInstance) into 
the same
+    # Kafka partition. Within the partition, the order of the events is 
preserved,
+    # e.g. dag_run.running > ti.running > ti.success > dag_run.success.
+    key = f"{dag_id}/{run_id}".encode()
+    try:
+        producer.produce(
+            _get_topic(),
+            key=key,
+            value=json.dumps(body, default=str).encode("utf-8"),
+            on_delivery=_on_delivery,
+        )
+        producer.poll(0)
+    except Exception as ex:
+        log.warning("Kafka listener: failed to enqueue %s for %s/%s: %s", 
event, dag_id, run_id, ex)
+
+
+class DagRunListener:
+    """Publishes DagRun state-change event messages to Kafka."""
+
+    @hookimpl
+    def on_dag_run_running(self, dag_run: DagRun, msg: str):
+        _produce_dr_message("dag_run.running", dag_run, msg)
+
+    @hookimpl
+    def on_dag_run_success(self, dag_run: DagRun, msg: str):
+        _produce_dr_message("dag_run.success", dag_run, msg)
+
+    @hookimpl
+    def on_dag_run_failed(self, dag_run: DagRun, msg: str):
+        _produce_dr_message("dag_run.failed", dag_run, msg)
+
+
+class TaskListener:
+    """Publishes TaskInstance state-change event messages to Kafka."""
+
+    if AIRFLOW_V_3_0_PLUS:
+
+        @hookimpl
+        def on_task_instance_running(
+            self, previous_state: TaskInstanceState, task_instance: 
RuntimeTaskInstance
+        ):
+            _produce_ti_message("task_instance.running", previous_state, 
task_instance)
+
+        @hookimpl
+        def on_task_instance_success(
+            self, previous_state: TaskInstanceState, task_instance: 
RuntimeTaskInstance
+        ):
+            _produce_ti_message("task_instance.success", previous_state, 
task_instance)
+
+        @hookimpl
+        def on_task_instance_failed(
+            self,
+            previous_state: TaskInstanceState,
+            task_instance: RuntimeTaskInstance,
+            error: None | str | BaseException,
+        ):
+            _produce_ti_message("task_instance.failed", previous_state, 
task_instance, error=error)
+
+        @hookimpl
+        def on_task_instance_skipped(
+            self, previous_state: TaskInstanceState, task_instance: 
RuntimeTaskInstance
+        ):
+            _produce_ti_message("task_instance.skipped", previous_state, 
task_instance)
+    else:
+
+        @hookimpl
+        def on_task_instance_running(  # type: ignore[misc]
+            self, previous_state: TaskInstanceState, task_instance: 
TaskInstance, session: Session
+        ):
+            _produce_ti_message("task_instance.running", previous_state, 
task_instance)
+
+        @hookimpl
+        def on_task_instance_success(  # type: ignore[misc]
+            self, previous_state: TaskInstanceState, task_instance: 
TaskInstance, session: Session
+        ):
+            _produce_ti_message("task_instance.success", previous_state, 
task_instance)
+
+        @hookimpl
+        def on_task_instance_failed(  # type: ignore[misc]
+            self,
+            previous_state: TaskInstanceState,
+            task_instance: TaskInstance,
+            error: None | str | BaseException,
+            session: Session,
+        ):
+            _produce_ti_message("task_instance.failed", previous_state, 
task_instance, error=error)
+
+        @hookimpl
+        def on_task_instance_skipped(  # type: ignore[misc]
+            self, previous_state: TaskInstanceState, task_instance: 
TaskInstance, session: Session
+        ):
+            _produce_ti_message("task_instance.skipped", previous_state, 
task_instance)
+
+
+def _get_enabled_listeners() -> list[object]:
+    listeners: list[object] = []
+    if _dag_run_events_enabled():
+        listeners.append(DagRunListener())
+    if _task_instance_events_enabled():
+        listeners.append(TaskListener())
+    return listeners
+
+
+class KafkaListenerPlugin(AirflowPlugin):
+    """Publishes Airflow DagRun and TaskInstance event messages to a defined 
Kafka topic."""
+
+    name = "kafka_listener"
+    listeners = _get_enabled_listeners()
diff --git 
a/providers/apache/kafka/tests/integration/apache/kafka/plugins/__init__.py 
b/providers/apache/kafka/tests/integration/apache/kafka/plugins/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/providers/apache/kafka/tests/integration/apache/kafka/plugins/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git 
a/providers/apache/kafka/tests/integration/apache/kafka/plugins/dags/__init__.py
 
b/providers/apache/kafka/tests/integration/apache/kafka/plugins/dags/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ 
b/providers/apache/kafka/tests/integration/apache/kafka/plugins/dags/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git 
a/providers/apache/kafka/tests/integration/apache/kafka/plugins/dags/demo_dag.py
 
b/providers/apache/kafka/tests/integration/apache/kafka/plugins/dags/demo_dag.py
new file mode 100644
index 00000000000..4ecd9bec577
--- /dev/null
+++ 
b/providers/apache/kafka/tests/integration/apache/kafka/plugins/dags/demo_dag.py
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+
+from airflow import DAG
+from airflow.sdk import task
+
+logger = logging.getLogger(__name__)
+
+args = {
+    "owner": "airflow",
+    "start_date": datetime(2024, 9, 1),
+    "retries": 0,
+}
+
+
+@task
+def task1():
+    logger.info("running task1")
+
+
+with DAG(
+    "demo_dag",
+    default_args=args,
+    schedule=None,
+    catchup=False,
+) as dag:
+    task1()
diff --git 
a/providers/apache/kafka/tests/integration/apache/kafka/plugins/test_listener.py
 
b/providers/apache/kafka/tests/integration/apache/kafka/plugins/test_listener.py
new file mode 100644
index 00000000000..ce900f79b87
--- /dev/null
+++ 
b/providers/apache/kafka/tests/integration/apache/kafka/plugins/test_listener.py
@@ -0,0 +1,167 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+import logging
+import os
+import subprocess
+import time
+import uuid
+
+import pytest
+
+from airflow.providers.apache.kafka.hooks.client import KafkaAdminClientHook
+from airflow.providers.apache.kafka.hooks.consume import KafkaConsumerHook
+from airflow.sdk import Connection
+from airflow.utils.state import State
+
+from tests_common.test_utils.integration_setup import (
+    serialize_and_get_dags,
+    start_scheduler,
+    terminate_process,
+    unpause_trigger_dag_and_get_run_id,
+    wait_for_dag_run,
+)
+
+log = logging.getLogger(__name__)
+
+client_config = {
+    "bootstrap.servers": "broker:29092",
+    "group.id": "kafka-listener-integration-test",
+    "enable.auto.commit": False,
+    "auto.offset.reset": "earliest",
+}
+
+
+def _wait_for_assignment(consumer, timeout: float = 10.0) -> None:
+    """Block until the consumer has been assigned partitions by the broker."""
+    deadline = time.monotonic() + timeout
+    while not consumer.assignment():
+        consumer.poll(0.5)
+        if time.monotonic() > deadline:
+            raise TimeoutError("Kafka consumer did not receive partition 
assignment in time")
+
+
[email protected]("kafka")
[email protected]("postgres")
+class TestEventListener:
+    test_dir = os.path.dirname(os.path.abspath(__file__))
+    dag_folder = os.path.join(test_dir, "dags")
+
+    KAFKA_CONFIG_ID = "kafka_default"
+    # Use a unique topic per run to avoid errors on a re-run, in case
+    # the previous teardown hasn't finished with the topic deletion.
+    TOPIC = f"airflow.events.itest.{uuid.uuid4().hex[:8]}"
+
+    @classmethod
+    def setup_class(cls):
+        # The pytest plugin strips AIRFLOW__*__* env vars (including the JWT 
secret set
+        # by Breeze). Both the scheduler and api-server subprocesses must 
share the same
+        # secret; otherwise each generates its own random key and token 
verification fails.
+        os.environ["AIRFLOW__API_AUTH__JWT_SECRET"] = 
"test-secret-key-for-testing"
+        os.environ["AIRFLOW__API_AUTH__JWT_ISSUER"] = "airflow"
+
+        os.environ["AIRFLOW__SCHEDULER__STANDALONE_DAG_PROCESSOR"] = "False"
+        os.environ["AIRFLOW__SCHEDULER__PROCESSOR_POLL_INTERVAL"] = "2"
+
+        os.environ["AIRFLOW__CORE__DAGS_FOLDER"] = f"{cls.dag_folder}"
+        os.environ["AIRFLOW__CORE__LOAD_EXAMPLES"] = "False"
+        os.environ["AIRFLOW__CORE__UNIT_TEST_MODE"] = "False"
+
+        os.environ["AIRFLOW__KAFKA_LISTENER__DAG_RUN_EVENTS_ENABLED"] = "True"
+        os.environ["AIRFLOW__KAFKA_LISTENER__TASK_INSTANCE_EVENTS_ENABLED"] = 
"True"
+        os.environ["AIRFLOW__KAFKA_LISTENER__TOPIC"] = cls.TOPIC
+        os.environ["AIRFLOW__KAFKA_LISTENER__SOURCE"] = "dev-breeze"
+
+        # Shared Kafka connection: used by the listener producer, the topic
+        # creation/deletion, and the consumer.
+        kafka_default_conn = Connection(
+            conn_id=cls.KAFKA_CONFIG_ID,
+            conn_type="kafka",
+            extra=json.dumps(client_config),
+        )
+        os.environ["AIRFLOW_CONN_KAFKA_DEFAULT"] = kafka_default_conn.as_json()
+
+        reset_command = ["airflow", "db", "reset", "--yes"]
+        subprocess.run(reset_command, check=True, env=os.environ.copy())
+
+        migrate_command = ["airflow", "db", "migrate"]
+        subprocess.run(migrate_command, check=True, env=os.environ.copy())
+
+        cls.dags = serialize_and_get_dags(dag_folder=cls.dag_folder)
+
+        cls._admin = KafkaAdminClientHook(kafka_config_id=cls.KAFKA_CONFIG_ID)
+        # Tuple positions are (name, partition, replication).
+        cls._admin.create_topic([(cls.TOPIC, 1, 1)])
+
+    @classmethod
+    def teardown_class(cls):
+        try:
+            cls._admin.delete_topic([cls.TOPIC])
+            time.sleep(2)  # let the broker finish the async delete
+        except Exception as exc:
+            log.warning("teardown: failed to delete topic %r: %s", cls.TOPIC, 
exc)
+
+    @pytest.fixture
+    def start_components(self):
+        scheduler_process = None
+        apiserver_process = None
+        try:
+            scheduler_process, apiserver_process = start_scheduler()
+            yield scheduler_process, apiserver_process
+        finally:
+            terminate_process(scheduler_process)
+            terminate_process(apiserver_process)
+
+    @pytest.mark.execution_timeout(90)
+    def test_dag_run_produces_event_messages(self, start_components):
+        consumer = KafkaConsumerHook(topics=[self.TOPIC], 
kafka_config_id=self.KAFKA_CONFIG_ID).get_consumer()
+        _wait_for_assignment(consumer)
+
+        dag_id = "demo_dag"
+        run_id = unpause_trigger_dag_and_get_run_id(dag_id=dag_id)
+
+        state = wait_for_dag_run(dag_id=dag_id, run_id=run_id, 
max_wait_time=90)
+        assert state == State.SUCCESS, f"Dag run did not complete 
successfully. Final state: {state}."
+
+        expected_events = [
+            "dag_run.running",
+            "task_instance.running",
+            "task_instance.success",
+            "dag_run.success",
+        ]
+
+        try:
+            messages = consumer.consume(num_messages=10, timeout=2.0)
+        finally:
+            consumer.close()
+
+        topic_msg_bodies: list[dict] = []
+        for message in messages:
+            topic_msg_bodies.append(json.loads(message.value()))
+
+        assert len(topic_msg_bodies) == len(expected_events)
+
+        for body in topic_msg_bodies:
+            assert body["dag_id"] == dag_id
+            assert body["run_id"] == run_id
+            if body["event"].startswith("task_instance."):
+                assert body["task_id"] == "task1"
+
+        received_events = [body["event"] for body in topic_msg_bodies]
+        assert sorted(received_events) == sorted(expected_events)
diff --git a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_base.py 
b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_base.py
index 33a75d536a0..154588a66e8 100644
--- a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_base.py
+++ b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_base.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import json
 from unittest import mock
 from unittest.mock import MagicMock
 
@@ -65,6 +66,22 @@ class TestKafkaBaseHook:
         with pytest.raises(ValueError, match="must be provided"):
             hook.get_conn()
 
+    @mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
+    def test_callbacks_resolved_from_connection_dotted_path(self, 
mock_get_connection):
+        stats_cb = MagicMock()
+        mock_get_connection.return_value.extra_dejson = {
+            "bootstrap.servers": "localhost:9092",
+            "error_cb": "json.loads",
+            "oauth_cb": "json.dumps",
+            "stats_cb": stats_cb,
+        }
+
+        config = SomeKafkaHook().get_conn
+        assert config["error_cb"] is json.loads
+        assert config["oauth_cb"] is json.dumps
+        # Already-callable values on the connection are passed through 
unchanged.
+        assert config["stats_cb"] is stats_cb
+
     @mock.patch("airflow.providers.apache.kafka.hooks.base.AdminClient")
     @mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
     def test_test_connection(self, mock_get_connection, admin_client, hook):
@@ -90,6 +107,18 @@ class TestKafkaBaseHook:
         
mock_admin_instance.list_topics.assert_called_once_with(timeout=TIMEOUT)
         assert connection == (False, "Failed to establish connection.")
 
+    @mock.patch("airflow.providers.apache.kafka.hooks.base.AdminClient")
+    @mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
+    def test_test_connection_resolves_callbacks(self, mock_get_connection, 
admin_client, hook):
+        # ``test_connection`` runs the same callback resolution as the runtime 
clients,
+        # so dotted-path strings on the connection are exercised by the UI 
test too.
+        mock_get_connection.return_value.extra_dejson = {
+            "bootstrap.servers": "localhost:9092",
+            "error_cb": "json.loads",
+        }
+        assert hook.test_connection() == (True, "Connection successful.")
+        admin_client.assert_called_once_with({"bootstrap.servers": 
"localhost:9092", "error_cb": json.loads})
+
     @mock.patch("airflow.providers.apache.kafka.hooks.base.AdminClient")
     @mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
     def test_test_connection_exception(self, mock_get_connection, 
admin_client, hook):
diff --git 
a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_consume.py 
b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_consume.py
index 0bad37aeac3..4036980c626 100644
--- a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_consume.py
+++ b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_consume.py
@@ -25,7 +25,7 @@ from confluent_kafka.admin import AdminClient
 from airflow.models import Connection
 
 # Import Hook
-from airflow.providers.apache.kafka.hooks.consume import KafkaConsumerHook
+from airflow.providers.apache.kafka.hooks.consume import KafkaConsumerHook, 
error_callback
 
 
 class TestConsumerHook:
@@ -59,3 +59,29 @@ class TestConsumerHook:
         mock_client_spec = MagicMock(spec=AdminClient)
         mock_client.return_value = mock_client_spec
         assert self.hook.get_consumer() == self.hook.get_conn
+
+    @patch("airflow.providers.apache.kafka.hooks.consume.Consumer")
+    def test_default_error_cb(self, mock_consumer):
+        consumer = self.hook.get_consumer()
+        config = mock_consumer.call_args.args[0]
+        assert config["error_cb"] is error_callback
+        assert consumer == mock_consumer.return_value
+        
mock_consumer.return_value.subscribe.assert_called_once_with(["test_1"])
+
+    @patch("airflow.providers.apache.kafka.hooks.consume.Consumer")
+    def 
test_user_error_cb_from_connection_resolved_and_not_overridden_by_default(
+        self, mock_consumer, create_connection_without_db
+    ):
+        # A dotted-path ``error_cb`` on the connection extras is resolved to 
the
+        # callable and used instead of the hook's default ``error_callback``.
+        create_connection_without_db(
+            Connection(
+                conn_id="kafka_cb",
+                conn_type="kafka",
+                extra=json.dumps({"bootstrap.servers": "localhost:9092", 
"error_cb": "json.loads"}),
+            )
+        )
+        hook = KafkaConsumerHook(["test_1"], kafka_config_id="kafka_cb")
+        hook.get_consumer()
+        config = mock_consumer.call_args.args[0]
+        assert config["error_cb"] is json.loads
diff --git 
a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_produce.py 
b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_produce.py
index 7b48026a476..e8f8cd8f667 100644
--- a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_produce.py
+++ b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_produce.py
@@ -60,3 +60,19 @@ class TestProducerHook:
         mock_client_spec = MagicMock(spec=AdminClient)
         mock_client.return_value = mock_client_spec
         assert self.hook.get_producer() == self.hook.get_conn
+
+    @patch("airflow.providers.apache.kafka.hooks.produce.Producer")
+    def test_connection_callback_resolved_from_dotted_path(self, 
mock_producer, create_connection_without_db):
+        # A dotted-path ``oauth_cb`` on the connection extras is resolved to 
the callable
+        # before the producer is built.
+        create_connection_without_db(
+            Connection(
+                conn_id="kafka_cb",
+                conn_type="kafka",
+                extra=json.dumps({"bootstrap.servers": "localhost:9092", 
"oauth_cb": "json.dumps"}),
+            )
+        )
+        hook = KafkaProducerHook(kafka_config_id="kafka_cb")
+        hook.get_producer()
+        config = mock_producer.call_args.args[0]
+        assert config["oauth_cb"] is json.dumps
diff --git a/providers/apache/kafka/tests/unit/apache/kafka/plugins/__init__.py 
b/providers/apache/kafka/tests/unit/apache/kafka/plugins/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/providers/apache/kafka/tests/unit/apache/kafka/plugins/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git 
a/providers/apache/kafka/tests/unit/apache/kafka/plugins/test_listener.py 
b/providers/apache/kafka/tests/unit/apache/kafka/plugins/test_listener.py
new file mode 100644
index 00000000000..38bdc056637
--- /dev/null
+++ b/providers/apache/kafka/tests/unit/apache/kafka/plugins/test_listener.py
@@ -0,0 +1,538 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+from collections.abc import Callable
+from unittest.mock import MagicMock, patch
+
+import pytest
+from confluent_kafka import KafkaError
+
+from airflow.models import Connection
+from airflow.providers.apache.kafka.plugins import listener
+from airflow.providers.apache.kafka.plugins.listener import DagRunListener, 
TaskListener
+
+from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
+
+# TaskInstance listener hooks required a ``session`` param for AF versions 
prior to 3.0.
+_TI_SESSION_KWARG: dict = {} if AIRFLOW_V_3_0_PLUS else {"session": None}
+
+_DAG_ID = "test_dag1"
+_DAG_RUN_ID = "test_dag_run1"
+_TASK_ID = "test_task1"
+_RUN_TYPE = "manual"
+
+_KAFKA_TOPIC = "airflow.events"
+_SOURCE = "af-dev-env"
+
+# The hook binds these names in its own namespace, so patch them there.
+_PRODUCER_CLS = "airflow.providers.apache.kafka.hooks.produce.Producer"
+_PRODUCER_HOOK_CLS = 
"airflow.providers.apache.kafka.hooks.produce.KafkaProducerHook"
+
+
[email protected](autouse=True)
+def _default_kafka_conn(create_connection_without_db):
+    # The listener builds its producer through ``KafkaProducerHook``, which 
requires
+    # a resolvable ``kafka_default`` connection.
+    create_connection_without_db(
+        Connection(
+            conn_id="kafka_default",
+            conn_type="kafka",
+            extra=json.dumps({"bootstrap.servers": "broker:29092"}),
+        )
+    )
+
+
[email protected](autouse=True)
+def _clear_cached_values():
+    """Invalidates caches before each test run."""
+    # Reset config cache.
+    listener._dag_run_events_enabled.cache_clear()
+    listener._task_instance_events_enabled.cache_clear()
+    listener._get_topic.cache_clear()
+    listener._get_kafka_config_id.cache_clear()
+    listener._get_source.cache_clear()
+    listener._get_dag_run_dag_id_allowlist.cache_clear()
+    listener._get_dag_run_dag_id_denylist.cache_clear()
+    listener._get_task_instance_dag_id_allowlist.cache_clear()
+    listener._get_task_instance_dag_id_denylist.cache_clear()
+    listener._get_task_instance_task_id_allowlist.cache_clear()
+    listener._get_task_instance_task_id_denylist.cache_clear()
+    listener._get_topic_check_timeout.cache_clear()
+    listener._get_topic_check_retry_interval.cache_clear()
+
+    # Reset the module-level producer and topic state.
+    listener._producer = None
+    listener._topic_exists = False
+    listener._topic_check_retry_after = 0.0
+
+
[email protected]
+def dr_mock():
+    dr_mock = MagicMock()
+    dr_mock.dag_id = _DAG_ID
+    dr_mock.run_id = _DAG_RUN_ID
+    dr_mock.run_type = _RUN_TYPE
+    dr_mock.logical_date = None
+    dr_mock.start_date = None
+    dr_mock.end_date = None
+    return dr_mock
+
+
[email protected]
+def ti_mock():
+    ti_mock = MagicMock()
+    ti_mock.dag_id = _DAG_ID
+    ti_mock.run_id = _DAG_RUN_ID
+    ti_mock.task_id = _TASK_ID
+    ti_mock.try_number = 2
+    ti_mock.map_index = -1
+    return ti_mock
+
+
[email protected]
+def kafka_producer_mock():
+    """Patches the Producer class used by KafkaProducerHook."""
+    mock = MagicMock()
+    # Make the topic check succeed for any configured topic name.
+    mock.list_topics.return_value.topics.__contains__.return_value = True
+    with patch(_PRODUCER_CLS, return_value=mock):
+        yield mock
+
+
+def _assert_common_message_fields(kafka_producer_mock, expected_event: str) -> 
dict:
+    kafka_producer_mock.list_topics.assert_called_once()
+    kafka_producer_mock.produce.assert_called_once()
+
+    args = kafka_producer_mock.produce.call_args.args
+    kwargs = kafka_producer_mock.produce.call_args.kwargs
+    assert args == (_KAFKA_TOPIC,)
+    assert kwargs["key"] == f"{_DAG_ID}/{_DAG_RUN_ID}".encode()
+
+    body = json.loads(kwargs["value"].decode("utf-8"))
+    assert body["schema_version"] == listener.SCHEMA_VERSION
+    assert body["source"] == _SOURCE
+    assert body["event"] == expected_event
+    assert body["dag_id"] == _DAG_ID
+    assert body["run_id"] == _DAG_RUN_ID
+    assert "timestamp" in body
+    return body
+
+
[email protected](
+    ("configs", "expected_listener_classes"),
+    [
+        pytest.param(
+            {
+                ("kafka_listener", "dag_run_events_enabled"): "True",
+                ("kafka_listener", "task_instance_events_enabled"): "True",
+            },
+            [DagRunListener, TaskListener],
+            id="both_listeners_enabled",
+        ),
+        pytest.param(
+            {
+                ("kafka_listener", "dag_run_events_enabled"): "True",
+            },
+            [DagRunListener],
+            id="only_dr_events",
+        ),
+        pytest.param(
+            {
+                ("kafka_listener", "task_instance_events_enabled"): "True",
+            },
+            [TaskListener],
+            id="only_ti_events",
+        ),
+        pytest.param(
+            {},
+            [],
+            id="listeners_disabled_by_default",
+        ),
+    ],
+)
+def test_get_enabled_listeners(configs, expected_listener_classes):
+    with conf_vars(configs):
+        registered_listeners = listener._get_enabled_listeners()
+        # Both lists should have the same size.
+        assert len(registered_listeners) == len(expected_listener_classes)
+
+        # Iterate both lists and assert.
+        assert [type(x) for x in registered_listeners] == 
expected_listener_classes
+
+
[email protected](
+    ("dr_event_function", "expected_event"),
+    [
+        pytest.param(DagRunListener.on_dag_run_running, "dag_run.running", 
id="dr_running"),
+        pytest.param(DagRunListener.on_dag_run_success, "dag_run.success", 
id="dr_success"),
+        pytest.param(DagRunListener.on_dag_run_failed, "dag_run.failed", 
id="dr_failed"),
+    ],
+)
+@conf_vars(
+    {
+        ("kafka_listener", "dag_run_events_enabled"): "True",
+        ("kafka_listener", "topic"): _KAFKA_TOPIC,
+        ("kafka_listener", "source"): _SOURCE,
+    }
+)
+def test_produce_dr_message(
+    dr_event_function: Callable,
+    expected_event: str,
+    dr_mock,
+    kafka_producer_mock,
+):
+    dr_event_function(DagRunListener(), dag_run=dr_mock, msg=None)
+
+    body = _assert_common_message_fields(kafka_producer_mock, expected_event)
+    assert body["run_type"] == _RUN_TYPE
+    assert body["logical_date"] == ""
+    assert body["start_date"] == ""
+    assert body["end_date"] == ""
+    assert body["msg"] == ""
+
+
[email protected](
+    ("ti_event_function", "expected_event", "extra_kwargs"),
+    [
+        pytest.param(TaskListener.on_task_instance_running, 
"task_instance.running", {}, id="ti_running"),
+        pytest.param(TaskListener.on_task_instance_success, 
"task_instance.success", {}, id="ti_success"),
+        pytest.param(TaskListener.on_task_instance_skipped, 
"task_instance.skipped", {}, id="ti_skipped"),
+        pytest.param(
+            TaskListener.on_task_instance_failed,
+            "task_instance.failed",
+            {"error": ValueError("boom")},
+            id="ti_failed",
+        ),
+    ],
+)
+@conf_vars(
+    {
+        ("kafka_listener", "task_instance_events_enabled"): "True",
+        ("kafka_listener", "topic"): _KAFKA_TOPIC,
+        ("kafka_listener", "source"): _SOURCE,
+    }
+)
+def test_produce_ti_message(
+    ti_event_function: Callable,
+    expected_event: str,
+    extra_kwargs: dict,
+    ti_mock,
+    kafka_producer_mock,
+):
+    ti_event_function(
+        TaskListener(),
+        previous_state="running",
+        task_instance=ti_mock,
+        **extra_kwargs,
+        **_TI_SESSION_KWARG,
+    )
+
+    body = _assert_common_message_fields(kafka_producer_mock, expected_event)
+    assert body["task_id"] == _TASK_ID
+    assert body["try_number"] == 2
+    assert body["map_index"] == -1
+    assert body["previous_state"] == "running"
+
+    if "error" in extra_kwargs:
+        assert body["error"] == str(extra_kwargs["error"])
+    else:
+        assert "error" not in body
+
+
+class TestGetProducer:
+    """Tests for the lifecycle and caching behavior of `_get_producer`."""
+
+    @pytest.fixture(autouse=True)
+    def _producer_conf(self):
+        with conf_vars({("kafka_listener", "topic"): _KAFKA_TOPIC}):
+            yield
+
+    def test_producer_cached_on_success(self):
+        kafka_producer_mock = MagicMock()
+
+        with patch(_PRODUCER_CLS, return_value=kafka_producer_mock) as 
producer_cls_mock:
+            producer1 = listener._get_producer()
+            producer2 = listener._get_producer()
+
+        assert producer1 is kafka_producer_mock
+        assert producer2 is producer1
+        producer_cls_mock.assert_called_once()
+
+    def test_init_failure_warns_and_returns_none(self, monkeypatch):
+        log_warning_mock = MagicMock()
+        monkeypatch.setattr(listener.log, "warning", log_warning_mock)
+
+        with patch(_PRODUCER_CLS, side_effect=ValueError("boom")):
+            assert listener._get_producer() is None
+
+        log_warning_mock.assert_called_once()
+        warning_format, exc_arg = log_warning_mock.call_args.args
+        assert "failed to initialize producer" in warning_format
+        assert str(exc_arg) == "boom"
+
+    def test_atexit_register(self, monkeypatch):
+        """On success, _get_producer registers a flush handler with atexit so 
any
+        pending messages are delivered to the broker when the process exits."""
+        kafka_producer_mock = MagicMock()
+
+        register_mock = MagicMock()
+        monkeypatch.setattr(listener.atexit, "register", register_mock)
+
+        with patch(_PRODUCER_CLS, return_value=kafka_producer_mock):
+            listener._get_producer()
+
+        
register_mock.assert_called_once_with(listener._flush_producer_at_exit, 
kafka_producer_mock)
+
+    def test_hook_built_from_listener_config(self):
+        """The listener section's ``kafka_config_id`` is forwarded to the 
hook."""
+        hook_mock = MagicMock()
+
+        with conf_vars({("kafka_listener", "kafka_config_id"): 
"kafka_events"}):
+            with patch(_PRODUCER_HOOK_CLS, return_value=hook_mock) as 
hook_cls_mock:
+                producer = listener._get_producer()
+
+        hook_cls_mock.assert_called_once_with(kafka_config_id="kafka_events")
+        assert producer is hook_mock.get_producer.return_value
+
+    def test_hook_defaults_when_options_unset(self):
+        """Without listener options the hook falls back to its own defaults."""
+        hook_mock = MagicMock()
+
+        with patch(_PRODUCER_HOOK_CLS, return_value=hook_mock) as 
hook_cls_mock:
+            listener._get_producer()
+
+        # Unset options must not be passed to the hook at all (e.g. as ""), so 
the
+        # hook's own defaults (the "kafka_default" connection) apply.
+        hook_cls_mock.assert_called_once_with()
+
+
+class TestCheckTopicExists:
+    """Tests for the topic check and its retry-after-failure cooldown."""
+
+    @pytest.fixture(autouse=True)
+    def _topic_conf(self):
+        with conf_vars({("kafka_listener", "topic"): _KAFKA_TOPIC}):
+            yield
+
+    def test_topic_exists_kept_for_process_lifetime(self):
+        kafka_producer_mock = MagicMock()
+        
kafka_producer_mock.list_topics.return_value.topics.__contains__.return_value = 
True
+
+        with patch(_PRODUCER_CLS, return_value=kafka_producer_mock):
+            assert listener._check_topic_exists() is True
+            # Once confirmed, the broker is not queried again.
+            assert listener._check_topic_exists() is True
+
+        kafka_producer_mock.list_topics.assert_called_once()
+
+    def test_topic_doesnt_exist(self, monkeypatch):
+        kafka_producer_mock = MagicMock()
+        
kafka_producer_mock.list_topics.return_value.topics.__contains__.return_value = 
False
+
+        log_warning_mock = MagicMock()
+        monkeypatch.setattr(listener.log, "warning", log_warning_mock)
+
+        with patch(_PRODUCER_CLS, return_value=kafka_producer_mock):
+            assert listener._check_topic_exists() is False
+
+        log_warning_mock.assert_called_once()
+        # Format string + the two formatting args are passed positionally to 
log.warning.
+        warning_format, topic_arg, retry_interval_arg = 
log_warning_mock.call_args.args
+        assert "topic %r not found on the broker" in warning_format
+        assert topic_arg == _KAFKA_TOPIC
+        assert retry_interval_arg == listener._get_topic_check_retry_interval()
+
+    def test_list_topics_failure_warns_and_sets_cooldown(self, monkeypatch):
+        kafka_producer_mock = MagicMock()
+        kafka_producer_mock.list_topics.side_effect = ValueError("broker down")
+
+        log_warning_mock = MagicMock()
+        monkeypatch.setattr(listener.log, "warning", log_warning_mock)
+
+        with patch(_PRODUCER_CLS, return_value=kafka_producer_mock):
+            assert listener._check_topic_exists() is False
+            # Within the cooldown window the check is not retried.
+            assert listener._check_topic_exists() is False
+
+        kafka_producer_mock.list_topics.assert_called_once()
+        log_warning_mock.assert_called_once()
+        assert "topic check failed" in log_warning_mock.call_args.args[0]
+
+    def test_retried_after_failure_cooldown(self, monkeypatch):
+        time_now = [1000.0]
+        monkeypatch.setattr(listener.time, "monotonic", lambda: time_now[0])
+
+        kafka_producer_mock = MagicMock()
+        # The topic doesn't exist yet.
+        
kafka_producer_mock.list_topics.return_value.topics.__contains__.return_value = 
False
+
+        with patch(_PRODUCER_CLS, return_value=kafka_producer_mock):
+            # First check fails: the topic is missing.
+            assert listener._check_topic_exists() is False
+            assert kafka_producer_mock.list_topics.call_count == 1
+
+            # The interval hasn't passed and the check isn't retried.
+            time_now[0] += 1
+            assert listener._check_topic_exists() is False
+            assert kafka_producer_mock.list_topics.call_count == 1
+
+            # Past the interval. The check is retried and succeeds this time.
+            
kafka_producer_mock.list_topics.return_value.topics.__contains__.return_value = 
True
+            time_now[0] += listener._get_topic_check_retry_interval() + 1
+            assert listener._check_topic_exists() is True
+            assert kafka_producer_mock.list_topics.call_count == 2
+
+    def test_producer_init_failure_sets_cooldown(self, monkeypatch):
+        time_now = [1000.0]
+        monkeypatch.setattr(listener.time, "monotonic", lambda: time_now[0])
+
+        with patch(_PRODUCER_CLS, side_effect=ValueError("boom")) as 
producer_cls_mock:
+            assert listener._check_topic_exists() is False
+            assert producer_cls_mock.call_count == 1
+
+            # The interval hasn't passed and the producer initialization isn't 
retried.
+            time_now[0] += 1
+            assert listener._check_topic_exists() is False
+            assert producer_cls_mock.call_count == 1
+
+            # Past the interval. The producer initialization is retried.
+            time_now[0] += listener._get_topic_check_retry_interval() + 1
+            assert listener._check_topic_exists() is False
+            assert producer_cls_mock.call_count == 2
+
+    @pytest.mark.parametrize(
+        ("kafka_error", "topic_exists_expected"),
+        [
+            pytest.param(KafkaError.UNKNOWN_TOPIC_OR_PART, False, 
id="broker_says_topic_gone"),
+            pytest.param(KafkaError._UNKNOWN_TOPIC, False, 
id="local_metadata_says_topic_gone"),
+            pytest.param(KafkaError.BROKER_NOT_AVAILABLE, True, 
id="unrelated_delivery_error_ignored"),
+        ],
+    )
+    def 
test_delivery_report_invalidates_topic_confirmation_only_on_missing_topic(
+        self, monkeypatch, kafka_error, topic_exists_expected
+    ):
+        """
+        If the delivery report failed and the topic is gone, ``_topic_exists`` 
must be reset so that the
+        next produce triggers a fresh check. Any delivery error unrelated to 
the topic, should be ignored.
+        """
+
+        time_now = [1000.0]
+        monkeypatch.setattr(listener.time, "monotonic", lambda: time_now[0])
+
+        kafka_producer_mock = MagicMock()
+        
kafka_producer_mock.list_topics.return_value.topics.__contains__.return_value = 
True
+        with patch(_PRODUCER_CLS, return_value=kafka_producer_mock):
+            assert listener._check_topic_exists() is True
+            assert listener._topic_exists is True
+
+            err = MagicMock()
+            err.code.return_value = kafka_error
+            listener._on_delivery(err, MagicMock())
+
+            assert listener._topic_exists is topic_exists_expected
+
+            if not topic_exists_expected:
+                # Cooldown holds off the immediate re-check.
+                assert listener._check_topic_exists() is False
+                # After the cooldown, the check re-verifies; the topic is 
still on the broker,
+                # so confirmation is restored.
+                time_now[0] += listener._get_topic_check_retry_interval() + 1
+                assert listener._check_topic_exists() is True
+
+
+class TestFilters:
+    """Tests for the allowlist/denylist behavior of both DagRun and 
TaskInstance events."""
+
+    @pytest.fixture(autouse=True)
+    def _filters_conf(self):
+        with conf_vars(
+            {
+                ("kafka_listener", "dag_run_events_enabled"): "True",
+                ("kafka_listener", "task_instance_events_enabled"): "True",
+                ("kafka_listener", "topic"): _KAFKA_TOPIC,
+            }
+        ):
+            yield
+
+    @pytest.mark.parametrize(
+        ("id_to_check", "allowlist", "denylist", "expected_result"),
+        [
+            pytest.param("some_value", (), (), True, 
id="empty_lists_allow_all"),
+            pytest.param("dag__1", ("dag_*",), (), True, id="allowlist_match"),
+            pytest.param("dag1", ("dag2_*", "dag2"), (), False, 
id="allowlist_no_match"),
+            pytest.param("task1_dev", (), ("task1_*",), False, 
id="denylist_match"),
+            pytest.param("dag1_task1", ("dag1_*",), ("dag1_task1",), False, 
id="deny_beats_allow"),
+        ],
+    )
+    def test_id_is_allowed(self, id_to_check, allowlist, denylist, 
expected_result):
+        assert listener._id_is_allowed(id_to_check, allowlist, denylist) is 
expected_result
+
+    @conf_vars(
+        {
+            ("kafka_listener", "task_instance_dag_id_denylist"): "dag1_*",
+        }
+    )
+    def test_dag_run_filter_ignores_ti_lists(self):
+        # The dag_id lists for DR events, are separate from the dag_id lists 
for TI events.
+        # The dag_id denylist for task instances would match "dag1_task1" but 
DR filter must ignore it.
+        assert listener._dag_run_event_allowed("dag1_task1") is True
+
+    @conf_vars(
+        {
+            ("kafka_listener", "dag_run_dag_id_denylist"): "dag1_*",
+        }
+    )
+    def test_task_instance_filter_ignores_dr_lists(self):
+        # The dag_id lists for DR events, are separate from the dag_id lists 
for TI events.
+        # DR denylist would match "dag1_task1" but TI filter must ignore it.
+        assert listener._task_instance_event_allowed("dag1_task1", "load") is 
True
+
+    @conf_vars(
+        {
+            ("kafka_listener", "task_instance_dag_id_allowlist"): "dag1_*",
+            ("kafka_listener", "task_instance_task_id_denylist"): "*_cleanup",
+        }
+    )
+    def test_task_instance_requires_both_dag_id_and_task_id(self):
+        # dag_id passes (matches allowlist), task_id passes (no deny match).
+        assert listener._task_instance_event_allowed("dag1_task1", "task2") is 
True
+        # dag_id passes, task_id denied.
+        assert listener._task_instance_event_allowed("dag1_task1", 
"task2_cleanup") is False
+        # dag_id denied (no allowlist match), task_id passes.
+        assert listener._task_instance_event_allowed("other_dag", "load") is 
False
+
+    @conf_vars({("kafka_listener", "dag_run_dag_id_denylist"): _DAG_ID})
+    def test_dr_filter_blocks_emission(self, dr_mock, kafka_producer_mock):
+        DagRunListener().on_dag_run_running(dag_run=dr_mock, msg=None)
+        kafka_producer_mock.produce.assert_not_called()
+
+    @conf_vars({("kafka_listener", "task_instance_dag_id_denylist"): _DAG_ID})
+    def test_ti_filter_blocks_emission_via_dag_id(self, ti_mock, 
kafka_producer_mock):
+        TaskListener().on_task_instance_running(
+            previous_state="queued", task_instance=ti_mock, **_TI_SESSION_KWARG
+        )
+        kafka_producer_mock.produce.assert_not_called()
+
+    @conf_vars({("kafka_listener", "task_instance_task_id_denylist"): 
_TASK_ID})
+    def test_ti_filter_blocks_emission_via_task_id(self, ti_mock, 
kafka_producer_mock):
+        TaskListener().on_task_instance_running(
+            previous_state="queued", task_instance=ti_mock, **_TI_SESSION_KWARG
+        )
+        kafka_producer_mock.produce.assert_not_called()
diff --git a/scripts/ci/docker-compose/integration-kafka.yml 
b/scripts/ci/docker-compose/integration-kafka.yml
index e476c51c7e3..f0ba7fab7d6 100644
--- a/scripts/ci/docker-compose/integration-kafka.yml
+++ b/scripts/ci/docker-compose/integration-kafka.yml
@@ -60,5 +60,9 @@ services:
   airflow:
     environment:
       - INTEGRATION_KAFKA=true
+      - AIRFLOW__KAFKA_LISTENER__DAG_RUN_EVENTS_ENABLED=True
+      - AIRFLOW__KAFKA_LISTENER__TASK_INSTANCE_EVENTS_ENABLED=True
+      - AIRFLOW__KAFKA_LISTENER__TOPIC=airflow.events
+      - AIRFLOW__KAFKA_LISTENER__SOURCE=dev-breeze
     depends_on:
       - broker

Reply via email to