Skip to main content
RunBook Academy

AnsibleXLIII · Observability and Auditing of AutomationObservability and auditing

Reading a PLAY RECAP like an operator

Intermediate⏱ ~26 minansible-coreansible-playbook

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

Not yet marked complete on this device.

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:

Read-only / Safeevery recap field populated
$ ansible-playbook -i hosts.ini recap.yml
PLAY 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=0

Now 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:

  1. You just deployed something. Expected, and the count should return to zero on the next run.
  2. Something outside Ansible changed the host, and this run corrected it. That is drift, and the count is the signal.
  3. A task is lying. It reports changed on every run because its changed_when is wrong, or because it is a command that 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:

Read-only / Safea task that connects, against hosts that cannot answer
$ ansible-playbook -i hosts.ini unreach.yml
PLAY 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=0

The 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:

Read-only / Safea completely successful run in which nothing was done
$ ansible-playbook -i hosts.ini skipall.yml
PLAY 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=0

failed=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.

  1. 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.
  2. unreachable, then failed. 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.
  3. changed against 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.
  4. skipped and ok together. ok=0 with a non-zero skipped is the run that did nothing. A skipped count 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

  1. 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?

  2. 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?

  3. Q3. Which readings of PLAY RECAP fields are accurate? Select all that apply.

  4. 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.