# Observability Patterns — OpenTelemetry, enrichment, investigation

> **Scope:** stacks=["dotnet"]
> **Layer:** 1 (on-keyword)
> **Keywords:** observability, telemetry, opentelemetry, logging, tracing, log investigation, troubleshooting, aspire dashboard, span, correlation id
> **Read by Claude in:** implement (wiring telemetry into an agent) and troubleshoot (investigating a production issue)

**Verified against:** Microsoft.Agents.AI 1.20.0 + Microsoft.Extensions.AI 10.9.0 (ai-pin 2026-09-08); `provado por templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs` — o guard de span duplicado, a regra de "um nível, uma instrumentação" e o default `EnableSensitiveData = false` são exatamente o que o kit compila, com os spans contados em `tests/Morph.AiKit.Tests/TelemetryTests.cs`. Nomes de símbolo, de span, de métrica e das duas `ActivitySource` foram **medidos por reflexão e por leitura dos literais das assemblies do pin** em 2026-09-08, e em 2026-09-09 **todos os treze nomes `gen_ai.*` desta página** foram remedidos, um a um, por **teste de exclusividade**: os quatro `SetTag` da seção *Enrichment* e os nove das seções de span, métrica e atributo de agente. **Os treze existem** — depois de uma correção: `gen_ai.response.finish_reason`, no **singular**, não existia, só aparecia como cabeça de `finish_reason`**s**, e sobreviveu a duas varreduras porque as duas usaram `includes` — e **`includes` de um prefixo não prova o literal autônomo**. Os treze estão tabelados, em **duas** tabelas com propósitos diferentes: a de *Enrichment* registra os **4** `SetTag` daquele bloco (mais os **cinco** nomes rejeitados: o `finish_reason` singular, `PromptTokens`, `CompletionTokens`, `prompt_tokens` e `completion_tokens`), e a de distribuição, na seção *Nomes de span, métricas e atributos — medidos*, registra os **13** por assembly. A conferência é reproduzível por `node scripts/api-probe/probe.mjs tags` sobre este arquivo. A **instabilidade da convenção** — o split do semconv **v1.42.0 (2026-06-12)**, que depreciou e moveu todo `gen_ai.*` para `open-telemetry/semantic-conventions-genai`, onde nada GenAI está `Stable`, contra a v1.37 que o `OpenTelemetryChatClient` declara implementar — é **verificação documental**, apurada em 2026-09-08. Last-verified: 2026-09-08.

---

## Por que isso importa

Um agente MAF em produção não é uma função — é uma cadeia (LLM call → tool call → sub-agent → RAG/DB) que pode ter qualquer profundidade. Sem telemetria estruturada, um bug em produção ("o agente respondeu errado", "a automação não disparou") é indistinguível de uma caixa-preta: não dá pra saber qual chamada falhou, quanto cada etapa levou, ou qual tool retornou o quê. Este standard cobre as três partes do problema: **como instrumentar** (setup), **o que registrar** (enrichment) e **como investigar** (análise) — nessa ordem, porque setup sem enrichment produz traces vazios, e enrichment sem prática de investigação não ajuda ninguém no momento do incidente.

---

## Setup — OpenTelemetry

### Duas correções medidas contra a versão anterior deste standard

| O que este standard dizia | O que o pin diz (medido em 2026-09-08) |
|---|---|
| `AgentOpenTelemetryConsts.DefaultSourceName` e `agent.WithOpenTelemetry()` | **Nenhum dos dois existe** em `Microsoft.Agents.AI` 1.20.0. A API é `builder.UseOpenTelemetry(sourceName, configure)` sobre `AIAgentBuilder` / `ChatClientBuilder` |
| `EnableSensitiveTelemetryData` | O símbolo é **`EnableSensitiveData`**, e existe **nos dois** tipos: `OpenTelemetryAgent.EnableSensitiveData` e `OpenTelemetryChatClient.EnableSensitiveData`. Não são dois nomes diferentes: é o **mesmo nome em dois níveis** |
| *"não use `Experimental.Microsoft.Extensions.AI.*` — é do 9.x"* | São **duas fontes vivas e necessárias**: `Experimental.Microsoft.Agents.AI` (do agente) e `Experimental.Microsoft.Extensions.AI` (do chat e das tools). Quem só registra a primeira perde os spans `chat` e `execute_tool` |

