Google Antigravity CLI Monitoring with OpenTelemetry Google's Antigravity CLI, the successor to Gemini CLI, lacks a built-in OpenTelemetry exporter, prompting a guide to monitor its agent loop using SigNoz and a custom hook script that converts tool completion, loop passes, and turn-end events into OTLP spans, while also sampling the weekly quota. The setup requires Python 3.11 or later, a SigNoz instance, and registers three passive hook events (PostToolUse, PostInvocation, Stop) to provide visibility into tool usage, loop passes, turn endings, model serving, and quota consumption. What is Antigravity CLI Observability? Antigravity CLI https://antigravity.google/ 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 https://github.com/google-antigravity/antigravity-cli/issues/366 . This guide closes that gap using OpenTelemetry https://opentelemetry.io/ . 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 https://signoz.io/teams/ 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-http Step 2: Save the following code in ~/.local/opt/agy-otel/hook.py . php /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.