Grafana Observability and Security for LLM Agents: OpenTelemetry on AWS Bedrock, GCP Vertex AI and Microsoft Foundry

10 min read5.5k

An agent is a loop. It plans, calls a model, calls a tool, reads the result, calls the model again, and stops when it decides it is done or runs out of budget. When that loop misbehaves in production, the symptom is usually vague: it was slow, it cost too much, it said something it should not have. HTTP status codes tell you almost nothing about any of that.

This post sets up observability for agents that use more than one model provider. The approach is the same on AWS Bedrock, GCP Vertex AI and Microsoft Foundry (formerly Azure AI Foundry): wrap each model and tool call in an OpenTelemetry span that follows the GenAI semantic conventions, export traces and metrics to Grafana, and build panels for the things that actually go wrong. Security is part of the design from the start, because the data you are tempted to log is exactly the data you should be careful with.

Grafana dashboard for LLM agent observability showing tokens per second by provider, spend, p95 agent latency, guardrail blocks and a trace waterfall

What to measure

Model calls are different from normal service calls in four ways. They are slow and variable, they cost money per token, they can be blocked by safety filters, and their inputs and outputs are sensitive. That gives you a short list of signals:

SignalWhy it mattersSource
Input and output tokensCost and context-window pressureModel response usage block
Time to first tokenWhat users feel on streaming responsesClient timing
Operation durationWhole-call latency, retry detectionSpan duration
Tool call success and durationAgents fail in tools more than in modelsTool spans
Guardrail interventionsSafety posture and false positivesProvider guardrail response
Loop iterations per taskRunaway agents, cost blow-upsCount of model spans per run
Errors by typeThrottling, timeouts, content filtersSpan status and error attributes

The conventions to follow

OpenTelemetry has a set of GenAI semantic conventions. They were still marked as development status when I wrote this, so expect attribute names to shift between releases and pin your instrumentation library version. The names I use below are the stable-looking core:

AttributeExampleNotes
gen_ai.operation.namechatOperation type
gen_ai.provider.nameaws.bedrock, gcp.vertex_ai, azure.ai.inferenceOlder releases used gen_ai.system
gen_ai.request.modelclaude-sonnet, gemini-2.5-flashWhat you asked for
gen_ai.response.modelmodel id returnedWhat actually served it
gen_ai.usage.input_tokens812Integer
gen_ai.usage.output_tokens204Integer
gen_ai.response.finish_reasons["end_turn"]Array of strings

Standard metrics from the same conventions: gen_ai.client.token.usage (histogram, split by token type) and gen_ai.client.operation.duration (histogram in seconds). Content capture, meaning the prompt and completion text, is opt-in by design. Keep it off by default and read the security section before turning it on.

Architecture

Instrumenting the model call

One wrapper handles all three providers. The provider-specific code only extracts tokens and finish reasons from its own response shape.

python
# llm_tracing.py
import time
from contextlib import contextmanager
from opentelemetry import trace, metrics

tracer = trace.get_tracer("agent.llm")
meter = metrics.get_meter("agent.llm")

token_usage = meter.create_histogram(
    "gen_ai.client.token.usage", unit="{token}",
    description="Input and output tokens per call")
op_duration = meter.create_histogram(
    "gen_ai.client.operation.duration", unit="s",
    description="End to end model call duration")
guardrail_hits = meter.create_counter(
    "agent.guardrail.interventions", description="Guardrail blocks and redactions")

@contextmanager
def llm_span(provider: str, model: str, operation: str = "chat"):
    start = time.monotonic()
    attrs = {"gen_ai.operation.name": operation,
             "gen_ai.provider.name": provider,
             "gen_ai.request.model": model}
    with tracer.start_as_current_span(f"{operation} {model}", attributes=attrs) as span:
        try:
            yield span
        except Exception as exc:
            span.record_exception(exc)
            span.set_status(trace.StatusCode.ERROR, type(exc).__name__)
            span.set_attribute("error.type", type(exc).__name__)
            raise
        finally:
            op_duration.record(time.monotonic() - start, attrs)

def record_usage(span, provider, model, input_tokens, output_tokens, finish):
    span.set_attribute("gen_ai.usage.input_tokens", input_tokens)
    span.set_attribute("gen_ai.usage.output_tokens", output_tokens)
    span.set_attribute("gen_ai.response.finish_reasons", [finish])
    base = {"gen_ai.provider.name": provider, "gen_ai.request.model": model}
    token_usage.record(input_tokens, {**base, "gen_ai.token.type": "input"})
    token_usage.record(output_tokens, {**base, "gen_ai.token.type": "output"})

AWS Bedrock

