What is Antigravity CLI Observability?
Antigravity CLI is Google's terminal coding agent, the successor to Gemini CLI. Its agent loop plans and executes multi-step work: it reads and edits files, runs shell commands, calls MCP servers, and delegates to subagents. What it does not give you is any record of that work. Antigravity has no built-in OpenTelemetry exporter, and a request for one is still open at antigravity-cli#366.
This guide closes that gap using OpenTelemetry. Antigravity ships its own hooks system that fires on tool completion, on every pass of the agent loop, and when a turn ends. A small hook script turns those events into OTLP spans, and samples the weekly quota that Antigravity meters your usage against.
With Antigravity CLI observability in SigNoz, you can see which tools the agent reaches for, how many passes of the agent loop each turn takes, why turns end, which models are actually serving your requests, and how fast you are burning through your weekly quota.
Prerequisites
- SigNoz setup (choose one):
- SigNoz Cloud account with an active ingestion key
- Self-hosted SigNoz instance
- Antigravity CLI (
agy) on macOS or Linux - Python 3.11 or later
Monitor Antigravity CLI with OpenTelemetry
Antigravity exposes five hook events. This setup registers the three passive ones (PostToolUse, PostInvocation, and Stop) and converts each into a span.
Step 1: Install the exporter in its own environment so it does not touch your system Python.
python3 -m venv ~/.local/opt/agy-otel
~/.local/opt/agy-otel/bin/pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-httpStep 2: Save the following code in ~/.local/opt/agy-otel/hook.py.
#!/usr/bin/env python3
"""Antigravity CLI hook -> OpenTelemetry span exporter.
Registered per event in ~/.gemini/config/hooks.json. The event name is passed as
argv[1] because Antigravity's hook payloads carry no event-type field.
Only passive events are supported: PostToolUse, PostInvocation and Stop. PreToolUse
is deliberately excluded because its `decision` field is required and every legal
value changes permission behaviour, so a telemetry hook cannot observe it neutrally.
"""
import json, os, sys, time, hashlib
PASSIVE_OUTPUT = {"PostToolUse": {}, "PostInvocation": {}, "Stop": {}}
CONFIG_PATH = os.path.expanduser("~/.config/agy-otel/env")
def load_config():
"""Read OTLP settings from a config file.
Interactive `agy` sessions inherit whatever shell they were launched from, so
hooks cannot rely on exported env vars. Without this the SDK silently falls
back to localhost:4318 and every span is dropped with connection refused.
Real environment variables still win.
"""
try:
with open(CONFIG_PATH) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, val = line.split("=", 1)
os.environ.setdefault(key.strip(), val.strip())
except FileNotFoundError:
pass
QUOTA_STAMP = os.path.expanduser("~/.config/agy-otel/.quota-last")
QUOTA_MIN_INTERVAL = int(os.environ.get("AGY_OTEL_QUOTA_INTERVAL", "300"))
def _quota_due():
"""Rate-limit quota sampling: /usage costs ~5s and the value moves slowly."""
try:
if time.time() - os.path.getmtime(QUOTA_STAMP) < QUOTA_MIN_INTERVAL:
return False
except OSError:
pass
try:
os.makedirs(os.path.dirname(QUOTA_STAMP), exist_ok=True)
open(QUOTA_STAMP, "w").close()
except OSError:
pass
return True
def emit_quota(meter):
"""Sample `agy -p /usage` and record remaining quota per bucket.
Antigravity is a flat-rate subscription and never exposes per-token cost to
interactive sessions, so quota consumed is the usable spend signal. The
command starts no agent turn, spends no quota and fires no hooks, so it is
safe to call from inside a hook.
"""
import json as _json
import subprocess
out = subprocess.run(["agy", "-p", "/usage", "--output-format", "json"],
capture_output=True, text=True, timeout=60).stdout
data = _json.loads(out, strict=False).get("command", {}).get("data", {})
remaining = meter.create_gauge(
"agy.quota.remaining_fraction", unit="1",
description="Fraction of the Antigravity weekly quota still available")
reset_in = meter.create_gauge(
"agy.quota.seconds_to_reset", unit="s",
description="Seconds until the Antigravity quota window resets")
now = time.time()
for group in data.get("groups", []):
for bucket in group.get("buckets", []):
attrs = {
"agy.quota.group": group.get("name", ""),
"agy.quota.bucket": bucket.get("id", ""),
"agy.quota.window": bucket.get("window", ""),
}
frac = bucket.get("remaining_fraction")
if frac is not None:
remaining.set(float(frac), attrs)
reset = bucket.get("reset_time")
if reset:
try:
from datetime import datetime, timezone
ts = datetime.fromisoformat(reset.replace("Z", "+00:00"))
reset_in.set(max(0.0, ts.timestamp() - now), attrs)
except Exception:
pass
def emit(event, payload):
# Imported here, not at module scope: the OpenTelemetry SDK costs ~200ms to
# import and only the detached child needs it, so the agent loop never waits.
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.trace import (SpanKind, SpanContext, TraceFlags,
NonRecordingSpan, set_span_in_context)
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
os.environ.setdefault("OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", "delta")
conv = payload.get("conversationId") or "unknown"
resource = Resource.create({
"service.name": os.environ.get("OTEL_SERVICE_NAME", "antigravity-cli"),
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
tracer = provider.get_tracer("antigravity-cli-hooks")
# Hooks are separate processes, so group a conversation's spans by deriving a
# stable trace id from conversationId. The root span is never emitted, so the
# trace shows a missing-root placeholder; that is expected.
digest = hashlib.sha256(conv.encode()).hexdigest()
ctx = set_span_in_context(NonRecordingSpan(SpanContext(
trace_id=int(digest[:32], 16),
span_id=int(digest[32:48], 16),
is_remote=True,
trace_flags=TraceFlags(TraceFlags.SAMPLED),
)))
tool = payload.get("toolName") or (payload.get("toolCall") or {}).get("name")
if event == "PostToolUse":
name = f"execute_tool {tool or 'unknown'}"
op = "execute_tool"
elif event == "Stop":
name, op = "agy stop", "invoke_agent"
else:
name, op = "agy invocation", "invoke_agent"
now = time.time()
span = tracer.start_span(name, context=ctx, kind=SpanKind.INTERNAL,
start_time=int(now * 1e9))
span.set_attribute("gen_ai.operation.name", op)
span.set_attribute("gen_ai.provider.name", "gcp.gemini")
span.set_attribute("gen_ai.conversation.id", conv)
span.set_attribute("agy.hook.event", event)
if payload.get("modelName"):
span.set_attribute("gen_ai.request.model", payload["modelName"])
for key, attr in (("stepIdx", "agy.step.index"),
("invocationNum", "agy.invocation.num"),
("initialNumSteps", "agy.initial_num_steps"),
("executionNum", "agy.execution.num"),
("terminationReason", "agy.termination_reason"),
("fullyIdle", "agy.fully_idle")):
if payload.get(key) is not None:
span.set_attribute(attr, payload[key])
if tool:
span.set_attribute("gen_ai.tool.name", tool)
err = payload.get("error")
if err:
span.set_attribute("error.type", str(err)[:200])
span.set_status(trace.Status(trace.StatusCode.ERROR, str(err)[:200]))
span.end(end_time=int(now * 1e9))
provider.force_flush(2000)
provider.shutdown()
# Quota is the only spend signal available to interactive sessions.
if event == "Stop" and _quota_due():
try:
mp = MeterProvider(resource=resource, metric_readers=[
PeriodicExportingMetricReader(OTLPMetricExporter(), 60000)])
emit_quota(mp.get_meter("antigravity-cli-quota"))
mp.force_flush(5000)
mp.shutdown()
except Exception:
pass
def main():
load_config()
event = sys.argv[1] if len(sys.argv) > 1 else "PostInvocation"
try:
raw = sys.stdin.read()
payload = json.loads(raw) if raw.strip() else {}
except Exception:
payload = {}
# Antigravity runs hooks synchronously and blocks the agent loop, and a
# foreground OTLP export costs 0.5-1s per tool call. Fork so the parent can
# answer immediately and the child ships the span in the background.
try:
if os.fork() == 0:
os.setsid()
if os.fork() == 0:
try:
emit(event, payload)
finally:
os._exit(0)
os._exit(0)
else:
os.wait() # reap the short-lived intermediate child
except Exception:
pass # never break the agent loop on a telemetry failure
# Passive response: must be valid JSON and must not alter agent behaviour.
print(json.dumps(PASSIVE_OUTPUT.get(event, {})))
if __name__ == "__main__":
main()Step 3: Write the exporter configuration to ~/.config/agy-otel/env.
mkdir -p ~/.config/agy-otel
cat > ~/.config/agy-otel/env <<'EOF'
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.<region>.signoz.cloud:443
OTEL_EXPORTER_OTLP_HEADERS=signoz-ingestion-key=<your-ingestion-key>
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_SERVICE_NAME=antigravity-cli
EOF
chmod 600 ~/.config/agy-otel/envVerify these values:
<region>: Your SigNoz Cloud region.<your-ingestion-key>: Your SigNoz ingestion key.
Step 4: Register the hooks in ~/.gemini/config/hooks.json. Antigravity inherits Gemini CLI's configuration directory, so this path is correct despite the name.
{
"signoz-otel": {
"enabled": true,
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "~/.local/opt/agy-otel/bin/python ~/.local/opt/agy-otel/hook.py PostToolUse",
"timeout": 10
}
]
}
],
"PostInvocation": [
{
"type": "command",
"command": "~/.local/opt/agy-otel/bin/python ~/.local/opt/agy-otel/hook.py PostInvocation",
"timeout": 10
}
],
"Stop": [
{
"type": "command",
"command": "~/.local/opt/agy-otel/bin/python ~/.local/opt/agy-otel/hook.py Stop",
"timeout": 10
}
]
}
}Step 5: Restart agy, then run a prompt that uses a tool.
Antigravity reads hooks.json once at startup, so a running session will not pick up the change. Hooks also load only for trusted folders, so run agy from a directory you have trusted, otherwise the hooks are silently skipped.
View Antigravity CLI Traces in SigNoz
Allow a minute after your first prompt, then open the Traces explorer and filter on service.name = 'antigravity-cli'.