Os dois nomes de `ActivitySource` foram lidos como literais nas assemblies `Microsoft.Agents.AI.dll` 1.20.0 e `Microsoft.Extensions.AI.dll` 10.9.0.

```csharp
using OpenTelemetry;
using OpenTelemetry.Trace;
using OpenTelemetry.Metrics;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

const string AgentSource = "Experimental.Microsoft.Agents.AI";      // span invoke_agent
const string ChatSource  = "Experimental.Microsoft.Extensions.AI";  // spans chat e execute_tool

// Instrumentar o AGENTE (span externo). EnableSensitiveData default = false.
AIAgent instrumented = agent.AsBuilder()
    .UseOpenTelemetry(sourceName: AgentSource, configure: o => o.EnableSensitiveData = false)
    .Build();

// Program.cs — setup completo
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing.AddSource(AgentSource);                             // sem isto: nenhum invoke_agent
        tracing.AddSource(ChatSource);                              // sem isto: nenhum chat / execute_tool
        tracing.AddSource(builder.Environment.ApplicationName);     // seus spans customizados (ver Enrichment)
        tracing.AddAspNetCoreInstrumentation();
        tracing.AddHttpClientInstrumentation();
        tracing.AddOtlpExporter();
    })
    .WithMetrics(metrics =>
    {
        metrics.AddMeter(ChatSource);                               // as métricas gen_ai.client.* saem daqui
        metrics.AddAspNetCoreInstrumentation();
        metrics.AddHttpClientInstrumentation();
        metrics.AddRuntimeInstrumentation();
    });

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeFormattedMessage = true;
    logging.IncludeScopes = true;           // necessário para correlação — ver Enrichment
    logging.AddOtlpExporter();
});
```

> **Produção:** `EnableSensitiveData = false` (é o default nos dois níveis) para não vazar prompt/response completos no backend de export.

### A regra de duplicação — um NÍVEL, uma instrumentação

A mudança de **1.6.1** (2026-05-14) foi o `OpenTelemetryAgent` passar a **auto-encapsular** o chat client com um `OpenTelemetryChatClient` no pipeline que o `ChatClientAgent` monta sozinho. Daí saiu a regra folclórica *"instrumente o agente **ou** o chat client, nunca os dois"* — que é **imprecisa**, e a diferença muda o que se escreve.

**O que foi medido no pin** (`Morph.AiKit.Tests/TelemetryTests.cs`, contra `Microsoft.Agents.AI` 1.20.0):

- Uma invocação instrumentada produz **`invoke_agent` (raiz) + `chat` (filho)**. Instrumentar os **dois níveis** é o caminho normal, e **não** duplica nada — o teste `AnInstrumentedAgentEmitsOneInvokeAgentSpanAndOneChatSpan` pina isso.
- O que duplica é **duas camadas no MESMO nível**: dois `OpenTelemetryChatClient` dão dois `chat`; dois `OpenTelemetryAgent` dão dois `invoke_agent` (`ForcingInstrumentChatClientProducesTheDuplicateChatSpan`).
- O guard do MAF **só alcança o pipeline que o `ChatClientAgent` monta sozinho**. Quem monta o próprio pipeline (`ChatClientAgentOptions.UseProvidedChatClientAsIs = true`, necessário para posicionar middleware de custo ou ajustar tool calling) **perde** esse slot: medido, agente instrumentado + pipeline próprio sem instrumentação de chat = **zero** spans de chat.

**A regra correta, então, é em duas linhas:**

1. **Instrumente cada nível no máximo uma vez.** O predicado é respondível, não um palpite: `inner.GetService(typeof(OpenTelemetryChatClient))` / `typeof(OpenTelemetryAgent)` atravessa a cadeia de delegates.
2. **Com pipeline próprio, a instrumentação de chat é obrigatória** — não é um extra opcional, é a única coisa que produz o span `chat`.

```csharp
// O guard, na forma que o ai-kit compila: nulo = instrumenta só o que ainda não está.
builder.Use(inner => inner.GetService(typeof(OpenTelemetryChatClient)) is null
    ? new OpenTelemetryChatClient(inner, logger: null, sourceName: ChatSource) { EnableSensitiveData = false }
    : inner);
```

