09 · Observability for Infrastructure Changes¶
Every module so far has assumed you're watching a plan/apply run in
a terminal, in real time. At platform scale, most applies happen in CI,
unattended, and the question shifts from "what does this apply do" to
"how would anyone know, after the fact, what changed, when, and who
approved it" — without re-running terraform show on every historical
plan by hand.
Why apply output alone isn't observability¶
aws_security_group_rule.web_https: Modifying... [id=sgr-0abc123]
aws_security_group_rule.web_https: Modification complete after 1s
This line, scrolling past in a CI log, is the entire record of a security group rule changing — unless someone is watching that specific job's log at that specific moment, or knows to go find it later, it's effectively invisible. CI log retention windows expire; logs from a hundred daily pipeline runs across dozens of repos aren't searchable as a coherent timeline. Observability for infrastructure changes means deliberately exporting three things every apply produces — the plan diff, the actual resource-level events, and the identity/approval metadata — into systems built to retain and query them.
Structured logging: TF_LOG_CORE / TF_LOG in JSON¶
{"@level":"info","@message":"aws_instance.app: Creation complete after 42s","@module":"terraform.ui","@timestamp":"2026-03-04T10:15:22.104Z","type":"apply_complete","hook":{"resource":{"addr":"aws_instance.app"},"action":"create","elapsed_seconds":42}}
TF_LOG_JSON=1 switches Terraform's CLI UI output from human-readable
text to structured JSON lines, with a stable schema per event type
(apply_complete, diagnostic, refresh_complete, etc.). This is what
turns "log output" into "log data" — a log-shipping agent can parse each
line as a JSON object and forward it to a log aggregator (CloudWatch
Logs, Datadog, an ELK stack) with resource.addr, action, and
elapsed_seconds as queryable fields, rather than a blob of text
requiring regex to extract anything.
Retaining the plan diff itself, not just the apply log¶
terraform plan -out=tfplan
terraform show -json tfplan > plan-$(date +%s).json
aws s3 cp plan-*.json s3://acme-tf-audit/production/plans/
The apply log tells you what happened to each resource during execution; it doesn't tell you why — what the reviewer actually saw and approved. Archiving every plan's JSON output (the same artifact consumed by cost estimation and security scanning in modules 04–05) to durable storage, keyed by timestamp and workspace, is what lets someone later answer "what exactly was proposed in the change that modified this security group on March 4th" without needing the CI job's log to still exist.
Correlating change events with an audit trail: who, what, when¶
{
"workspace": "production",
"run_id": "run-CZcmQ9mVXwFwSMPr",
"vcs_commit": "a1b2c3d",
"triggered_by": "jane@acme.com",
"approved_by": "platform-lead@acme.com",
"plan_summary": { "add": 1, "change": 2, "destroy": 0 },
"applied_at": "2026-03-04T10:15:00Z"
}
Terraform Cloud/Enterprise emits exactly this kind of run metadata via its Audit Trails API/webhook — every run's triggering commit, requester, approver, and plan summary, independent of the resource-level apply log. Piping this into the same aggregation system as the structured apply logs and archived plan JSON turns three separate artifacts (who approved it, what was proposed, what actually happened) into one queryable timeline per infrastructure change, which is what an incident retrospective or a compliance audit actually needs — not any single one of those three in isolation.
Worked example: wiring drift detection into the same observability pipeline¶
# .github/workflows/drift-detect.yml
on:
schedule:
- cron: "0 */6 * * *"
jobs:
drift:
runs-on: ubuntu-latest
steps:
- run: terraform plan -detailed-exitcode -out=tfplan; echo "exitcode=$?" >> "$GITHUB_OUTPUT"
id: plan
- if: steps.plan.outputs.exitcode == '2'
run: |
terraform show -json tfplan > drift.json
aws s3 cp drift.json s3://acme-tf-audit/production/drift/drift-$(date +%s).json
curl -X POST "$SLACK_WEBHOOK" -d "{\"text\":\"Drift detected in production — see drift.json in audit bucket\"}"
Level 4's drift-detection module (03) covers finding drift with
-detailed-exitcode; wiring the detected drift's JSON plan into the same
S3 audit prefix and alerting channel as regular applies means drift
shows up in the same timeline as deliberate changes — someone reviewing
"what happened to this resource last week" sees both the applies your
team ran and any drift Terraform caught, in one place, instead of drift
detection living in a separate, disconnected pipeline nobody checks.
How It Actually Works¶
TF_LOG_JSON doesn't add new information to what Terraform Core
already tracks internally — it switches the serialization of the same
UI event stream Core always emits to drive the human-readable CLI
output. Every "Creating...", "Modifying...", "complete after Ns" line
you've seen throughout this course originates from Core's UI hook
system emitting a typed event (resource address, action, timestamp,
elapsed time) after each provider RPC completes; the human-readable
renderer and the JSON renderer are two different consumers of that exact
same event stream. This is why JSON logging costs nothing in terms of
information — it's strictly a format change on data Core was already
producing, which is also why it's safe to enable everywhere without
changing apply behavior at all.
A saved plan file's JSON representation and the audit-trail metadata
from Terraform Cloud/Enterprise are two independent systems recording
two different scopes: the plan JSON is Core's understanding of one
specific proposed diff, while the run's audit metadata is Terraform
Cloud/Enterprise's record of the workflow around that diff (who
triggered it, who approved it) — Core itself has no concept of
"approval" at all. Approval, requester identity, and run sequencing
are entirely implemented in the orchestration layer sitting on top of
Core (Terraform Cloud/Enterprise, or an equivalent CI system's own
approval gates) — which is exactly why self-managed CI pipelines have to
build and retain this metadata themselves (as in the JSON example
above) if they want it, since nothing about terraform plan/apply
running in a bare CI job produces an approval record on its own.
Drift detected via -detailed-exitcode (exit code 2) produces exactly
the same JSON plan schema as a deliberate change's plan — Core has no
separate code path or output format for "this diff exists because
someone edited the cloud console" versus "this diff exists because I
changed the .tf file" — both are just a computed difference between
current real-world state (refreshed from the provider) and current
configuration. This is precisely why drift's JSON output can flow into
the identical audit-storage and alerting pipeline as regular applies
with zero special-casing: from Core's perspective, and therefore from
the observability tooling's perspective, drift is just another plan
diff — the only thing distinguishing it is that no corresponding
apply follows it in the same run, which the pipeline in the worked
example detects via the plan-only exit code, not via anything in the
plan's own content.
Exercise¶
A production security group rule was changed outside Terraform (someone edited it directly in the cloud console) three weeks ago, and it was never reconciled. Using only the observability mechanisms in this module — structured apply logs, archived plan JSON, audit-trail run metadata, and the scheduled drift-detection pipeline — describe how you would locate: (a) when the drift was first detected, and (b) whether any deliberate Terraform apply around that same time might have been the actual cause instead of an out-of-band console edit.