python
import boto3
from llm_tracing import llm_span, record_usage, guardrail_hits

bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")

def call_bedrock(model_id: str, messages: list, guardrail_id: str | None = None):
    with llm_span("aws.bedrock", model_id) as span:
        kwargs = dict(modelId=model_id, messages=messages,
                      inferenceConfig={"maxTokens": 1024, "temperature": 0.2})
        if guardrail_id:
            kwargs["guardrailConfig"] = {"guardrailIdentifier": guardrail_id,
                                         "guardrailVersion": "DRAFT", "trace": "enabled"}
        resp = bedrock.converse(**kwargs)
        usage = resp["usage"]
        record_usage(span, "aws.bedrock", model_id,
                     usage["inputTokens"], usage["outputTokens"], resp["stopReason"])
        if resp["stopReason"] == "guardrail_intervened":
            guardrail_hits.add(1, {"provider": "aws.bedrock", "action": "block"})
        return resp

GCP Vertex AI

python
from google import genai
from llm_tracing import llm_span, record_usage

client = genai.Client(vertexai=True, project="my-project", location="us-central1")

def call_vertex(model: str, prompt: str):
    with llm_span("gcp.vertex_ai", model) as span:
        resp = client.models.generate_content(model=model, contents=prompt)
        u = resp.usage_metadata
        finish = resp.candidates[0].finish_reason.name if resp.candidates else "none"
        record_usage(span, "gcp.vertex_ai", model,
                     u.prompt_token_count, u.candidates_token_count, finish)
        return resp

Microsoft Foundry

python
import os
from azure.ai.inference import ChatCompletionsClient
from azure.core.credentials import AzureKeyCredential
from llm_tracing import llm_span, record_usage

foundry = ChatCompletionsClient(
    endpoint=os.environ["FOUNDRY_ENDPOINT"],
    credential=AzureKeyCredential(os.environ["FOUNDRY_KEY"]))

def call_foundry(model: str, messages: list):
    with llm_span("azure.ai.inference", model) as span:
        resp = foundry.complete(model=model, messages=messages)
        record_usage(span, "azure.ai.inference", model,
                     resp.usage.prompt_tokens, resp.usage.completion_tokens,
                     resp.choices[0].finish_reason)
        return resp

For production on Azure I would replace the key with DefaultAzureCredential so there is no secret to leak. The same applies to Vertex (workload identity) and Bedrock (IAM role), which already work that way.

Tool calls and the agent run

Tool spans are where agents most often fail, so give them equal treatment. One parent span per task makes the whole loop a single trace.

python
from opentelemetry import trace
tracer = trace.get_tracer("agent.loop")

def run_agent(task: str, max_steps: int = 8):
    with tracer.start_as_current_span("agent.run", attributes={"agent.name": "support-bot"}) as run:
        for step in range(max_steps):
            reply = call_model(task)                    # llm_span inside
            run.set_attribute("agent.steps", step + 1)
            if not reply.tool_calls:
                return reply.text
            for call in reply.tool_calls:
                with tracer.start_as_current_span(f"tool {call.name}",
                        attributes={"gen_ai.operation.name": "execute_tool",
                                    "gen_ai.tool.name": call.name}) as tspan:
                    try:
                        task = invoke_tool(call)
                    except Exception as exc:
                        tspan.record_exception(exc)
                        tspan.set_status(trace.StatusCode.ERROR)
                        task = f"tool error: {exc}"
        run.set_attribute("agent.stopped_reason", "max_steps")
        return "I could not finish this task."

The max_steps cap is an observability and cost control at the same time. When agent.stopped_reason="max_steps" shows up in traces, you have found a runaway loop.

The collector: redaction and routing

Do the sensitive work at the collector, not in every service. That gives you one place to audit.

yaml
# otel-collector.yaml
receivers:
  otlp:
    protocols: { grpc: { endpoint: 0.0.0.0:4317 } }

processors:
  memory_limiter: { check_interval: 1s, limit_mib: 512 }
  # Drop content attributes even if a developer turns capture on by mistake
  attributes/strip_content:
    actions:
      - { key: gen_ai.prompt,        action: delete }
      - { key: gen_ai.completion,    action: delete }
      - { key: gen_ai.input.messages,  action: delete }
      - { key: gen_ai.output.messages, action: delete }
  # Hash user identifiers instead of storing them
  attributes/hash_user:
    actions:
      - { key: enduser.id, action: hash }
  batch: {}

exporters:
  otlphttp/grafana:
    endpoint: https://otlp-gateway-prod-us-east-0.grafana.net/otlp
    headers: { Authorization: "Basic ${env:GRAFANA_OTLP_TOKEN}" }

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, attributes/strip_content, attributes/hash_user, batch]
      exporters: [otlphttp/grafana]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/grafana]