**Onde o span de chat fica no pipeline:** **abaixo** do `FunctionInvokingChatClient`. É assim que o span `chat` fecha antes das tools e o `invoke_agent` fica sendo a raiz. Ver a ordem completa do pipeline em `ai-agents-middleware-patterns`.

### Nomes de span, métricas e atributos — medidos

| O que | Nome | Onde foi medido |
|---|---|---|
| Span do agente | `invoke_agent` | literal em `Microsoft.Agents.AI.dll` 1.20.0 |
| Span do modelo | operação + modelo (`gen_ai.operation.name` + `gen_ai.request.model`) | `Microsoft.Extensions.AI.dll` 10.9.0 |
| Span de tool | `execute_tool` | `Microsoft.Extensions.AI.dll` 10.9.0 |
| Métrica de duração | `gen_ai.client.operation.duration` | idem |
| Métrica de tokens | `gen_ai.client.token.usage` | idem |
| Métricas extras | `gen_ai.client.operation.exception`, `gen_ai.client.operation.time_per_output_chunk` | idem |
| Atributos do agente | `gen_ai.agent.id`, `gen_ai.agent.name`, `gen_ai.agent.description`, `gen_ai.provider.name` | `Microsoft.Agents.AI.dll` 1.20.0 |

A coluna "onde foi medido" nomeia **uma** assembly onde o literal ocorre, e **não** afirma
exclusividade. A distribuição completa, medida por exclusividade sobre **todas** as assemblies
restauradas do pin (`probe.mjs tags`, 2026-09-09) — repare que ela **não se limita às duas** que esta
página nomeia:

| Nome | Assemblies onde o literal ocorre |
|---|---|
| `invoke_agent` | `Microsoft.Agents.AI` **e** `Microsoft.Extensions.AI` |
| `execute_tool` | `Microsoft.Extensions.AI` **e** `ModelContextProtocol.Core` |
| `gen_ai.agent.id`, `gen_ai.agent.name`, `gen_ai.agent.description` | **só** `Microsoft.Agents.AI` |
| `gen_ai.provider.name` | `Microsoft.Agents.AI` **e** `Microsoft.Extensions.AI` |
| `gen_ai.operation.name` | `Microsoft.Agents.AI`, `Microsoft.Extensions.AI`, `ModelContextProtocol.Core` **e** `OpenAI` |
| `gen_ai.request.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.response.finish_reasons`, `gen_ai.client.operation.duration`, `gen_ai.client.token.usage` | `Microsoft.Extensions.AI` **e** `OpenAI` |
| `gen_ai.client.operation.exception`, `gen_ai.client.operation.time_per_output_chunk` | **só** `Microsoft.Extensions.AI` |

**Por que isto importa para quem instrumenta:** o mesmo nome emitido por camadas diferentes é o que
produz span duplicado — e é a razão de este standard começar pelo guard de "um nível, uma
instrumentação". `OpenAI` e `ModelContextProtocol.Core` estão sob o **mesmo pin** e carregam parte
destes nomes; se você registrar a fonte deles junto com a do agente sem o guard, o atributo chega
duas vezes com procedências diferentes.

> **Duas afirmações que NÃO se confirmaram, e por isso não estão acima.** `agent_framework.function.invocation.duration` e a variável de ambiente `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental` **não aparecem** em nenhuma das quatro assemblies do pin (`Microsoft.Agents.AI`, `.Abstractions`, `Microsoft.Extensions.AI`, `.Abstractions`), varridas por literal em 2026-09-08. A duração de invocação de função pode existir noutro pacote, e a variável é do SDK do OpenTelemetry, não destes pacotes — em nenhum dos dois casos este standard as prescreve sem fonte.

### Estabilidade da convenção GenAI — a ressalva que muda como ler a tabela acima

Os nomes acima são **medidos nas assemblies do pin**, e continuam corretos. O que não é estável é a
**convenção** que eles implementam, e um standard que manda instrumentar sem dizer isso está mandando
pintar alvo num muro que se move.

Os fatos, apurados em 2026-09-08:

- As **GenAI semantic conventions saíram do repositório principal** do OpenTelemetry. A versão
  **1.42.0 do semconv (2026-06-12)** depreciou e moveu todo o namespace `gen_ai.*`; a **1.43.0
  (2026-07-03)** não traz nenhum.
