From c473cc5a7d6c1f0c58458d4be221a886176b6302 Mon Sep 17 00:00:00 2001 From: Alexander Freiherr von Buddenbrock Date: Fri, 16 Jan 2026 22:14:44 +0100 Subject: [PATCH 1/5] Start opentelemetry spans on host start instead of task start v2_playbook_on_task_start does not have the host information, so spans would always start at the same time for every host in that task, even if they started at different times, like when hosts > forks with strategy host_pinned. This also hides the duration of the task for that host. This change uses the newer v2_runner_on_start callback and adds the acutal host start time to the span. The change is backwards compatible with ansible versions that do not have v2_runner_on_start and makes no assumptions around the ordering of v2_runner_on_start and v2_playbook_on_task_start. --- plugins/callback/opentelemetry.py | 27 ++++++++++++++++--- .../plugins/callback/test_opentelemetry.py | 21 +++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/plugins/callback/opentelemetry.py b/plugins/callback/opentelemetry.py index 990006f42d..7659e24a61 100644 --- a/plugins/callback/opentelemetry.py +++ b/plugins/callback/opentelemetry.py @@ -185,6 +185,7 @@ class TaskData: # concatenate task include output from multiple items host.result = f"{self.host_data[host.uuid].result}\n{host.result}" else: + self.host_data[host.uuid].update(host) return self.host_data[host.uuid] = host @@ -195,12 +196,20 @@ class HostData: Data about an individual host. """ - def __init__(self, uuid, name, status, result): + def __init__(self, uuid, name, status, result, start=None): self.uuid = uuid self.name = name self.status = status self.result = result self.finish = time_ns() + self.start = start + + def update(self, host): + self.status = host.status + self.result = host.result + self.finish = host.finish + if host.start is not None: + self.start = host.start class OpenTelemetrySource: @@ -221,13 +230,17 @@ class OpenTelemetrySource: carrier["traceparent"] = traceparent return TraceContextTextMapPropagator().extract(carrier=carrier) - def start_task(self, tasks_data, hide_task_arguments, play_name, task): + def start_task(self, tasks_data, hide_task_arguments, play_name, task, host=None): """record the start of a task for one or more hosts""" uuid = task._uuid if uuid in tasks_data: - return + if host: + tasks_data[uuid].add_host(HostData(host._uuid, host.name, "started", None, time_ns())) + return + else: + return name = task.get_name().strip() path = task.get_path() @@ -238,6 +251,8 @@ class OpenTelemetrySource: args = task.args tasks_data[uuid] = TaskData(uuid, name, path, play_name, action, args) + if host: + tasks_data[uuid].add_host(HostData(host._uuid, host.name, "started", None, time_ns())) def finish_task(self, tasks_data, status, result, dump): """record the results of a task for a single host""" @@ -310,7 +325,8 @@ class OpenTelemetrySource: parent.set_attribute("ansible.host.user", self.user) for task in tasks: for host_data in task.host_data.values(): - with tracer.start_as_current_span(task.name, start_time=task.start, end_on_exit=False) as span: + start = host_data.start or task.start + with tracer.start_as_current_span(task.name, start_time=start, end_on_exit=False) as span: self.update_span_data(task, host_data, span, disable_logs, disable_attributes_in_logs) return otel_exporter @@ -565,6 +581,9 @@ class CallbackModule(CallbackBase): def v2_playbook_on_task_start(self, task, is_conditional): self.opentelemetry.start_task(self.tasks_data, self.hide_task_arguments, self.play_name, task) + def v2_runner_on_start(self, host, task): + self.opentelemetry.start_task(self.tasks_data, self.hide_task_arguments, self.play_name, task, host) + def v2_playbook_on_cleanup_task_start(self, task): self.opentelemetry.start_task(self.tasks_data, self.hide_task_arguments, self.play_name, task) diff --git a/tests/unit/plugins/callback/test_opentelemetry.py b/tests/unit/plugins/callback/test_opentelemetry.py index 9fb566ef88..57f9d4ac5c 100644 --- a/tests/unit/plugins/callback/test_opentelemetry.py +++ b/tests/unit/plugins/callback/test_opentelemetry.py @@ -50,6 +50,27 @@ class TestOpentelemetry(unittest.TestCase): self.assertEqual(task_data.action, "myaction") self.assertEqual(task_data.args, {}) + def test_run_task_with_host(self): + tasks_data = OrderedDict() + + self.opentelemetry.start_task(tasks_data, False, "myplay", self.mock_task, self.mock_host) + + task_data = tasks_data["myuuid"] + self.assertEqual(task_data.uuid, "myuuid") + self.assertEqual(task_data.name, "mytask") + self.assertEqual(task_data.path, "/mypath") + self.assertEqual(task_data.play, "myplay") + self.assertEqual(task_data.action, "myaction") + self.assertEqual(task_data.args, {}) + + host_data = task_data.host_data["myhost_uuid"] + self.assertEqual(host_data.uuid, "myhost_uuid") + self.assertEqual(host_data.name, "myhost") + self.assertIsNotNone(host_data.start) + + self.opentelemetry.finish_task(tasks_data, "ok", self.my_task_result, "") + self.assertEqual(host_data.status, "ok") + def test_finish_task_with_a_host_match(self): tasks_data = OrderedDict() tasks_data["myuuid"] = self.my_task From 855beecd825f4934eaa8cec7d763f08eeda91e3b Mon Sep 17 00:00:00 2001 From: Alexander Freiherr von Buddenbrock Date: Fri, 16 Jan 2026 22:37:46 +0100 Subject: [PATCH 2/5] Add changelog fragment --- changelogs/fragments/11434-opentelemetry-spans.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/11434-opentelemetry-spans.yml diff --git a/changelogs/fragments/11434-opentelemetry-spans.yml b/changelogs/fragments/11434-opentelemetry-spans.yml new file mode 100644 index 0000000000..a6a3f54fb3 --- /dev/null +++ b/changelogs/fragments/11434-opentelemetry-spans.yml @@ -0,0 +1,2 @@ +bugfixes: + - opentelemetry callback plugin - set span start to the actual start time of the task for the given host instead of the task start time for the first host of that task (https://github.com/ansible-collections/community.general/pull/11434). From 81873ea059b229165bd7ac454dbd8d715182a7c7 Mon Sep 17 00:00:00 2001 From: Alexander Freiherr von Buddenbrock Date: Mon, 19 Jan 2026 19:19:26 +0100 Subject: [PATCH 3/5] Remove redundant callback hooks v2_runner_on_starts gets called by ansible right after the strategy has called on_task_start or on_handler_start. So there is no need to keep this code as the minimum ansible-core version is guaranteed to have this function. on_cleanup (removed around ansible-core 2.0) and on_no_hosts (removed around ansible-core 2.5) never get called. --- plugins/callback/opentelemetry.py | 42 ++++++------------- .../plugins/callback/test_opentelemetry.py | 13 ------ 2 files changed, 12 insertions(+), 43 deletions(-) diff --git a/plugins/callback/opentelemetry.py b/plugins/callback/opentelemetry.py index 7659e24a61..de12d0c5b4 100644 --- a/plugins/callback/opentelemetry.py +++ b/plugins/callback/opentelemetry.py @@ -185,7 +185,7 @@ class TaskData: # concatenate task include output from multiple items host.result = f"{self.host_data[host.uuid].result}\n{host.result}" else: - self.host_data[host.uuid].update(host) + self.host_data[host.uuid].update(host.status, host.result) return self.host_data[host.uuid] = host @@ -196,20 +196,18 @@ class HostData: Data about an individual host. """ - def __init__(self, uuid, name, status, result, start=None): + def __init__(self, uuid, name, status, result): self.uuid = uuid self.name = name self.status = status self.result = result - self.finish = time_ns() - self.start = start + self.finish = None + self.start = time_ns() - def update(self, host): - self.status = host.status - self.result = host.result - self.finish = host.finish - if host.start is not None: - self.start = host.start + def update(self, status, result): + self.status = status + self.result = result + self.finish = time_ns() class OpenTelemetrySource: @@ -230,17 +228,14 @@ class OpenTelemetrySource: carrier["traceparent"] = traceparent return TraceContextTextMapPropagator().extract(carrier=carrier) - def start_task(self, tasks_data, hide_task_arguments, play_name, task, host=None): + def start_task(self, tasks_data, hide_task_arguments, play_name, task, host): """record the start of a task for one or more hosts""" uuid = task._uuid if uuid in tasks_data: - if host: - tasks_data[uuid].add_host(HostData(host._uuid, host.name, "started", None, time_ns())) - return - else: - return + tasks_data[uuid].add_host(HostData(host._uuid, host.name, "started", None)) + return name = task.get_name().strip() path = task.get_path() @@ -251,8 +246,7 @@ class OpenTelemetrySource: args = task.args tasks_data[uuid] = TaskData(uuid, name, path, play_name, action, args) - if host: - tasks_data[uuid].add_host(HostData(host._uuid, host.name, "started", None, time_ns())) + tasks_data[uuid].add_host(HostData(host._uuid, host.name, "started", None)) def finish_task(self, tasks_data, status, result, dump): """record the results of a task for a single host""" @@ -575,21 +569,9 @@ class CallbackModule(CallbackBase): def v2_playbook_on_play_start(self, play): self.play_name = play.get_name() - def v2_runner_on_no_hosts(self, task): - self.opentelemetry.start_task(self.tasks_data, self.hide_task_arguments, self.play_name, task) - - def v2_playbook_on_task_start(self, task, is_conditional): - self.opentelemetry.start_task(self.tasks_data, self.hide_task_arguments, self.play_name, task) - def v2_runner_on_start(self, host, task): self.opentelemetry.start_task(self.tasks_data, self.hide_task_arguments, self.play_name, task, host) - def v2_playbook_on_cleanup_task_start(self, task): - self.opentelemetry.start_task(self.tasks_data, self.hide_task_arguments, self.play_name, task) - - def v2_playbook_on_handler_task_start(self, task): - self.opentelemetry.start_task(self.tasks_data, self.hide_task_arguments, self.play_name, task) - def v2_runner_on_failed(self, result, ignore_errors=False): if ignore_errors: status = "ignored" diff --git a/tests/unit/plugins/callback/test_opentelemetry.py b/tests/unit/plugins/callback/test_opentelemetry.py index 57f9d4ac5c..0238de45b2 100644 --- a/tests/unit/plugins/callback/test_opentelemetry.py +++ b/tests/unit/plugins/callback/test_opentelemetry.py @@ -37,19 +37,6 @@ class TestOpentelemetry(unittest.TestCase): host=self.mock_host, task=self.mock_task, return_data={}, task_fields=self.task_fields ) - def test_start_task(self): - tasks_data = OrderedDict() - - self.opentelemetry.start_task(tasks_data, False, "myplay", self.mock_task) - - task_data = tasks_data["myuuid"] - self.assertEqual(task_data.uuid, "myuuid") - self.assertEqual(task_data.name, "mytask") - self.assertEqual(task_data.path, "/mypath") - self.assertEqual(task_data.play, "myplay") - self.assertEqual(task_data.action, "myaction") - self.assertEqual(task_data.args, {}) - def test_run_task_with_host(self): tasks_data = OrderedDict() From fd178d4c2883d4fe1c16e1eb993c091c9a8b7c7a Mon Sep 17 00:00:00 2001 From: Alexander Freiherr von Buddenbrock Date: Fri, 23 Jan 2026 16:04:28 +0100 Subject: [PATCH 4/5] Fix unreachable hosts causing exceptions If finish_task is never called for a host the result object stays None, which caused an exception in update_span_data. This was the case for unreachable hosts, as the callback plugin did not implement v2_runner_on_unreachable. --- plugins/callback/opentelemetry.py | 16 +++++++++++----- .../unit/plugins/callback/test_opentelemetry.py | 13 ++++++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/plugins/callback/opentelemetry.py b/plugins/callback/opentelemetry.py index de12d0c5b4..12113f26c3 100644 --- a/plugins/callback/opentelemetry.py +++ b/plugins/callback/opentelemetry.py @@ -196,7 +196,7 @@ class HostData: Data about an individual host. """ - def __init__(self, uuid, name, status, result): + def __init__(self, uuid, name, status, result=None): self.uuid = uuid self.name = name self.status = status @@ -234,7 +234,7 @@ class OpenTelemetrySource: uuid = task._uuid if uuid in tasks_data: - tasks_data[uuid].add_host(HostData(host._uuid, host.name, "started", None)) + tasks_data[uuid].add_host(HostData(host._uuid, host.name, "started")) return name = task.get_name().strip() @@ -246,7 +246,7 @@ class OpenTelemetrySource: args = task.args tasks_data[uuid] = TaskData(uuid, name, path, play_name, action, args) - tasks_data[uuid].add_host(HostData(host._uuid, host.name, "started", None)) + tasks_data[uuid].add_host(HostData(host._uuid, host.name, "started")) def finish_task(self, tasks_data, status, result, dump): """record the results of a task for a single host""" @@ -337,13 +337,13 @@ class OpenTelemetrySource: if host_data.status != "included": # Support loops enriched_error_message = None - if "results" in host_data.result._result: + if host_data.result and "results" in host_data.result._result: if host_data.status == "failed": message = self.get_error_message_from_results(host_data.result._result["results"], task_data.action) enriched_error_message = self.enrich_error_message_from_results( host_data.result._result["results"], task_data.action ) - else: + elif host_data.result: res = host_data.result._result rc = res.get("rc", 0) if host_data.status == "failed": @@ -593,6 +593,12 @@ class CallbackModule(CallbackBase): self.tasks_data, "skipped", result, self.dump_results(self.tasks_data[result._task._uuid], result) ) + def v2_runner_on_unreachable(self, result): + self.errors += 1 + self.opentelemetry.finish_task( + self.tasks_data, "failed", result, self.dump_results(self.tasks_data[result._task._uuid], result) + ) + def v2_playbook_on_include(self, included_file): self.opentelemetry.finish_task(self.tasks_data, "included", included_file, "") diff --git a/tests/unit/plugins/callback/test_opentelemetry.py b/tests/unit/plugins/callback/test_opentelemetry.py index 0238de45b2..698d7203be 100644 --- a/tests/unit/plugins/callback/test_opentelemetry.py +++ b/tests/unit/plugins/callback/test_opentelemetry.py @@ -11,7 +11,7 @@ from unittest.mock import MagicMock, Mock, patch from ansible.executor.task_result import TaskResult from ansible.playbook.task import Task -from ansible_collections.community.general.plugins.callback.opentelemetry import OpenTelemetrySource, TaskData +from ansible_collections.community.general.plugins.callback.opentelemetry import OpenTelemetrySource, TaskData, HostData class TestOpentelemetry(unittest.TestCase): @@ -36,6 +36,10 @@ class TestOpentelemetry(unittest.TestCase): self.my_task_result = TaskResult( host=self.mock_host, task=self.mock_task, return_data={}, task_fields=self.task_fields ) + self.mock_span = Mock("MockSpan") + self.mock_span.set_status = MagicMock() + self.mock_span.set_attributes = MagicMock() + self.mock_span.end = MagicMock() def test_run_task_with_host(self): tasks_data = OrderedDict() @@ -83,6 +87,13 @@ class TestOpentelemetry(unittest.TestCase): self.assertEqual(host_data.name, "include") self.assertEqual(host_data.status, "ok") + @patch("ansible_collections.community.general.plugins.callback.opentelemetry.Status", create=True) + @patch("ansible_collections.community.general.plugins.callback.opentelemetry.StatusCode", create=True) + def test_update_span_data(self, mock_status_code, mock_status): + unfinished_host = HostData("myhost_uuid", "myhost", "unreachable") + self.opentelemetry.update_span_data(self.mock_task, unfinished_host, self.mock_span, True, True) + self.mock_span.end.assert_called() + def test_get_error_message(self): test_cases = ( ("my-exception", "my-msg", None, "my-exception"), From 40284549834d66a0f7de7dfc005d491d3bbc2891 Mon Sep 17 00:00:00 2001 From: Alexander Freiherr von Buddenbrock Date: Sat, 24 Jan 2026 12:51:07 +0100 Subject: [PATCH 5/5] Fix import order in test_opentelemetry --- tests/unit/plugins/callback/test_opentelemetry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/plugins/callback/test_opentelemetry.py b/tests/unit/plugins/callback/test_opentelemetry.py index 698d7203be..5618e6d313 100644 --- a/tests/unit/plugins/callback/test_opentelemetry.py +++ b/tests/unit/plugins/callback/test_opentelemetry.py @@ -11,7 +11,7 @@ from unittest.mock import MagicMock, Mock, patch from ansible.executor.task_result import TaskResult from ansible.playbook.task import Task -from ansible_collections.community.general.plugins.callback.opentelemetry import OpenTelemetrySource, TaskData, HostData +from ansible_collections.community.general.plugins.callback.opentelemetry import HostData, OpenTelemetrySource, TaskData class TestOpentelemetry(unittest.TestCase):