AnsibleXLIII · Observability and Auditing of AutomationObservability and auditing
Reading a PLAY RECAP like an operator
What you'll learn
- State precisely what each PLAY RECAP field asserts and what it does not
- Read a rising changed count on a converged fleet as a drift signal
- Detect the fully green run in which nothing was done
- Recognise that hosts absent from the recap were never reached
- Separate a successful run from a successful change
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
The recap is the most-read and least-read output in Ansible. Everybody looks
at it. Almost everybody reads two fields — failed and unreachable — and
treats the rest as decoration.
The other five are where the interesting failures live. A run in which
nothing failed, nothing was unreachable, and nothing happened is a
common and expensive outcome, and it is visible in the recap only if you
read skipped. A fleet that has quietly acquired a second owner for one
piece of state announces itself in changed, permanently, and never
anywhere else.
This lesson goes field by field, with a single run that exercises all seven.
Every field, in one run
Four hosts, arranged so that each one produces a different outcome:
$ ansible-playbook -i hosts.ini recap.ymlPLAY RECAP *********************************************************************
web1 : ok=3 changed=1 unreachable=0 failed=0 skipped=3 rescued=0 ignored=0
web2 : ok=3 changed=1 unreachable=0 failed=0 skipped=3 rescued=0 ignored=1
web3 : ok=3 changed=0 unreachable=0 failed=0 skipped=3 rescued=1 ignored=0
web4 : ok=2 changed=0 unreachable=0 failed=1 skipped=3 rescued=0 ignored=0Now the fields, in the order they mislead people.
ok — the module was satisfied
ok counts tasks that completed and reported no change. It is an assertion
about the module, not about the system.
ansible.builtin.service reporting ok means the module asked the service
manager for the state of a unit, found it already matching, and had nothing
to do. It does not mean the application inside that unit is answering
requests. It does not mean the port is listening. The
verify the outcome, not the task result
lesson makes this argument in full, and it is the reason the whole of this
part exists.
Read ok as “nothing needed doing here, according to the thing that
checked”.
changed — the module says it altered something
The single most information-dense field, and the one this part builds its metrics on.
On a converged fleet — one where the automation has already been applied
and nothing external has interfered — the healthiest possible result is
changed=0 on every host. Zero means the desired state and the actual state
agree everywhere.
That makes any non-zero changed on a converged fleet a question with
exactly three answers:
- You just deployed something. Expected, and the count should return to zero on the next run.
- Something outside Ansible changed the host, and this run corrected it. That is drift, and the count is the signal.
- A task is lying. It reports
changedon every run because itschanged_whenis wrong, or because it is acommandthat has no way to know. This is the case that destroys the value of the other two, and the inaccurate changed lesson is the reference.
The third case is worth being severe about. One permanently-changing task
means changed never falls to zero, which means nobody can use it as a
signal, which means real drift is invisible. A single sloppy task removes
the fleet’s ability to be audited.
unreachable — the connection failed
A connection was attempted and did not succeed: SSH refused, timed out, DNS
failed, authentication failed at the transport level. It is categorically
different from failed, and the
unreachable versus failed
lesson is the full treatment.
The caveat that catches people is the mirror image: a task that never
connects cannot report a host unreachable. ansible.builtin.debug,
ansible.builtin.fail, set_fact and assert are action plugins that
execute on the controller. A “can we reach the fleet?” pre-flight play built
from debug reports unreachable=0 for hosts that are comprehensively
unreachable, because nothing tried.
Verified on 2.21.3 against two documentation-range addresses that cannot answer:
$ ansible-playbook -i hosts.ini unreach.ymlPLAY RECAP *********************************************************************
edge1 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0
edge2 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0The run exited 4. A run with a task failure and no unreachable host
exits 2. A run with both exits 4 — verified by execution, because
unreachable takes precedence. A CI gate that checks only for 2 treats a
run containing both as a pass.
failed — a module reported failure and was not rescued
The one everybody reads. Worth two notes.
It counts hosts, not tasks: a host that failed once shows failed=1,
and it left the play at that point, so its other counters stopped
incrementing. web4 above shows ok=2 rather than ok=3 for exactly that
reason.
And a failed host does not stop the other hosts by default. The play continues on everyone else, which is why partial failure is the normal shape of a fleet run and why Part XLIV exists.
skipped — a task was not run because a condition said not to
The field that hides the most.
A skip is indistinguishable in the recap from a deliberate one. skipped=3
on a play that deliberately targets three optional tasks is healthy.
skipped=3 on a play where a variable name was mistyped means the work did
not happen — and the run is green:
$ ansible-playbook -i hosts.ini skipall.ymlPLAY RECAP *********************************************************************
web1 : ok=0 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web2 : ok=0 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web3 : ok=0 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web4 : ok=0 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0failed=0 everywhere. Exit code 0. CI is green. The deployment did not
happen.
The tell is ok=0. A run that did nothing at all has nothing in ok, and
that combination — ok=0 with a non-zero skipped — is worth alerting on
directly.
rescued — a failure was caught by a rescue block
A task failed, a rescue handled it, and the host continued. web3 above
shows rescued=1 and failed=0: from the recap’s point of view the host
succeeded.
Which is correct, and is also an event worth counting. A rescue firing
means the primary path did not work. If rescued is routinely non-zero,
your automation is routinely taking its error path, and the run summary is
the only place that says so — the run output scrolls past and the exit code
is 0.
ignored — a task failed and ignore_errors: true swallowed it
web2 shows ignored=1. Something genuinely failed there and the play was
instructed not to care.
Every non-zero ignored is a decision somebody made once, in a commit that
is probably older than the current problem. The
ignore_errors lesson
argues that most of them should be failed_when instead. In the recap, the
number is the count of failures you have agreed in advance not to be told
about, and it deserves the same periodic review as a suppressed alert.
A four-step reading
The order matters, because each step changes what the next one means.
- Count the hosts. Compare against what you expected to target. Missing hosts are the finding, and they are invisible if you start with the numbers.
unreachable, thenfailed. In that order: unreachable hosts are an infrastructure statement, failed hosts are an automation statement, and confusing them sends the diagnosis to the wrong team.changedagainst your expectation for this run. For a deploy, some change is expected. For a scheduled convergence run on a stable fleet, any change is a question.skippedandoktogether.ok=0with a non-zeroskippedis the run that did nothing. Askippedcount that differs between hosts you believe are identical is a variable resolving differently somewhere.
Then, and only then, the exit code — because it compresses all of that into one number and discards which hosts were involved.
Knowledge check
Knowledge check · 4 questions
Q1. A nightly convergence play over 40 hosts reports failed=0, unreachable=0, skipped=1 and ok=0 on every host, and exits 0. What has happened?
Q2. A play targeting six hosts with serial: 1 fails on the second host. How many hosts appear in the PLAY RECAP and what does that tell you?
Q3. Which readings of PLAY RECAP fields are accurate? Select all that apply.
Q4. A single task with an incorrect changed_when, reporting changed on every run, removes the fleet ability to be audited for drift.
Passing score: 75%. Answers are checked in this browser.