Swap the exporter for your own Tempo and Mimir endpoints if you self-host. The processors are the same.

Panels and queries

Metric names depend on how you ingest OTLP, so check the exact names in Grafana Explore before you copy anything. With OTLP into Mimir or Grafana Cloud, the histograms above become gen_ai_client_token_usage_* and gen_ai_client_operation_duration_seconds_*. These are the queries I start from:

PanelQuery
Output tokens/s by providersum by (gen_ai_provider_name) (rate(gen_ai_client_token_usage_sum{gen_ai_token_type="output"}[5m]))
Estimated spend per hoursum(rate(gen_ai_client_token_usage_sum{gen_ai_token_type="input"}[1h]) * 3600 * on(gen_ai_request_model) group_left() model_price_input_per_token) + sum(rate(gen_ai_client_token_usage_sum{gen_ai_token_type="output"}[1h]) * 3600 * on(gen_ai_request_model) group_left() model_price_output_per_token)
p95 call latencyhistogram_quantile(0.95, sum by (le, gen_ai_provider_name) (rate(gen_ai_client_operation_duration_seconds_bucket[5m])))
Guardrail blockssum by (provider, action) (increase(agent_guardrail_interventions_total[1h]))
Error rate by providersum by (gen_ai_provider_name) (rate(gen_ai_client_operation_duration_seconds_count{error_type!=""}[5m])) / sum by (gen_ai_provider_name) (rate(gen_ai_client_operation_duration_seconds_count[5m]))

For the price join, publish prices as a small recording rule or a static metric you control, for example model_price_output_per_token{gen_ai_request_model="..."} 0.000015. Pricing changes, and a number in a PromQL string goes stale quietly, so keep it in one maintained place. Treat spend as an estimate and reconcile it against the real invoice from each cloud.

For traces, TraceQL answers the questions metrics cannot:

traceql
// Agent runs that hit the step cap
{ name = "agent.run" && span.agent.stopped_reason = "max_steps" }

// Slow tool calls inside long runs
{ name = "agent.run" && duration > 15s } >> { span.gen_ai.operation.name = "execute_tool" && duration > 3s }

// Any model call that returned a guardrail intervention
{ span.gen_ai.response.finish_reasons = "guardrail_intervened" }

Alerts

Alert on symptoms people can act on:

yaml
groups:
  - name: llm-agents
    rules:
      - alert: LLMSpendSpike
        expr: llm:spend_per_hour:sum > 2 * avg_over_time(llm:spend_per_hour:sum[7d] offset 1h)
        for: 30m
        labels: { severity: page }
      - alert: GuardrailBlockSpike
        expr: sum(increase(agent_guardrail_interventions_total[15m])) > 3 * sum(increase(agent_guardrail_interventions_total[15m] offset 1d)) and sum(increase(agent_guardrail_interventions_total[15m])) > 10
        for: 10m
        labels: { severity: ticket }
        annotations:
          summary: "Guardrail blocks are 3x yesterday. Possible prompt-injection attempt or a bad prompt release."
      - alert: ProviderErrorRateHigh
        expr: llm:error_ratio:rate5m > 0.05
        for: 10m
        labels: { severity: page }

A guardrail spike is ambiguous. It might be an attack, or it might be your latest prompt change making the model produce content that trips a filter. Having both the dashboard and trace links in the alert lets whoever is on call tell the difference in a minute.

Security: what not to record

The dashboard above works without a single prompt or response being stored. That is deliberate.

RiskWhat goes wrongControl
Prompts contain PII or secretsTraces become a copy of sensitive data, readable by everyone with Grafana accessContent capture off by default, strip at collector
Tool arguments contain credentialsA tool call span stores a tokenRecord tool name and status only, not arguments
Prompt injectionA document tells the agent to call a tool it should notAllowlist tools per agent, log denied calls as a metric
Cost abuseOne user triggers loops that burn budgetPer-user token budgets, max_steps, spend alert
Over-broad Grafana accessJunior or external users see tracesFolder permissions, team-scoped data sources, label-based access

If you do need content for debugging, capture it for a sampled slice of non-production traffic, set a short retention, and restrict the Tempo data source to a small group. Record the decision and the owner in your runbook.

Wrap-up

Agent observability comes down to three habits. Wrap every model and tool call in a span with the GenAI attributes. Do redaction in the collector, not by trusting each developer. And build panels around the failure modes that are specific to agents: cost, loop depth, tool errors and guardrail interventions. The provider matters much less than you would expect, because once the wrapper normalizes tokens and finish reasons, the same dashboard covers all of them.

Resources