- O conteúdo passou a viver em `open-telemetry/semantic-conventions-genai`, e ali **nenhum** span,
  evento, métrica ou atributo GenAI está marcado `Stable` — todos estão em `Development`. Os únicos
  estáveis no conjunto são `error.type`, `server.address` e `server.port`, que são **atributos
  comuns**, não GenAI.
- Enquanto isso, o `OpenTelemetryChatClient` do `Microsoft.Extensions.AI` declara implementar a
  **v1.37** — ou seja, a versão implementada está **atrás** do split.

**O que isso implica na prática, e é o que se leva daqui:**

| Implicação | O que fazer |
|---|---|
| Nome de atributo `gen_ai.*` pode mudar entre versões do pacote | Não construa alerta cujo único identificador seja um nome `gen_ai.*` sem um plano de renomeação |
| A versão implementada pelo pacote **não** é a última da convenção | Ao subir o pin, confira a tabela de nomes acima em vez de assumir continuidade |
| Painel montado sobre o namespace instável envelhece com o pacote | Vale a pena manter uma camada própria de nomes (`morph.*`) para o que o time consulta todo dia |

Isto **não** é motivo para não instrumentar. É motivo para instrumentar sabendo que os nomes são de
uma convenção em desenvolvimento, e para não tratar uma renomeação futura como incidente.

### Limitação conhecida, em aberto

Issue **3637** do `microsoft/agent-framework`: o `UseOpenTelemetry()` do .NET **não emite `ActivityEvent`s** conforme a spec de eventos GenAI — o conteúdo de mensagem viaja como atributo, não como evento. Estado em 2026-09-08: **aberta**. Consequência prática: um backend que monta a conversa a partir de *events* GenAI mostra spans sem mensagens. Não é bug do seu wiring.

### Aspire Dashboard (dev/local)

Visualiza em tempo real: fluxo completo de conversas entre agents, ciclos de tool call (Model → Tool → Model), token usage e latência por chamada, error traces e retry patterns. Para ambientes hospedados (Coolify), exporte via OTLP para o backend de observability do projeto — ver `infrastructure-docker-coolify-deploy` para o setup de Serilog/JSON console log que alimenta o mesmo pipeline de agregação.

---

## Enrichment — o que preencher em spans e logs

Setup sem enrichment produz traces tecnicamente corretos mas operacionalmente inúteis — você vê "uma chamada ao LLM aconteceu", não "por que ela demorou 8s" ou "qual feature/task gerou essa chamada". Preencha sempre:

### Correlação entre trace, feature e requisição

```csharp
using var activity = Activity.Current;
activity?.SetTag("morph.feature", featureName);
activity?.SetTag("morph.task_id", taskId);
activity?.SetTag("morph.correlation_id", correlationId);   // mesmo ID em toda a cadeia de uma requisição de usuário
```

Sem um `correlation_id` propagado manualmente através de chamadas assíncronas/filas (Hangfire, background jobs), você não consegue religar "o usuário reclamou às 14:32" a um trace específico — o `TraceId` do OpenTelemetry sozinho não sobrevive a um boundary de fila.

### Atributos de custo e uso do modelo

```csharp
activity?.SetTag("gen_ai.request.model", modelAlias);       // alias do Model Registry, nunca o nome hardcoded
activity?.SetTag("gen_ai.usage.input_tokens", usage.InputTokenCount);
activity?.SetTag("gen_ai.usage.output_tokens", usage.OutputTokenCount);
activity?.SetTag("gen_ai.response.finish_reasons", finishReasons);   // PLURAL — o singular não existe no pin
```

