AnsibleXLIII · Observability and Auditing of AutomationObservability and auditing
Machine-readable run artefacts
What you'll learn
- Distinguish a rendered log from a structured artefact and say when each is useful
- Name the structured callbacks available and which collection each requires
- Stamp a run with its own identity using set_stats and custom stats
- List the metadata without which a run artefact cannot answer a later question
- Set retention as a decision with a stated reason rather than a default
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 controller log from the previous lesson has one structural flaw that no
amount of retention fixes: it is a rendering. It contains the text a
callback produced for a human, which means answering a question about it
requires parsing prose that changes shape when somebody sets
callback_result_format.
An artefact is different. It is the same events, emitted as a data structure, with the per-task and per-host detail intact. You can ask it questions with a program.
The distinction matters most at exactly the moment you need it: months later, when nobody remembers the run and the question is precise.
What is available, and where it lives
ansible-core ships two callbacks that write files, and both come with
caveats worth knowing before you build on them:
$ ansible-doc -t callback -lansible.builtin.default default Ansible screen output
ansible.builtin.junit write playbook output to a JUnit file
ansible.builtin.minimal minimal Ansible screen output
ansible.builtin.oneline oneline Ansible screen output
ansible.builtin.tree Save host events to filesansible.builtin.junit writes a JUnit XML file — one test case per task
per host, with the module result as system-out. It is genuinely useful and
its main consumer is a CI system that already understands JUnit. Verified on
2.21.3, the top of a real file:
$ head -8 junit/recap-1786491185.8839395.xml<?xml version="1.0" ?>
<testsuites disabled="0" errors="2" failures="0" tests="25" time="0.2650287151336669921875">
<testsuite disabled="0" errors="2" failures="0" name="recap" skipped="12" tests="25" time="0.265028715133666992">
<testcase classname="/path/to/recap.yml:5" name="[web1] Every recap field in one play: Plain ok msg=ok" time="0.0104944705963134765625">
<system-out>{
"changed": false,
"msg": "ok"
}</system-out>
</testcase>Note the shape of that: 25 test cases for a four-host play with seven tasks,
because the unit is task-times-host. Note also that the module result is
embedded verbatim — including anything a task returned that no_log did
not suppress, which makes this file exactly as sensitive as the log from
the previous lesson.
ansible.builtin.tree writes one file per host, and it is both
deprecated for removal in 2.23 and structurally limited. Verified on
2.21.3, the file it produced for a host that ran seven tasks:
$ cat tree/web1{
"changed": false,
"msg": "conditional"
}One result. It is a per-host snapshot, not a run record, and it is on its way out. Do not build on it.
The JSON callbacks are in ansible.posix. ansible.posix.json emits a
single JSON document for the run; ansible.posix.jsonl emits one JSON object
per line, which is what a log pipeline wants because it can be appended and
streamed. Both names were confirmed against the collection documentation.
Their output is not reproduced here. The validation controller for this course runs a bare
ansible-corewith no collections installed, and this course does not print output it did not produce. Installansible.posix, pin it inrequirements.yml, and enable it withcallbacks_enabled.
[defaults]
callbacks_enabled = ansible.posix.jsonl
The artefact does not know what the run was
Here is the failure that makes most run artefacts useless, and it is not a tooling problem.
Every callback consumes the event stream: tasks starting, results arriving, the run ending. None of them is told the things a later investigation actually needs:
- Which repository commit the playbook came from.
- Which inventory source was used, out of the several the project has.
- Which
--limitwas applied — the single most important one. - Which vault ids were unlocked.
- Who ran it, and from where.
- Whether it was
--check.
None of that is in the artefact by default, because none of it is an event. And a run record that says “site.yml ran, 180 hosts changed” without saying which limit produced those 180 hosts cannot answer “which hosts did this touch?” — because, as lesson 1 established, hosts that were never reached do not appear in the recap at all.
Stamping the run with its own identity
ansible.builtin.set_stats is the mechanism, and it is in core. It attaches
arbitrary data to the run’s statistics, which every callback then sees.
- name: Record what this run is
hosts: localhost
connection: local
gather_facts: false
tasks:
- name: Stamp the run with its identity
ansible.builtin.set_stats:
data:
repo_commit: "{{ lookup('ansible.builtin.env', 'GIT_COMMIT') | default('unknown', true) }}"
inventory_source: "{{ ansible_inventory_sources | join(',') }}"
limit_applied: "{{ ansible_limit | default('none', true) }}"
check_mode: "{{ ansible_check_mode }}"
operator: "{{ lookup('ansible.builtin.env', 'SUDO_USER') | default(lookup('ansible.builtin.env', 'USER'), true) }}"
aggregate: false
Put that play first in site.yml, and every run carries its own
provenance. With custom stats displayed, it appears in the output — verified
on 2.21.3:
$ ANSIBLE_SHOW_CUSTOM_STATS=1 ansible-playbook -i hosts.ini meta.ymlPLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0
CUSTOM STATS: ******************************************************************
RUN: { "inventory_source": "inventory/production", "limit_applied": "webservers:!web4", "operator": "REDACTED", "repo_commit": "REPLACE_ME_COMMIT_SHA"}show_custom_stats is an option of the default callback
(ANSIBLE_SHOW_CUSTOM_STATS, or show_custom_stats in [defaults]), and
the same data reaches any structured callback you have enabled.
Sidecar metadata: the belt-and-braces version
The stamping play covers the run’s own view of itself. A wrapper — a
Makefile target, a CI step, a thin shell script that everyone uses instead
of typing ansible-playbook — can record things the run cannot see:
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
mkdir -p artefacts
jq -n --arg id "$RUN_ID" --arg commit "$(git rev-parse HEAD)" --arg branch "$(git rev-parse --abbrev-ref HEAD)" --arg dirty "$(git status --porcelain | wc -l)" --arg host "$(hostname -f)" --arg user "${SUDO_USER:-$USER}" '{run_id: $id, commit: $commit, branch: $branch, uncommitted_files: $dirty, controller: $host, operator: $user}' > "artefacts/${RUN_ID}.meta.json"uncommitted_files is the field people leave out and then need. A run made
from a working tree with local modifications is not reproducible from the
commit, and the honest artefact says so.
Retention is a decision, not a default
Three questions, answered once, written down:
- How long? Driven by the longest question you expect to answer. “Which automation touched this host before the incident” is usually answered within days; “who changed this control before the audit period closed” is quarters. Pick the longer one and say why.
- How much? An artefact per run, per host, per task grows with the fleet and the schedule. A nightly run over 400 hosts with 200 tasks is 80,000 records a night. Estimate before enabling, not after the bucket bill.
- What is deleted, and can it be? If your retention policy says ninety days and the artefacts contain something you are required to keep for seven years, you have a conflict to resolve now rather than during the audit.
The failure mode of getting this wrong is not usually cost. It is that the artefacts stop being written when the disk fills, quietly, and nobody notices until the first time somebody needs one.
Knowledge check
Knowledge check · 4 questions
Q1. A team has a structured artefact for every run, containing every task result for every host. Why can it still fail to answer whether Tuesday run touched a given host?
Q2. Why does set_stats make provenance visible to callbacks when registering a variable does not?
Q3. Which statements about the file-writing callbacks in ansible-core are accurate? Select all that apply.
Q4. Because a run artefact is machine-readable rather than human-readable, retention can be left at whatever the storage system defaults to.
Passing score: 75%. Answers are checked in this browser.