← Todas as publicações

Publicação Técnica · Amazon Web Services

Observabilidade de agentes na AWS: rastreando a resposta até o chunk de origem, com o custo do rastro apurado

Alguém abre um chamado dizendo que o agente respondeu uma coisa que não está em lugar nenhum da base. Você tem o log da aplicação, tem a…

13 min de leitura2.888 palavrasSeções: 9Imagens: 1Blocos de código: 1630 ago 2026

Palavras-chave

Compartilhar
Comentar

Alguém abre um chamado dizendo que o agente respondeu uma coisa que não está em lugar nenhum da base. Você tem o log da aplicação, tem a latência, tem a contagem de tokens, e não tem como responder a única pergunta que importa: quais trechos o modelo estava lendo quando escreveu aquilo. O log diz que a chamada aconteceu. Ele não diz o que a chamada continha.

Este post monta a instrumentação que responde essa pergunta no Bedrock AgentCore, com Terraform, e apura quanto ela custa por mês em três regimes de captura.

O que este post entrega

  • Um agente no AgentCore Runtime emitindo spans para o CloudWatch, com Transaction Search habilitado via awscc.
  • Um esquema de atributos que liga a resposta ao chunk que a originou sem carregar o texto do chunk no span.
  • A conta de custo dos três regimes de captura, com preços de us-east-1 consultados em 30/08/2026.
  • Uma política de proteção de dados no log group dos spans, porque prompt de usuário é entrada livre.

Não cobre avaliação de qualidade. Rastro mostra o que aconteceu, não se a resposta estava certa. São dois problemas, e misturá-los é o erro mais comum nessa área.

Pré-requisitos

Terraform 1.9+, provider hashicorp/aws ~> 6.0 e hashicorp/awscc ~> 1.60, região us-east-1, aws-opentelemetry-distro >= 0.18.0 no agente. Permissões para criar log groups, política de recurso no CloudWatch Logs, papéis IAM e o runtime do AgentCore. O laboratório roda em menos de uma hora e o gasto fica abaixo de um dólar se você derrubar tudo no fim.

A arquitetura

cliente
     │  X-Amzn-Bedrock-AgentCore-Runtime-Session-Id
     ▼
┌─────────────────────────┐
│  AgentCore Runtime      │  ADOT auto-instrumenta o processo
│   agente (Strands/LC)   │  spans: invoke → retrieve → model → tool
└───────────┬─────────────┘
            │ OTLP
            ▼
  /aws/bedrock-agentcore/runtimes/<id>-<endpoint>
       stream: spans          stream: runtime-logs
            │
            ├──► política de proteção de dados (máscara na ingestão)
            │
            ├──► X-Ray: indexa N% como trace summary
            │
            └──► Application Signals / GenAI Observability

Por que estes componentes, um a um. AgentCore Runtime porque ele já entrega o span de sessão e o de memória sem código, e porque o session.id viaja no header de invocação, que é o que permite juntar quinze traces de uma conversa em uma unidade investigável. Transaction Search porque ele ingere 100% dos spans como logs estruturados e deixa a indexação em X-Ray como decisão separada; sem ele, você tem amostragem de X-Ray e uma busca que não alcança o span que interessa. Log group dedicado do agente em vez do aws/spans compartilhado, ativado por UNIFIED_TRACES_DESTINATION_ENABLED=true, porque retenção, política de proteção de dados e escopo de consulta passam a ser por agente; num destino compartilhado você aplica a política mais restritiva de todos a todo mundo, e paga varredura de consulta sobre spans de agentes que não têm nada com o caso. Política de proteção de dados porque a decisão de capturar conteúdo e a decisão de armazenar dado pessoal são a mesma decisão, tomada no mesmo lugar.

Implementação

1. Versões e tags

terraform {
  required_version = ">= 1.9"
  required_providers {
    aws   = { source = "hashicorp/aws", version = "~> 6.0" }
    awscc = { source = "hashicorp/awscc", version = "~> 1.60" }
  }
}
provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      Projeto     = "observabilidade-agente"
      Ambiente    = "lab"
      Responsavel = "cdiego"
    }
  }
}