> **Correção de 2026-09-09 (duas passadas), medida por reflexão e por literal nas assemblies do
> pin. As QUATRO linhas acima foram conferidas, uma a uma** — a primeira passada corrigiu duas e
> deixou uma errada no mesmo bloco, que é a razão de este callout listar todas em vez de só as que
> mudaram.
>
> | Tag / símbolo | Veredito | Como |
> |---|---|---|
> | `gen_ai.request.model` | **existe** | 2 ocorrências autônomas; **distribuição por assembly na tabela da seção _Nomes de span, métricas e atributos — medidos_** — não é exclusivo de uma |
> | `gen_ai.usage.input_tokens` | **existe** | 2 autônomas; distribuição na tabela citada acima |
> | `gen_ai.usage.output_tokens` | **existe** | 2 autônomas; distribuição na tabela citada acima |
> | `gen_ai.response.finish_reasons` (a linha acima, no **plural**) | **existe** | 2 autônomas; distribuição na tabela citada acima |
> | `gen_ai.response.finish_reason` (o **singular**, que estava aqui) | **NÃO existe** | as **mesmas** 2 ocorrências do plural, **nenhuma autônoma**: o singular é a cabeça de `finish_reason`**s** |
> | `usage.PromptTokens` / `CompletionTokens` | **NÃO existem** | 0 ocorrências autônomas em assembly restaurada nenhuma |
> | `gen_ai.usage.prompt_tokens` / `completion_tokens` | **NÃO existem** | 0 ocorrências autônomas em assembly restaurada nenhuma |
>
> O que existe no lugar dos dois últimos é `InputTokenCount` / `OutputTokenCount`, propriedades de
> `UsageDetails` — que vive na assembly **`Microsoft.Extensions.AI.Abstractions` 10.9.0** (que viaja dentro do pacote `Microsoft.Extensions.AI`, e não é id de pacote do pin), e não na assembly
> que carrega os literais. E **"a assembly que carrega os literais" não é uma só** — a distribuição
> completa, por nome, está na tabela da seção *Nomes de span, métricas e atributos — medidos*, e **esta linha não a
> repete**: uma segunda tabela dos mesmos nomes foi escrita aqui na rodada 5 e **contradizia** a
> primeira (dizia "só em `Microsoft.Extensions.AI`" para seis literais que também vivem em `OpenAI`),
> justamente por invocá-la como autoridade sem relê-la. Duas cópias do mesmo fato divergem; a
> segunda foi removida.
>
> Faz sentido: quem **emite** o span de chamada de modelo é `OpenTelemetryChatClient`
> (`Microsoft.Extensions.AI` 10.9.0); quem descreve o **agente** é a camada de agente. Os nomes
> velhos eram da convenção **anterior** à renomeação do semconv. Código copiado daqui não compilava,
> e a tag emitida não casaria com nenhum painel montado sobre a convenção atual.
>
> **O método importa tanto quanto o resultado:** `finish_reason` sobreviveu a duas varreduras porque
> as duas usaram `includes(literal)`, e **`includes` de um prefixo não prova o literal autônomo** —
> o singular casa dentro do plural. A conferência acima é por **exclusividade**: cada ocorrência é
> aceita só se o caractere anterior e o seguinte não forem `[A-Za-z0-9_.-]`. Literais de string .NET
> vivem no heap `#US` em **UTF-16**; a varredura cobre `#US` (UTF-16) e `#Strings` (ASCII).

Nomeação `gen_ai.*` segue a convenção semântica OpenTelemetry para GenAI — mantém compatibilidade se o backend de observability tiver dashboards prontos para essa convenção. Sobre a **instabilidade** dessa convenção, ver a ressalva na seção de Setup.

> **Tokens ficam aqui; dinheiro não.** A **tarifa** por alias (com data de verificação), o **cálculo**
> do custo (e as três armadilhas de fórmula: raciocínio cobrado como saída, entrada cacheada como
> subconjunto da entrada, taxa fixa de operação hospedada), o estado de um custo
> (`Computed`/`Waived`/`UsageMissing`, e por que `cost_usd` nunca é nulo) e o **teto de orçamento**
> têm **tratamento canônico** em **`ai-agents-cost-and-budget`**. Não duplicado aqui. A delegação é
> de mão dupla: aquele standard aponta de volta para esta seção quando o assunto é atributo de span.

### Logging estruturado com `ILogger` — sempre com scope

```csharp
using (_logger.BeginScope(new Dictionary<string, object>
{
    ["FeatureId"] = featureName,
    ["TaskId"] = taskId,
    ["AgentId"] = agentPersonaId
}))
{
    _logger.LogInformation("Tool call {ToolName} started with args {Args}", toolName, args);
    // ... chamada ...
    _logger.LogInformation("Tool call {ToolName} completed in {ElapsedMs}ms", toolName, elapsed);
}
```