Opening a conversation shows every pass of the agent loop with the tools that ran between them, and the span attributes on the right.

Attributes Worth Knowing
Every span carries gen_ai.operation.name, gen_ai.provider.name, gen_ai.conversation.id, gen_ai.request.model, and agy.hook.event. Four details decide whether your queries are correct.
- Spans have zero duration.
PostToolUsecarries no timing, andPreToolUsecannot be registered safely, so these spans are point-in-time markers. You get tool frequency and sequence, not tool latency. gen_ai.request.modelis what ran, not what you asked for. Requestinggemini-3.1-pro-highsurfaces as the internal idgemini-pro-agent, which does not appear inagy modelsat all.agy.invocation.numcounts loop passes, not prompts. One turn commonly issues six or more model requests, so this is the signal to watch for a runaway agent.- A trace shows a missing root span. Hooks run as separate processes, so the script derives a stable trace id from the conversation id and parents every span to a root it never emits. The conversation groups correctly and every query works.
The Stop span adds agy.termination_reason, which is the closest thing Antigravity gives you to a failure signal. Values include NO_TOOL_CALL (the normal ending, where the model answered without calling a tool), ERROR, USER_CANCELED, MAX_INVOCATIONS, MAX_FORCED_INVOCATIONS, and MAX_TOKEN_BUDGET_EXCEEDED. The loop-cap reasons are the ones worth alerting on: they mean the agent hit its ceiling instead of finishing.
It is the only failure signal you have. Antigravity does not treat a non-zero shell exit as a tool error: running false produces a tool step with an empty error field, the turn still reports SUCCESS, and has_error is never set on any of these spans. An error rate built on them reads zero and implies everything is healthy, so use agy.termination_reason instead.
Tracking Quota Instead of Tokens
Antigravity is flat-rate and never exposes per-token counts to an interactive session, so quota is the spend signal. The Stop hook samples agy -p "/usage" and records two gauges:
agy.quota.remaining_fraction, the share of the weekly limit still availableagy.quota.seconds_to_reset, how long until the window rolls over
Both carry agy.quota.bucket, agy.quota.group, and agy.quota.window. Gemini models are metered separately from Claude and GPT-OSS, so you get a gemini-weekly bucket and a 3p-weekly bucket.
Sampling is rate-limited to once every 300 seconds, tunable with AGY_OTEL_QUOTA_INTERVAL.
Antigravity CLI Monitoring Dashboard
The Antigravity CLI dashboard turns these spans into quota burn-down, tool activity, agent loop volume, and turn outcomes.