Duas coisas separadas acontecem aqui. A política de recurso autoriza o X-Ray a escrever spans nos log groups; o recurso de configuração define quanto disso vira resumo indexado.

resource "aws_cloudwatch_log_resource_policy" "xray_spans" {
  policy_name = "TransactionSearchXRayAccess"
  policy_document = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "TransactionSearchXRayAccess"
      Effect    = "Allow"
      Principal = { Service = "xray.amazonaws.com" }
      Action    = "logs:PutLogEvents"
      Resource = [
        "arn:aws:logs:us-east-1:${local.account_id}:log-group:aws/spans:*",
        "arn:aws:logs:us-east-1:${local.account_id}:log-group:/aws/application-signals/data:*",
        "arn:aws:logs:us-east-1:${local.account_id}:log-group:/aws/bedrock-agentcore/runtimes/*",
      ]
      Condition = {
        StringEquals = { "aws:SourceAccount" = local.account_id }
        ArnLike      = { "aws:SourceArn" = "arn:aws:logs:us-east-1:${local.account_id}:*" }
      }
    }]
  })
}
resource "awscc_xray_transaction_search_config" "principal" {
  indexing_percentage = 5
  depends_on          = [aws_cloudwatch_log_resource_policy.xray_spans]
}

O wildcard em runtimes/* é sobre os log groups dos agentes desta conta, não sobre o CloudWatch inteiro. Se você tiver agentes de times diferentes na mesma conta e isso não for aceitável, liste os ARNs um a um e aceite o atrito de mudar a política a cada agente novo.

indexing_percentage = 5 é escolha, não padrão. O padrão do serviço é 1%, e esse 1% é gratuito. A seção de custo mostra o que os outros 4% compram e o que custam.

3. Log group do agente, com retenção declarada

resource "aws_cloudwatch_log_group" "agente" {
  name              = "/aws/bedrock-agentcore/runtimes/${local.agente_id}-DEFAULT"
  retention_in_days = 30
  log_group_class   = "STANDARD"
}

Classe STANDARD porque INFREQUENT_ACCESS custa metade na ingestão e não serve aqui: o caso de uso é consulta interativa durante incidente, que é exatamente o que a classe barata não oferece. Trinta dias porque investigação de agente acontece em janela curta; se o seu regulador pede mais, o caminho é exportar para o S3, não subir a retenção do log group.

4. Máscara antes de a consulta existir

resource "aws_cloudwatch_log_data_protection_policy" "agente" {
  log_group_name = aws_cloudwatch_log_group.agente.name
  policy_document = jsonencode({
    Name    = "mascarar-pii-em-spans"
    Version = "2021-06-01"
    Statement = [
      {
        Sid            = "Auditar"
        DataIdentifier = [
          "arn:aws:dataprotection::aws:data-identifier/EmailAddress",
          "arn:aws:dataprotection::aws:data-identifier/CpfCode-BR",
          "arn:aws:dataprotection::aws:data-identifier/PhoneNumber-BR",
        ]
        Operation = {
          Audit = { FindingsDestination = {} }
        }
      },
      {
        Sid            = "Mascarar"
        DataIdentifier = [
          "arn:aws:dataprotection::aws:data-identifier/EmailAddress",
          "arn:aws:dataprotection::aws:data-identifier/CpfCode-BR",
          "arn:aws:dataprotection::aws:data-identifier/PhoneNumber-BR",
        ]
        Operation = {
          Deidentify = { MaskConfig = {} }
        }
      },
    ]
  })
}

A declaração de auditoria existe para você descobrir o que está entrando, e a de mascaramento para impedir que fique legível. O CloudWatch traz identificadores gerenciados para CPF, CNPJ, RG, CEP e telefone brasileiros, o que cobre boa parte dos casos daqui; confira a lista completa antes de confiar nisto. Para dado que não tem identificador gerenciado, como número de contrato ou identificador interno de cliente, o mascaramento tem que acontecer no código, antes de o atributo virar span.

5. O runtime

resource "awscc_bedrockagentcore_runtime" "agente" {
  agent_runtime_name = "agente_rag_rastreavel"
  role_arn           = aws_iam_role.agente.arn
  agent_runtime_artifact = {
    container_configuration = {
      container_uri = "${aws_ecr_repository.agente.repository_url}:${var.tag_imagem}"
    }
  }
  network_configuration = { network_mode = "PUBLIC" }
  environment_variables = {
    AGENT_OBSERVABILITY_ENABLED         = "true"
    UNIFIED_TRACES_DESTINATION_ENABLED  = "true"
    OTEL_METRICS_EXPORTER               = "awsemf"
    RAG_INDEX_VERSION                   = var.versao_indice
    RAG_CAPTURAR_CONTEUDO               = "false"
  }
}

As duas últimas variáveis são minhas, não do serviço. RAG_INDEX_VERSION é o que faz o identificador de chunk continuar significando alguma coisa depois de uma reindexação. RAG_CAPTURAR_CONTEUDO é o interruptor entre os regimes que a próxima seção mede, e ele fica em false como padrão pelo mesmo motivo que as convenções GenAI do OpenTelemetry não capturam conteúdo por padrão.

6. O atributo que resolve o problema

import hashlib, json, os
from opentelemetry import trace
tracer = trace.get_tracer("agente.rag")
VERSAO_INDICE = os.environ["RAG_INDEX_VERSION"]
CAPTURAR = os.environ.get("RAG_CAPTURAR_CONTEUDO") == "true"
def recuperar(pergunta: str, kb_id: str):
    with tracer.start_as_current_span("rag.retrieve") as span:
        r = cliente.retrieve(
            knowledgeBaseId=kb_id,
            retrievalQuery={"text": pergunta},
            retrievalConfiguration={"vectorSearchConfiguration": {"numberOfResults": 5}},
        )
        refs = [
            {
                "p": i,                                    # posição no contexto montado
                "u": x["location"]["s3Location"]["uri"],
                "s": round(x["score"], 4),
                "h": hashlib.sha256(x["content"]["text"].encode()).hexdigest()[:12],
            }
            for i, x in enumerate(r["retrievalResults"])
        ]
        # um atributo serializado, não um atributo por chunk:
        # cinco atributos separados viram cinco chaves indexadas e nenhum ganho de busca
        span.set_attribute("rag.chunks", json.dumps(refs, separators=(",", ":")))
        span.set_attribute("rag.index_version", VERSAO_INDICE)
        span.set_attribute("rag.k", 5)
        if CAPTURAR:
            span.set_attribute("rag.chunks_text", json.dumps([x["content"]["text"] for x in r["retrievalResults"]]))
        return r, refs

Esse atributo fica por volta de 500 bytes para cinco chunks com URIs curtas, e no dobro disso quando os caminhos do S3 são longos. O equivalente com o texto dos mesmos cinco chunks fica entre 15 e 20 KB. A diferença entre os dois é a diferença entre as colunas B e C da tabela adiante.

Repare no que não está aqui: o span da chamada de modelo não repete os chunks. Ele é filho do mesmo trace, e o trace é a chave de junção. Duplicar o contexto em cada span é o multiplicador silencioso de custo nessa arquitetura, e a próxima seção mostra o tamanho dele.

7. IAM

data "aws_iam_policy_document" "agente" {
  statement {
    actions   = ["bedrock:Retrieve"]
    resources = [var.kb_arn]
  }
  statement {
    actions   = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"]
    resources = [var.modelo_arn]
  }
  statement {
    actions   = ["logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogStreams"]
    resources = ["${aws_cloudwatch_log_group.agente.arn}:*"]
  }
  statement {
    actions   = ["xray:PutTraceSegments", "xray:PutTelemetryRecords"]
    resources = ["*"]
  }
}

O :* no recurso de logs alcança os log streams dentro daquele grupo, e só. O "*" do X-Ray é diferente: essas duas ações não aceitam recurso, então o wildcard é obrigatório e vale a pena saber o que ele expõe, que é a capacidade de escrever segmentos de trace na conta inteira. Numa conta multi-time, isso significa que um agente comprometido pode poluir o rastro dos outros.

A prova: quanto custa por mês o rastro que leva até a origem

A pergunta. Um agente com um milhão de sessões por mês. Quanto custa manter rastreabilidade suficiente para pegar uma resposta suspeita e chegar ao trecho que a produziu, e quanto muda se eu guardar o texto dos trechos em vez do endereço deles?

O método. Isto é um cálculo com preços publicados, não uma medição. Preços de us-east-1 consultados na página de preços do CloudWatch em 30/08/2026. Premissas declaradas, todas substituíveis pelos seus números:

  • 1.000.000 de sessões por mês, 8 spans por sessão (invocação, três chamadas de modelo, dois retrieves, uma operação de memória, uma de gateway), totalizando 8.000.000 de spans.
  • Regime A, só metadados: 1,2 KB por span, 9,6 KB por sessão.
  • Regime B, metadados mais endereço do chunk (URI, score, hash de 12 caracteres, versão do índice): 2,5 KB por span, 20 KB por sessão.
  • Regime C, conteúdo completo: 85 KB por sessão, contando que o contexto recuperado reaparece em cada uma das três chamadas de modelo do mesmo trace.
  • 500 consultas de Logs Insights por mês varrendo 30 dias. Parece muito e não é: um painel que atualiza de hora em hora consome 720 sozinho, e uma investigação de incidente consome de 20 a 40.
  • Indexação em 100% dos spans, com o 1% gratuito descontado. Retenção de 30 dias.
  • Para a comparação com a inferência ao final: 4.000 tokens de entrada e 400 de saída por chamada de modelo, o que dá 12.000 milhões de tokens de entrada no mês.

A leitura. Três coisas saem daí.

A primeira é que a consulta custa mais que a ingestão em todos os regimes, e no regime C custa sete vezes mais. Todo mundo dimensiona observabilidade pelo que entra. A conta é dominada pelo que se varre depois, e varredura é proporcional ao tamanho do que você guardou, multiplicado pelo número de vezes que alguém pergunta. Guardar o texto do chunk não é caro uma vez. É caro quinhentas vezes por mês.

A segunda é que ingestão e indexação são alavancas independentes. Ingestão escala com o tamanho do span, indexação escala com a quantidade. Encher o span de conteúdo não muda a linha de indexação em um centavo, e reduzir a indexação de 100% para 5% não tira nada da linha de ingestão. São dois botões, e vi mais de uma equipe girar o errado.

A terceira é a proporção. A conta de inferência desse mesmo volume é 12.000 × p_entrada + 1.200 × p_saída, com os preços por milhão de tokens do seu modelo, que você consulta na página de preços do Bedrock na data em que fizer a conta. Só a parcela de entrada, a US$ 0,25 por milhão, já dá US$ 3.000, e aí o regime C fica em 8% dela. A US$ 3 por milhão, dá US$ 36.000, e o regime C cai para 0,7%. O que impede de ligar o rastro completo raramente é o preço, e é por isso que a decisão real desta arquitetura é sobre dado pessoal e sobre consulta, não sobre fatura de ingestão.

O que este número não prova. Os tamanhos de span são premissa, e são a variável mais sensível do modelo inteiro. Meça os seus antes de confiar na tabela, e a medição é gratuita: divida a métrica IncomingBytes pela IncomingLogEvents do log group do agente, no CloudWatch, com math de métrica. Dá o tamanho médio real do seu span em bytes, sem varrer nada. Se ele vier em 6 KB no regime B em vez de 2,5 KB, a coluna inteira triplica e a conclusão sobre a dominância da consulta fica mais forte, não mais fraca.

O cálculo também não prova que o regime B responde a pergunta na sua base. Ele responde se, e apenas se, o endereço continuar resolvendo. É o assunto da próxima seção.

Onde isso dói

A reindexação apaga o rastro sem apagar nada. Você guardou URI e posição do chunk. A base foi reprocessada com outro tamanho de janela, os limites mudaram, e o identificador agora aponta para um texto diferente do que o modelo leu. O rastro continua lá, íntegro, e mentindo. Por isso o hash e a rag.index_version no span: o hash detecta a divergência, a versão explica. Sem os dois, o regime B vira teatro.

O limite de evento do CloudWatch Logs é 1.024 KB e não é ajustável. Um span com histórico longo mais vinte chunks passa disso. O que acontece é o pior desfecho possível: o rastro desaparece justamente na sessão anômala, que é a única que você queria ver. Se você opera no regime C, trunque o conteúdo no código, com o tamanho declarado como atributo, em vez de descobrir o limite pela ausência.

A indexação padrão de 1% engana. Habilitar Transaction Search dá a sensação de busca sobre tudo. Os spans estão todos no log, sim. Os resumos de trace no X-Ray, não: 1% deles. A busca por trace encontra o que foi indexado, e a diferença entre “está armazenado” e “está pesquisável” só aparece no meio de um incidente.

O session.id não atravessa processo sozinho. Se o agente chama outro serviço, propague por baggage. Sem isso você tem dois traces órfãos e nenhuma sessão.

Amostragem de cabeça descarta a sessão inteira. Se precisar amostrar, amostre por sessão e não por span, e force 100% quando houver sinal: erro, guardrail acionado, feedback negativo. A sessão que interessa é sempre a que a amostragem aleatória descarta.

Custo e operação

Regime B, indexação em 100%, retenção de 30 dias, us-east-1, preços consultados em 30/08/2026.

No cenário de pico, indexação a 5% em vez de 100% leva aquela linha de US$ 59,40 para US$ 2,40, e a linha de Logs Insights passa a responder por 86% de uma conta de US$ 552. Painel recorrente sobre Logs Insights é o item mais caro dessa arquitetura, e a saída é conhecida: filtro de métrica para o que é observado sempre, consulta para o que é investigado às vezes.

O custo que não aparece na fatura: alguém precisa manter o esquema de atributos, e esquema de atributo é contrato. No dia em que um time renomeia rag.chunks para retrieval.chunks, todas as consultas salvas param, silenciosamente, e ninguém percebe até o próximo incidente. Isso entra no runbook ou vira dívida.

Quando NÃO usar

  • Abaixo de 50.000 sessões por mês, com um time só. O rastro custa poucos dólares e o pipeline custa semanas de atenção. O console do Bedrock e o log de aplicação resolvem, até o primeiro incidente que exija reconstituir uma sessão inteira. Aí volte aqui.
  • Quando o dado do prompt não pode residir no CloudWatch. Se o regime de retenção ou de residência não fecha, nenhuma máscara resolve, porque a máscara age na ingestão e não muda onde o dado fica. Alternativa: coletor OTEL próprio, exportando para um destino que você controla, aceitando perder a integração pronta com Application Signals.
  • Quando a pergunta é sobre qualidade, não sobre origem. Rastro não diz se a resposta estava certa. Se o que você quer é medir correção factual ou relevância, o instrumento é harness de avaliação com conjunto rotulado, e ele roda fora do caminho de produção.
  • Quando o requisito é busca por atributo arbitrário em retenção longa. O par ingestão mais Logs Insights fica caro rápido nesse desenho, e a conta acima mostra por onde. Alternativa: exportar spans para o S3 em Parquet e consultar com Athena, trocando latência de consulta por preço.

Fechando

Guardar o endereço do chunk responde a mesma pergunta que guardar o chunk, por um quarto do preço, sob uma condição que quase ninguém escreve: o endereço tem que continuar resolvendo depois da próxima reindexação. Quem cuida disso não é a plataforma de observabilidade, é a disciplina de versionamento da base de conhecimento.

O que me interessa na conta acima não é o total. É que a linha dominante seja a consulta, e não a coleta. Instrumentação de agente vem sendo dimensionada como se o problema fosse o que entra, e o problema é quantas vezes você vai precisar perguntar.

Até o próximo post!

Referências:

Comentários

Todo comentário passa por moderação antes de aparecer aqui. Nada é publicado automaticamente.

Carregando…