Add Observability to a Workflow: Log Input, Decision, Output
Three days after a workflow ships, it stops doing what it did on day one. The log line says the step failed, maybe with a stack trace, and that is the entire forensic record you have. You re-run the job, it works, and you learn nothing.
That is a logging design problem, not a code problem, and you can fix it in an afternoon by deciding up front what a step record contains.
Log three things per step, not one
A useful step record answers three questions: what arrived, why the step did what it did, and what left. The error is a fourth field, not the whole record.
- Input: the payload the step received, or a digest plus a short preview of it.
- Decision: the branch, threshold, model, template or tool the step chose, and the value that drove it.
- Output: what the step returned, or a digest plus preview.
- Error (optional): type and message, capped in length.
The cleanest way to guarantee these fields stay in sync with real behaviour is to wrap each step rather than sprinkling log calls inside it. The wrapper sees the actual input and actual output, so it cannot drift.
import hashlib, json, sys, time
from functools import wraps
MAX_PREVIEW = 2048
SECRET_KEYS = ("api_key", "authorization", "password", "token", "cookie")
def digest(value):
raw = json.dumps(value, default=str, sort_keys=True)
return {
"sha": hashlib.sha256(raw.encode()).hexdigest()[:12],
"bytes": len(raw),
"preview": raw[:MAX_PREVIEW],
}
def step(name):
def wrap(fn):
@wraps(fn)
def inner(ctx, payload):
started = time.monotonic()
record = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"run_id": ctx["run_id"],
"parent_step": ctx.get("parent_step"),
"step": name,
"attempt": ctx.get("attempt", 1),
"workflow_version": ctx["workflow_version"],
"input": digest(payload),
}
try:
output, decision = fn(ctx, payload) # every step returns (output, why)
record.update(decision=decision, output=digest(output), status="ok")
return output
except Exception as exc:
record.update(
status="error",
error={"type": type(exc).__name__, "msg": str(exc)[:300]},
)
raise
finally:
record["ms"] = round((time.monotonic() - started) * 1000)
print(json.dumps(record, default=str), file=sys.stderr, flush=True)
return inner
return wrap
One JSON line per step, to stderr, flushed immediately. If the process is killed mid-run, you still have every step that completed.
The decision record is the part people skip
Output alone tells you a ticket went to human review. It does not tell you whether that was correct, and three days later nobody remembers the threshold. Store the rule and the observed value together, so the record reads as a claim you can check.
{
"run_id": "r-8f3c",
"step": "route_ticket",
"decision": {
"rule": "confidence >= 0.80 -> auto_send, else human_review",
"observed": {"confidence": 0.71, "classifier": "v3", "template": "reply-v7"},
"taken": "human_review"
}
}
Model names, prompt template versions, retrieved document IDs, retry reasons and fallback paths all belong in this object. When a workflow degrades quietly, the decision field is what tells you it was the prompt version that changed on Tuesday, not the code. That is the same reasoning behind triaging a failing workflow before you rewrite it: you need the evidence before you need the fix.
Keep the logs small enough to read
A log nobody reads is a compliance artefact. The failure mode is not too few bytes, it is a thousand lines of pretty-printed prompt text that make grep useless. Pick a storage mode per step and be honest about the tradeoff.
| Storage choice | What you can still answer 3 days later | Risk | Use it for |
|---|---|---|---|
| Full payload | Everything, including fields you never anticipated | Secrets, PII, unreadable volume | Low-volume internal steps |
| Digest + preview | Shape, key fields, whether two runs had identical input | Preview may cut the field you need | Default for most steps |
| Digest only | Whether inputs matched, nothing about content | Debugging stalls immediately | High-volume or sensitive steps |
Set the rules in one config file rather than per step, so nobody has to remember them at 2am.
logging:
format: jsonl
destination: file
always_fields: [ts, run_id, parent_step, step, attempt, status, ms, workflow_version]
payloads:
mode: preview # full | preview | hash
preview_bytes: 2048
redact_keys: [api_key, authorization, password, token, cookie]
sampling:
success_rate: 0.1 # keep a slice of successful runs as a baseline
keep_on_error: true
retention:
hot_days: 14
cold_days: 90
Sampling successes matters more than it sounds. You cannot judge an anomalous run without a sense of a normal one, and keeping every successful full payload is how log stores become expensive. Keep all failures, sample the rest.
Then write the two queries you will actually need, and commit them so they are not re-derived under pressure.
# Everything that happened in one run, in order
jq -c 'select(.run_id=="r-8f3c") | {ts, step, status, ms}' logs/workflow.jsonl
# Every failing run and the step it died on
jq -s 'map(select(.status=="error"))
| group_by(.run_id)
| map({run_id: .[0].run_id, died_at: (map(.step)|last)})' \
logs/workflow.jsonl
The field you must never leave out because it is obvious
The run_id. It feels redundant: your framework creates the run, the job name is in the log, timestamps are there. Then a fan-out step spawns four parallel branches, one of them calls a sub-agent, that sub-agent calls an external HTTP API, and a human reviewer picks up a queue item. Without a run identifier threaded through every one of those hops, you cannot reconstruct the sequence, only a pile of timestamped fragments.
Treat run_id as plumbing, not metadata:
- Generate it once at the entry point, and pass it in the context object, never as a module-level global.
- Send it as an outbound header (
X-Run-Id) on every external call, so the vendor’s logs and yours can be joined. - Put it in human review tickets, error alerts and webhook payloads.
- Use the same value as your idempotency key, so retries are traceable to the original.
- Add
parent_stepfor fan-out, so the tree is recoverable rather than a flat list.
If you are working inside a visual builder, check whether the step identity it exports is stable across edits; that difference is central to running one in production, as covered in visual workflow builders vs scripts. A step name that changes when you rename a box is not an identifier.
What to do next
- Add
run_id,step,statusandmsto every step this week, even before you add payload logging. Then take one failing run from the last month and check you can find all of its lines with a singlegrep. - Make every step return a
decisionobject, not just an output. Start with the three steps that involve a model call or a branch, since those are the ones that degrade silently. - Switch payload logging to preview mode with a 2 KB cap and a redaction list covering
api_key,authorization,tokenandcookie. - Commit a
scripts/trace.shwith your twojqqueries so the next incident starts with a command, not a discussion. - If you want the trace structure already present in the workflow definition, AI Workflow Builder ($99 USD, one-time) turns plain-English prompts into validated multi-agent workflow definitions you can inspect, version and run, available at https://slashmaster6.gumroad.com/l/amwkf.
Get AI Workflow Builder
AI Workflow Builder โ $99, one-time payment, instant download. See the full breakdown on the review page.
Related reading
- The Ultimate Guide to AI Automation 2026
- How I Built a 10-Product Digital Business
- Building an AI Content Factory
๐ Want to try before you buy?
Both samples are 100% free. Enter your email to unlock the download links and get our 3-day AI toolkit drip in your inbox.
No spam. Unsubscribe anytime. We respect your privacy.
Or browse the full catalogue in the Slashman Tools storefront.
Published by slashman413 โ writing practical, evergreen guides on money, productivity, developer tooling and the web. More about this site โ
Frequently Asked Questions
How long should I keep workflow logs?
Keep a short hot window you can search interactively, and a longer cold archive for audits or slow-burning bugs. Two weeks hot and a quarter cold covers most automations, and sampling successful runs keeps the volume manageable.
Should I log full prompts and responses?
Usually not in production. A digest plus a truncated preview answers most debugging questions, and full payloads bring secret-leak and volume problems. Switch individual steps to full logging temporarily when you are actively investigating.
What if my automation tool does not expose a run identifier?
Generate your own at the entry point and pass it through your context object, including into sub-workflows and outbound HTTP headers. Tool-level run IDs are convenient but you should not depend on them staying stable across edits.
๐ Recommended Tools
๐ Related Articles
- Every Product We Sell on Gumroad: The Complete CatalogEvery live Slashman Tools product on Gumroad in one place โ AI prompts, agent workflows, dashboards, courses โฆ
- What Makes a Feishu Template Worth Paying ForA breakdown of Feishu template parts: data model, views, permission scopes, automation flows โ and which ones โฆ
- Buying a Template vs Building Your Own: Variables That DecideA practical decision guide for buying a template versus building your own workspace: data model stability, โฆ
- n8n vs Custom Multi-Agent Workflows: When to Stop Dragging Nodes and Write Code (2026)n8n or a custom code multi-agent workflow? An honest 2026 comparison of versioning, testing, cost, LLM โฆ
๐ฌ Free Weekly AI Product Guides
New tools, templates and automation walkthroughs โ plus hands-on updates on the Slashman Tools catalogue. One email a week, zero fluff.
Free forever ยท No spam ยท Unsubscribe anytime ยท Sent instantly