Skip to main content
RunBook Academy

AnsibleXLIII · Observability and Auditing of AutomationObservability and auditing

Machine-readable run artefacts

Advanced⏱ ~26 minansible-coreansible-playbookjq

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

Not yet marked complete on this device.

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:

Read-only / Safethe file-writing callbacks in core
$ ansible-doc -t callback -l
ansible.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 files

ansible.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:

Read-only / Safea real JUnit artefact from the four-host recap play
$ 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:

Read-only / Safetree keeps the last result per host, not the run
$ 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-core with no collections installed, and this course does not print output it did not produce. Install ansible.posix, pin it in requirements.yml, and enable it with callbacks_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 --limit was 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:

Read-only / Safethe run stamped with its own identity
$ ANSIBLE_SHOW_CUSTOM_STATS=1 ansible-playbook -i hosts.ini meta.yml
PLAY 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:

Read-only / Safea sidecar written before the run starts
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:

  1. 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.
  2. 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.
  3. 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

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

  2. Q2. Why does set_stats make provenance visible to callbacks when registering a variable does not?

  3. Q3. Which statements about the file-writing callbacks in ansible-core are accurate? Select all that apply.

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