Designing Antigravity Agent Traces That Tell You Why It Failed — Observability in Practice
Run Antigravity agents long enough and unreadable failure logs pile up fast. This piece walks span structure, attribute design, failure tagging, dashboards, cost visibility, and retry policy — backed by six months of production metrics — so you can cut post-incident debugging time in half.
It took six months of running Antigravity agents in production for the first piece of operational debt to show: failure traces I could not read. Early on I sprinkled console.log calls across the agent loop, and when execution volume was a few dozen runs per day I could scroll. Once it crossed a few thousand runs per month, errors were happening but I could no longer tell which step of which cycle had broken.
After six months of investing in observability I am at the point where three lines of a trace usually point at the cause. Mean time to resolution (MTTR) for incidents dropped from 47 minutes pre-instrumentation to 11 minutes today. This article shares the trace design I run against my own Antigravity agents — span structure, attributes, failure tagging, dashboards, and retry policy — at a level of detail you can copy into your own repo.
Having shipped mobile apps since 2014, I remember it taking three months on my first wallpaper app to make production crash reports actually readable. Instrumenting agents was the same job in a new costume.
Why agents fall into the "I cannot tell why it failed" hole
The patterns I have observed cluster into three causes.
First, fuzzy step boundaries. Agents cycle through "tool call → LLM inference → state update," and without logging that boundary you cannot tell which step of which cycle blew up. Time-stamped flat logs hide the cycle structure.
Second, opaque inputs and outputs. Teams log "what happened inside the LLM call" but not the context passed in or the raw response returned. Failures collapse into "the LLM said something weird" and become unreproducible.
Third, no failure taxonomy. Errors are logged, but you cannot tell after the fact whether the failure was retryable, deserved a user notification, or was a plain code bug. That triggers alert fatigue and broken retry policies.
Observability design is about wiring structure for these three up front.
The base unit of a trace — pick a span structure
I align with OpenTelemetry semantics and represent agent execution like this. The shape is generic, not Antigravity-specific, but inserting a "cycle" level matters.
- agent.run (root span — one full agent execution)
- agent.cycle (a single reasoning + execution cycle)
- agent.plan (LLM picks next action)
- agent.tool_call (tool execution — separate span per call)
- agent.observe (folding the tool result back into context)
- agent.cycle
- ...
Making agent.cycle its own span is the load-bearing decision. When an agent runs eight cycles and fails on the fifth, without cycle-level spans a human has to walk the log to recover the fact that "cycle five hit a tool error."
Implementing this is just wrapping the cycle in tracer.start_as_current_span("agent.cycle"). In Python:
from opentelemetry import tracefrom opentelemetry.trace import Status, StatusCodetracer = trace.get_tracer("antigravity.agent")def run_agent(task): with tracer.start_as_current_span("agent.run") as run_span: run_span.set_attribute("task.id", task.id) run_span.set_attribute("task.kind", task.kind) run_span.set_attribute("agent.policy", task.policy_name) run_span.set_attribute("agent.tools_available", list(task.tools)) for cycle_idx in range(MAX_CYCLES): with tracer.start_as_current_span("agent.cycle") as cycle_span: cycle_span.set_attribute("cycle.index", cycle_idx) try: action = plan_next_action(task) if action is None: cycle_span.set_attribute("cycle.terminated", True) run_span.set_attribute("result.status", "success") return success(task) result = execute_tool(action) observe(task, result) cycle_span.set_attribute("cycle.action_type", action.kind) except AgentError as e: cycle_span.set_status(Status(StatusCode.ERROR, str(e))) cycle_span.set_attribute("result.failure_class", e.failure_class) run_span.set_attribute("result.status", "failure") run_span.set_attribute("result.failure_class", e.failure_class) raise
In TypeScript (Node.js) with @opentelemetry/api the same structure looks like:
One catch: always set cycle.index on agent.cycle. You will end up writing the query "show me what happened in the third cycle."
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦OpenTelemetry-aligned four-level span structure (agent.run / agent.cycle / agent.plan / agent.tool_call) with copy-pasteable Python and TypeScript implementations
✦An eight-class failure_class taxonomy paired with a retry-policy and user-notification matrix you can drop into your repo today
✦Real production numbers from six months of operation — average cycle count 4.2 → 2.8, token consumption down 35%, MTTR 47 min → 11 min — plus three dashboard query examples
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
cost.input_tokens / cost.output_tokens — token usage for the whole task
cost.usd_estimate — estimated USD spend
On agent.cycle:
cycle.index
cycle.action_type — what the cycle chose to do
cycle.token_count — LLM tokens consumed
cycle.duration_ms — total cycle time
cycle.terminated — clean-termination flag
On agent.tool_call:
tool.name
tool.input_size — bytes or element count
tool.output_size
tool.error_kind — error classification when failing
tool.retry_count — retries inside this call
What I deliberately leave out is raw input and output text. Putting it in traces blows up storage costs and invites accidental PII leaks. I store raw I/O in object storage (Cloudflare R2 in my setup) keyed by trace ID, and put only the storage key onto the span.
def record_io(span, payload, kind): # Stash raw I/O in object storage; only the key goes on the span. key = f"{span.context.trace_id:032x}/{span.context.span_id:016x}/{kind}" r2_client.put_object(Bucket="agent-io", Key=key, Body=payload) span.set_attribute(f"io.{kind}.storage_key", key) span.set_attribute(f"io.{kind}.size_bytes", len(payload))
Once you have this in place, you find a trace in Grafana, copy io.input.storage_key, and pull the raw input in one query. Splitting "search → drill in" into two steps beats stuffing long strings onto spans. R2 storage is $0.015/GB/month; six gigabytes of raw I/O per month costs under $0.10.
Failure tags — result.failure_class
Lumping every agent failure into "error" is observability surrendering. I tag them like this:
tool_unavailable — external API down or auth expired
tool_misuse — bad input handed to the tool (prompt design issue)
model_refusal — LLM declined for safety reasons
model_hallucination — LLM tried to call a tool/function that does not exist
This taxonomy grows over time. I would not chase completeness on day one; I started with the five most frequent classes from a month of logs and dropped everything else into unknown_runtime_error, then revised monthly.
Once tags exist, dashboards can carve out "show me only tool_unavailable and validation_failed" — and that view directly informs retry policy and the wording of user-facing notifications.
Mapping failure_class to retry policy
The single biggest payoff of a failure taxonomy is that the policy of "how do we treat each failure" collapses into a table. Mine looks like this:
failure_class
Retry
Backoff
Notify
Open ticket
tool_unavailable
yes (max 3)
exponential (1s, 2s, 4s)
only if persistent
when >10 in 24h
tool_misuse
no
—
yes (immediate)
no (fix prompt)
model_refusal
no
—
yes (immediate)
no
model_hallucination
yes (max 1)
immediate
yes
weekly review
cycle_budget_exceeded
no
—
yes
no (rebudget)
token_budget_exceeded
no
—
yes
no (rebudget)
validation_failed
yes (max 2)
immediate
yes (after 2 fails)
weekly review
unknown_runtime_error
yes (max 1)
fixed 5s
yes (immediate)
open immediately
In Python I encode it as a dataclass-driven registry:
The point is to keep design decisions in one place. When you add a new failure_class, you only update this table — behavior across the whole agent shifts in lockstep. Once retry logic scatters across the codebase, the operational story falls apart fast.
Dashboard design — three views that pay for themselves
Once traces are flowing into Grafana or Honeycomb or similar, these three views are what I build first. They cut my incident response time roughly in half — concretely, MTTR went from 47 min to 11 min.
View 1 — failure trace list, last 24 hours. Spans where result.status = failure, grouped by result.failure_class. You see at a glance "what is breaking right now." When an Antigravity update changes behavior, a sudden surge in model_hallucination shows up here before users start writing in.
In LogQL on Loki:
sum by (failure_class) ( count_over_time( {service="antigravity-agent"} | json | result_status="failure" [1h] ))
View 2 — cycle count histogram. Take the maximum cycle.index per successful agent.run and bin it. When tasks that used to finish in three cycles start needing six, you are watching prompt design quietly degrade. After model upgrades I check this every day for the first week.
In Honeycomb:
WHERE name = "agent.run" AND result.status = "success"
VISUALIZE HEATMAP(cycle.index_max)
GROUP BY task.kind
TIME 24h
View 3 — per-tool error rate over time. For each tool.name, plot the percentage where tool.error_kind is non-null over time. A specific external API silently degrading shows up here. Twice now I have caught a payment provider's API drifting downward week over week and reached out to support before users noticed.
sum by (tool_name) (rate(agent_tool_call_total{error_kind!=""}[1h]))/sum by (tool_name) (rate(agent_tool_call_total[1h]))
There is no off-the-shelf Grafana template for these, but with the attributes above in place, an hour or two in the query editor is usually enough.
Cost visibility — one chart that shows "what is eating the wallet"
Agents drift into expensive-by-surprise pretty easily, so putting cost attributes on traces speeds up operational decisions. I attach cost.input_tokens, cost.output_tokens, and cost.usd_estimate to every agent.run and watch task-kind cost over time:
sum by (task_kind) ( rate(agent_run_cost_usd[1h]))
Within a week of putting this together, I could see that one specific task kind (internal-doc summarization) was burning 62% of total spend. Adding context compression — a small preprocessing pass that summarizes long quotations before they hit the agent — cut that kind's spend by 38%.
Whether you can close a "notice → improve" loop inside a week comes down to your measurement resolution.
Handling PII and audit-grade logging
The data agents touch is often personal. I treat any field that could contain PII (email, payment info, user-authored text) as never written to span attributes. Instead I wrap it:
Anything with possible PII is wrapped in a SecureBlob type and written to a dedicated store.
Spans only carry pii.storage_key and pii.kind.
During debugging, going from trace ID to the PII store requires a separate permission grant.
This is a minimal posture for GDPR / Japanese APPI, but operationally it structurally prevents "oops, that ended up in logs." It is a half-day implementation: one wrapper type plus enforced usage.
Four things became visible after running this for six months that I want to record.
Six in ten failures were tool_unavailable, and most of those concentrated on a single external API. Once visible, this turned into a concrete fix: a health check before that API call, with a fallback path. Before, all I had was the vague feeling that "things fail a lot."
model_hallucination doubled the week after model updates. Once I could see this, I baked a re-evaluation ritual into the model-update process and started updating prompts before the new model went live in production.
One specific task type used pathologically many cycles. Diagnosis: insufficient initial context, so the agent burned cycles gathering info it needed up front. A context refactor brought average cycle count from 4.2 to 2.8 and trimmed token consumption by 35%.
MTTR went from 47 to 11 minutes, which over the long term is the biggest win. Saving ~30 minutes per incident at 20 incidents a month is more than 10 hours a month. Observability is one of those areas where the first hour of investment is the highest-ROI hour you will spend.
Recommended adoption order
For anyone bootstrapping observability for the first time, here is the order I would take if I started over.
Add the agent.cycle span only (30 minutes) — drop a single tracer.start_as_current_span("agent.cycle") line into the loop. Cycle boundaries alone change how the system looks operationally.
Define five failure classes (1 hour) — pick the five most common failure modes from a recent month of logs and start tagging result.failure_class.
Build dashboard view 1 (1 hour) — the failure trace list. Just that view reshapes incident response.
Add cost attributes (30 minutes) — put cost.usd_estimate on agent.run and watch spend by task kind.
Three hours of investment gets you well past halfway to "the trace tells me why it failed." The other half is the polish that one month of operation gives you.
Observability is heavy to start, but committing one focused hour to span structure and failure tags, then implementing one cycle at a time, gets you to "the trace tells me why it failed" within a month. For your next agent build, start by adding a single agent.cycle span — the rest of the design will follow. I hope this is useful for anyone working on the same problem.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.