- **Sempre** parâmetros nomeados no template (`{ToolName}`, não string interpolada) — permite querying estruturado no backend em vez de grep de texto livre.
- **Nunca** logar o conteúdo completo do prompt/response em `Information` — isso é PII/dado sensível do cliente por padrão. Logar em `Debug` apenas, e só se `EnableSensitiveData` estiver habilitado explicitamente para o ambiente.
- **Nível de log por tipo de evento:** `Information` para início/fim de tool call e decisões do agent; `Warning` para retry/fallback de provider; `Error` apenas para falhas que geram resposta degradada ao usuário (não para toda exceção capturada e tratada).

---

## Rastreabilidade do que foi ao provedor

A pergunta que a instrumentação existe para responder depois de um incidente é uma só: **dá para
reconstruir o que foi enviado ao provedor, e o que ele respondeu, neste turno?**

**A regra: prompt cru no harness; hash em produção.**

| Ambiente | O que se registra | Por quê |
|---|---|---|
| Dev e harness | O texto enviado, com **opt-in explícito** e sem dado pessoal | É onde se depura; o custo de vazamento é controlado |
| Produção | O **hash** do texto enviado (e, quando o prompt é composto de blocos, um hash por bloco) | Um prompt cru persistido em produção é um vazamento esperando indexação. O hash responde "foi o mesmo texto?" sem guardar o texto |

Isso é a mesma chave que `EnableSensitiveData = false` fora de dev já protege — e o hash é o que
sobra de útil depois de desligá-la.

### A invariante que torna o hash confiável

Um hash só prova alguma coisa se o texto de que ele é hash **é** o texto que foi enviado. Isso deixa
de valer no instante em que existe um cache entre a montagem e o envio: a partir dali, telemetria e
requisição podem divergir, e a divergência é silenciosa.

Por isso a exigência é do lado de quem monta o prompt, não de quem o registra: **o texto enviado é
derivado dos blocos, sem cache**. É uma invariante deliberadamente cara, e a razão está escrita no
código de campo que a implementa — *"para a telemetria não poder mentir"*. O tratamento canônico
está em `ai-agents-conversational-agent-with-phases`, seção do compositor de turno.

**Duas asserções que fecham o ciclo**, e ambas são de teste, não de wiring:

- A lista de tools **ofertadas** saiu da lista real do turno, e não de uma constante paralela.
- **Turno cancelado não grava** — um registro de algo que não aconteceu é pior que a ausência dele.

A metade de harness deste assunto (o que o fake captura, e como assertar sobre isso) é de
`ai-agents-testing-ai`. O veredito ternário e o escape registrado, que são o que torna um guard
auditável pela mesma via, são de `ai-agents-guardrails`.

> **O que o Gate 3 procura.** Os nomes concretos — `ActivitySource`, spans, métricas e atributos
> requeridos — estão medidos na tabela **"Nomes de span, métricas e atributos — medidos"** acima, e
> **não** são repetidos aqui: uma segunda lista dos mesmos nomes é a segunda verdade nascendo. A
> régua de avaliação correspondente vive em
> `.morph/framework/evals/proof-rubrics/observabilidade-genai.md`, e ela pergunta a **propriedade**
> desta seção (dá para reconstruir o turno?), não a tecnologia — porque uma régua binária "tem span?"
> daria a mesma nota a quem tem só a mensagem da exceção e a quem tem hash por bloco de prompt com
> teste de fidelidade.

---

## Investigação e análise — quando algo dá errado

Workflow para diagnosticar um incidente relatado ("o agente X respondeu errado/lento/nada às HH:MM"):

1. **Ancore no correlation_id ou janela de tempo.** Se o relato tem um identificador (número de WhatsApp, feature, conversationId), busque por ele nos atributos (`morph.correlation_id`, `gen_ai.*`) — não pelo texto do log. Sem identificador, restrinja pela janela de tempo + `morph.feature`/`AgentId` antes de olhar qualquer trace individual.
2. **Suba do trace raiz para os spans filhos.** O span raiz mostra a duração total; os filhos (`Function Calling`, `IChatClient`) mostram onde o tempo/erro realmente está. Não assuma — meça. Um "agente lento" é quase sempre UM tool call específico (chamada de API externa, query sem índice), não o LLM.
3. **Correlacione com métricas antes de teorizar.** Confira a tabela "What to Monitor" abaixo pelo período do incidente — um pico de `tool call failure rate` ou `token usage` aponta a causa mais rápido que ler traces individuais um a um.
4. **Reproduza com Debug ligado, nunca em produção.** Se a causa não estiver clara nos atributos estruturados, habilite `Debug` (com dados sensíveis, se necessário) num ambiente de staging com o mesmo input — nunca habilite log de prompt/response completo em produção para investigar.
5. **Documente a causa raiz em `decisions.md`** se a investigação levar a uma mudança de arquitetura (novo middleware de retry, novo timeout, novo cache) — não só no commit message.

