💡 Why toCyberIntermediate✨ AI-assisted

Why You Should Alert on Agent Tool Calls, Not Just Errors

WittyTech Daily··5 min read
#ai-agents#observability#opentelemetry#security

Your error dashboard is green, the agent answered every request, and somewhere in those logs it quietly mailed a customer export to a stranger. That is the case to alert on agent tool calls: a hijacked agent isn't failing, it's obeying, and every tool call it makes returns a clean 200. This piece makes the argument, then gives you the span attributes and Prometheus rules to catch it.

Why error alerts can't detect agent misuse

Error-rate alerts watch for things that break. A prompt-injected or misused agent breaks nothing. Simon Willison's lethal trifecta describes the setup: an agent with access to private data, exposure to untrusted content and a way to communicate externally can be tricked into leaking that data. Every step of that attack is a successful call.

So the signal isn't in the status code. It's in which tool was called, how often, in what order and at what hour. That is agent tool call monitoring, and it needs its own alerts, separate from latency and error budgets.

Record every tool call as a span

OpenTelemetry's GenAI conventions define an execute_tool span. It is named execute_tool {gen_ai.tool.name}, has kind INTERNAL, and requires gen_ai.operation.name and gen_ai.tool.name. gen_ai.agent.name, gen_ai.conversation.id and gen_ai.tool.call.id are set when you have them. Arguments and results are opt-in because they can hold sensitive data. These conventions are still marked Development, so expect renames.

The spec has no attribute for where a call goes, so add your own. I use app.tool.destination_host, hostname only.

from opentelemetry import trace
from opentelemetry.trace import SpanKind

tracer = trace.get_tracer("agent.tools")


def run_tool(fn, *, name, call_id, agent, conversation_id, destination_host=None, **kwargs):
    attributes = {
        "gen_ai.operation.name": "execute_tool",
        "gen_ai.tool.name": name,
        "gen_ai.tool.call.id": call_id,
        "gen_ai.agent.name": agent,
        "gen_ai.conversation.id": conversation_id,
    }
    if destination_host:
        attributes["app.tool.destination_host"] = destination_host  # custom, not in the spec

    with tracer.start_as_current_span(
        f"execute_tool {name}", kind=SpanKind.INTERNAL, attributes=attributes
    ):
        return fn(**kwargs)

Wrap every tool in this one function so no tool ships without a span. Setting the name and operation at span creation, as the spec recommends, also lets a sampler keep every tool span even if you sample the rest.

Turn tool spans into AI agent observability alerts

The Collector's span metrics connector counts spans into traces.span.metrics.calls, and it can split that count by any span attribute you list under dimensions.

connectors:
  span_metrics:
    metrics_flush_interval: 60s
    dimensions:
      - name: gen_ai.operation.name
      - name: gen_ai.tool.name
      - name: gen_ai.agent.name

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlp/tracing-backend, span_metrics]
    metrics/tool-calls:
      receivers: [span_metrics]
      exporters: [prometheusremotewrite]

The receivers and exporters are defined elsewhere in your config. Older Collector releases called this connector spanmetrics, so check your version. Prometheus will typically see traces_span_metrics_calls_total with labels such as gen_ai_tool_name, but confirm the exact names in your setup before pasting the rules below.

Three rules to alert on agent tool calls

These are simple agent anomaly detection rules. Each compares an agent against its own history, so there is no model to train.

groups:
  - name: agent-tool-calls
    rules:
      - alert: AgentToolCallRateSpike
        expr: |
          sum by (gen_ai_agent_name, gen_ai_tool_name) (
            rate(traces_span_metrics_calls_total{gen_ai_operation_name="execute_tool"}[5m])
          ) > 0.2
          and
          sum by (gen_ai_agent_name, gen_ai_tool_name) (
            rate(traces_span_metrics_calls_total{gen_ai_operation_name="execute_tool"}[5m])
          )
          > 5 * sum by (gen_ai_agent_name, gen_ai_tool_name) (
            rate(traces_span_metrics_calls_total{gen_ai_operation_name="execute_tool"}[1h] offset 1d)
          )
        for: 5m
        labels:
          severity: page
        annotations:
          summary: "{{ $labels.gen_ai_agent_name }} is calling {{ $labels.gen_ai_tool_name }} at over 5x its usual rate"

      - alert: AgentCalledNewTool
        expr: |
          sum by (gen_ai_agent_name, gen_ai_tool_name) (
            traces_span_metrics_calls_total{gen_ai_operation_name="execute_tool"}
          )
          unless
          sum by (gen_ai_agent_name, gen_ai_tool_name) (
            last_over_time(traces_span_metrics_calls_total{gen_ai_operation_name="execute_tool"}[14d] offset 10m)
          )
        labels:
          severity: ticket
        annotations:
          summary: "{{ $labels.gen_ai_agent_name }} called {{ $labels.gen_ai_tool_name }} for the first time in 14 days"

The rate rule catches loops and bulk exfiltration, and the 0.2 calls per second floor keeps quiet tools from flapping. The new-tool rule fires on a pair that has no series in the previous 14 days, even if the first call is the only one. It needs 14 days of retention, and it also fires when a deploy adds a tool, which is why it files a ticket instead of paging.

      - alert: AgentOutboundToolsOffHours
        expr: |
          sum by (gen_ai_agent_name, gen_ai_tool_name) (
            rate(traces_span_metrics_calls_total{gen_ai_operation_name="execute_tool", gen_ai_tool_name=~"send_email|http_post|run_sql_write"}[15m])
          ) > 0
          and on() (hour() >= 22 or hour() < 6)
        for: 10m
        labels:
          severity: page
        annotations:
          summary: "{{ $labels.gen_ai_agent_name }} is using {{ $labels.gen_ai_tool_name }} outside business hours (UTC)"

Swap in your own outbound and write tool names. PromQL's hour() is UTC, so shift the window to your customer's working day.

New destinations and sequences belong in a trace query, not a metric. An attacker picks the hostnames, so a hostname label can grow without limit and bloat your metrics store. Query app.tool.destination_host in your tracing backend instead. Use gen_ai.conversation.id to flag a conversation where a data-read tool is followed by an outbound call to a host that agent has never contacted.

The strongest objection: authorization should stop this

It should, and it comes first: a minimal tool set, scoped credentials and human approval for high-impact actions. Alerts don't replace any of that. They cover the gap where a permitted tool is used for the wrong purpose, which is exactly where authorization has nothing to say.

The other objection is noise. Agent text varies, but the set of tools it calls and their rough volume tend to be far steadier, and that is what makes a baseline workable. Page only on outbound and write tools, and send everything else to a ticket queue.

Start with three tools

Pick the three tools that can send data or money out of the building. Emit execute_tool spans for them, add the rate and new-tool rules at ticket severity, and tune for two weeks before anything pages.

In production, an agent that can act on your systems needs a detector that watches what it does, because a successful call is the one thing error alerts will never flag.

← More in Cyber