Troubleshooting Antigravity CLI Observability
No spans arrive at all
Confirm the hooks loaded. Antigravity logs this at startup:
grep -i "named hooks" ~/.gemini/antigravity-cli/cli.logloaded 1 named hooks from 1 hooks.json file(s) means the file parsed. loaded 0 means it did not, and a failed to parse hooks.json line above it will say why.
If the hooks loaded but nothing reaches SigNoz, the exporter almost certainly cannot see its configuration. Run the hook by hand with a stripped environment, which is what an interactive session gives it:
echo '{"conversationId":"test","modelName":"test"}' | \
env -i HOME="$HOME" PATH=/usr/bin:/bin \
~/.local/opt/agy-otel/bin/python ~/.local/opt/agy-otel/hook.py PostToolUseIt should print {} with no connection errors. Errors mentioning localhost:4318 mean ~/.config/agy-otel/env is missing or unreadable.
Hooks are registered but never fire
Antigravity only loads hooks for trusted folders. Check that your working directory is listed:
cat ~/.gemini/antigravity-cli/settings.jsonAdd it under trustedWorkspaces, or start agy from a directory you have already trusted.
Every tool call is blocked
A PreToolUse hook is registered somewhere and is not returning a valid decision. Remove it. A PreToolUse handler that returns an empty object denies every tool call, and the agent reports tool call denied by pre-tool hook.
Tools are auto-denied in headless runs
Headless mode cannot prompt for permission, so any tool without an allow rule is denied. Antigravity gates by tool class, not just shell commands, so write_file, read_url, and read_file each need their own entry:
{
"permissions": {
"allow": ["command(echo)", "write_file(*)", "read_url(*)", "read_file(*)"]
}
}This lives in ~/.gemini/antigravity-cli/settings.json, not ~/.gemini/settings.json. The wrong file fails silently.
Quota panels stay empty
Quota is sampled only on the Stop event and at most once every 300 seconds. Complete a turn, wait a minute for ingestion, and confirm the stamp file was written:
ls -l ~/.config/agy-otel/.quota-lastRelated integrations
Instrument the other AI coding agents your team runs, using the same OpenTelemetry pipeline:
- Monitor Claude Code with OpenTelemetry
- Monitor OpenAI Codex with OpenTelemetry
- Cursor IDE observability with OpenTelemetry
- Qwen Code observability with OpenTelemetry
Browse all LLM observability integrations to instrument the rest of your stack.