### What to Monitor

| Metric | Purpose | Alert Threshold |
|--------|---------|------------------|
| Token usage per request | Cost control | > 10K tokens/request |
| Latency per agent call | Performance | > 5s for simple tasks |
| Tool call failure rate | Reliability | > 5% failures |
| Cache hit rate | Cost optimization | < 50% (tune strategy) |
| Model drift | Quality | Evaluation score drop > 10% |

---

## Checklist (verifiable by morph-eval)

- [ ] `AddSource` das **duas** fontes: `Experimental.Microsoft.Agents.AI` e `Experimental.Microsoft.Extensions.AI`
- [ ] Nenhuma referência a `AgentOpenTelemetryConsts` ou `agent.WithOpenTelemetry()` (não existem no pin)
- [ ] `EnableSensitiveData = false` fora de dev/staging (é o default — confirme que ninguém ligou)
- [ ] Cada nível instrumentado **no máximo uma vez**; com `UseProvidedChatClientAsIs = true`, a instrumentação de chat está explicitamente presente
- [ ] Um teste conta os spans de uma invocação: exatamente um `invoke_agent` e um `chat`
- [ ] Todo tool call relevante tem `correlation_id` propagado manualmente através de boundaries assíncronos (filas, background jobs)
- [ ] Logging via `ILogger` com `BeginScope` estruturado, nunca string interpolada em templates de log
- [ ] Nenhum log em `Information`/produção contém prompt/response completo
- [ ] Alertas configurados para os thresholds da tabela "What to Monitor" relevantes ao projeto
- [ ] Nenhum alerta depende de um nome `gen_ai.*` como identificador único sem plano de renomeação (a convenção está em `Development`, não `Stable`)
- [ ] Em produção, o que foi ao provedor é reconstituível por **hash**; prompt cru só em dev/harness, com opt-in
- [ ] O texto enviado é derivado dos blocos **sem cache** — sem isso o hash não prova nada
- [ ] Há asserção de que a lista de tools ofertadas saiu da lista real do turno
- [ ] Turno cancelado **não** grava telemetria

---

## References

- `ai-agents-production` — panorama de produção; esta seção era resumida lá, tratamento canônico agora aqui
- `ai-agents-middleware-patterns` — middleware de telemetria no nível `IChatClient` (`TelemetryMiddleware`)
- `ai-agents-cost-and-budget` — tarifa, cálculo de custo e teto de orçamento (delegação recíproca: tokens ficam aqui, dinheiro fica lá)
- `ai-agents-conversational-agent-with-phases` — o compositor de turno e a invariante "derivado sem cache" que torna o hash confiável
- `ai-agents-testing-ai` — a metade de harness da rastreabilidade (captura do fake, prompt cru com opt-in)
- `ai-agents-guardrails` — veredito ternário e escape registrado, auditáveis pela mesma telemetria
- `.morph/framework/evals/proof-rubrics/observabilidade-genai.md` — a régua do Gate 3 para a propriedade desta seção
- `infrastructure-docker-coolify-deploy` — Serilog/JSON console log para o pipeline de agregação em produção
- Reference implementation (compila e conta spans contra o pin): `templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs` + `tests/Morph.AiKit.Tests/TelemetryTests.cs`
- [OpenTelemetry Semantic Conventions for GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
- [Agent Observability](https://learn.microsoft.com/agent-framework/user-guide/observability/)
- Issue aberta `microsoft/agent-framework#3637` — `ActivityEvent`s da spec GenAI não emitidos (estado em 2026-09-08: **aberta**)

---

*MORPH-SPEC by Polymorphism Tech — ai-agents/observability-patterns.md v2.0 (2026-09-08)*
