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…
Palavras-chave
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"
}
}
}
2. Transaction Search
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:
- Understand observability for agentic resources in AgentCore
- Add observability to your Amazon Bedrock AgentCore resources
- CloudWatch Transaction Search e Enable Transaction Search
- Amazon CloudWatch Pricing (consultado em 30/08/2026)
- CloudWatch Logs quotas
- Generative AI observability now generally available for Amazon CloudWatch
- Inside the LLM Call: GenAI Observability with OpenTelemetry
- awscc_xray_transaction_search_config e awscc_bedrockagentcore_runtime
Comentários
Todo comentário passa por moderação antes de aparecer aqui. Nada é publicado automaticamente.
Carregando…