Encontrar uma execução
Uma investigação começa com uma reclamação, não com um painel. Esta página é como você transforma uma reclamação na única execução que a causou.
Busque o payload, não o rótulo
A maioria das ferramentas de tracing indexa nomes e atributos. O mcpd
indexa também o prompt e a resposta do modelo, porque aquilo de que uma
pessoa reclama é algo que o modelo disse, e as palavras que ela repete para
você são o único fio de que você dispõe. O parâmetro q é esse
índice.
- 3 hits the run whose completion said it
- 12 hits every answer a scorer marked down
- 7 hits runs slower than five seconds
- 2 hits failures on one model
O que o q cobre, exatamente
O índice guarda três campos por span: o nome, a entrada e a saída dele. Entrada e saída são JSON, então, antes de indexar, o mcpd percorre o payload e fica só com as strings e os números, jogando fora as chaves, as aspas e os nomes dos campos. Indexar o JSON cru faria de cada sinal de pontuação um token e enterraria as palavras que você está procurando.
Seis consequências valem ser conhecidas antes de você depender disso:
- Uma execução casa quando um span qualquer dentro dela casa. O que volta são execuções inteiras; abra uma para ver qual era a etapa.
-
As palavras são combinadas com AND.
q=refund delayedacha spans que contêm as duas, em qualquer ponto do texto indexado, e não uma ao lado da outra. -
A correspondência é por palavra inteira, não diferencia maiúsculas de
minúsculas e normaliza os acentos, então
resumeacharésumé. Não há stemming:refundnão acharefunds. - Os primeiros 64 KiB da entrada e os primeiros 64 KiB da saída entram no índice. Payloads maiores ficam gravados e são mostrados inteiros; só a cauda de um prompt muito grande fica fora da busca.
- A ocultação roda antes da gravação, então nada do que ela tirou chegou ao índice. Veja Ocultação e retenção.
- Um span gravado mais de uma vez, como acontece com um span longo quando ele termina, substitui a linha dele no índice, então a busca sempre reflete a versão atual.
Uma aspa solta é uma busca, não um erro
O índice por baixo tem uma linguagem de consulta própria: uma aspa dupla
abre uma frase, e NEAR, OR e * são
operadores. Entregar o seu texto sem mudança a ela faria com que a mensagem
de um cliente com uma aspa dentro voltasse como erro de sintaxe em vez de
resultado, e uma caixa de busca que pode estourar é uma caixa de busca que
as pessoas param de usar.
Então o seu texto nunca chega àquele parser. O mcpd divide o que você
digitou nos espaços, tira toda aspa dupla de cada palavra, embrulha cada
palavra como termo literal e junta tudo com AND. q=say "no"
procura as palavras say e no. q=NEAR procura
a palavra NEAR. Nada do que você digitar é erro de sintaxe, e nenhum
operador fica à sua disposição. É essa a troca, feita nessa direção de
propósito.
Um q feito só de espaços é descartado em vez de não casar com
nada, e um q= vazio é tratado como ausente. Todo parâmetro se
comporta assim, então um formulário que envia as caixas de filtro vazias
não acaba filtrando pela string vazia.
Estreitar uma lista de execuções
GET /v1/api/traces aceita o que vem abaixo. O que você deixar
de fora não é aplicado.
| Parâmetro | O que ele seleciona |
|---|---|
q | Texto completo sobre o nome, a entrada e a saída de cada span. |
status | unset, ok, error ou
cancelled. Uma execução é error quando
qualquer span dentro dela falhou, mesmo um cujo erro foi capturado.
|
kind |
O tipo do span raiz da execução, como agent_run ou
workflow_run.
|
name | O nome do span raiz, exato. Não é um prefixo nem um padrão. |
tags | Separadas por vírgula. Uma execução precisa carregar todas as tags listadas, e não qualquer uma delas. |
session_id | Toda execução de uma mesma conversa. |
thread_id | Toda execução de uma mesma thread. |
user_id | Toda execução de um mesmo usuário final. |
environment | Um ambiente, como production. |
model | Execuções com pelo menos um span contra este id de modelo. |
from, to | A janela, medida sobre a hora de início da execução. |
min_duration_ms | Execuções pelo menos tão lentas quanto isto, em milissegundos. |
min_cost | Execuções que custaram pelo menos isto. Uma execução sem spans precificados conta como zero, então qualquer valor positivo a exclui. |
has_score | Execuções que carregam uma nota deste id de avaliador. |
score_below | Com has_score: execuções que aquele avaliador pôs abaixo deste valor. |
sort | start_time, duration, cost,
span_count ou score. O padrão é
start_time. score ordena as execuções pela
menor nota que algum avaliador deu a elas, então a ordem crescente
põe a pior execução na frente; uma execução sem nota vale zero.
|
order | asc ou desc. O padrão é desc. |
limit | Tamanho da página, preso entre 1 e 500. O padrão é 50. |
cursor | O cursor opaco da página anterior. |
project | Qual projeto ler. O padrão é o projeto padrão do próprio daemon, então uma instalação com um projeto só nunca precisa passar isto. |
name, os ids de correlação e environment são
testes de igualdade, que é o que os torna baratos. Se você quer uma
correspondência parcial em um nome, use q: os nomes de span
estão no índice de texto também.
Quanto custa uma linha da lista
Não existe uma segunda lista de execuções, mais leve, porque a primeira já é leve. A projeção de trace que fica gravada não carrega entrada nenhuma, saída nenhuma e atributo nenhum: nomes, contagens, tempos, custo, tags e correlação, e nada do que um modelo disse. Abrir uma execução é o que busca o payload.
Medido em uma execução realista, um agente de suporte de oito etapas que recupera três documentos de política, chama duas ferramentas e faz duas chamadas de modelo com prompts e respostas de verdade: a linha da lista tem 577 bytes e a execução inteira tem 10.717 bytes, uma redução de 94,6%. É este daemon, naquela execução, sobre a superfície HTTP real. As suas execuções vão diferir, e o que importa é a direção: a economia cresce com o tamanho dos prompts, porque a parte que cresce é a parte que a lista não carrega.
O número é produzido por um teste, e não digitado aqui, então ele não pode
envelhecer em um lugar sem falhar no outro. Rode
cargo test -p mcpd-server --test payload_size -- --nocapture e
ele imprime as duas contagens de bytes. Um campo acrescentado à projeção
que carregue payload faz esse teste falhar.
Qualquer outra coisa é recusada, pelo nome
Aquela tabela é o vocabulário inteiro. Um parâmetro que não está nela é
recusado com um 400 que o nomeia, em vez de aceito e descartado. Isto
mudou: o mcpd ignorava um parâmetro que não conhecia, então
?statuss=error voltava 200 com todas as execuções do projeto e
nada na resposta dizia que o filtro não tinha sido aplicado. Uma resposta
errada que parece certa é pior do que um erro, porque não há nada a notar.
Vale nomear a troca que isso faz. Um cliente que manda um filtro que um daemon mais novo entende agora recebe um 400 de um mais antigo, em vez de um conjunto de resultados mais largo. Essa é a melhor das duas falhas: o 400 nomeia o parâmetro e para.
Algumas recusas dizem mais do que “desconhecido”. O mcpd grava
service_name, service_version,
resource_id, run_id, request_id,
experiment_id e release em todo trace, mas a
lista de execuções ainda não filtra por eles. Dos sete, só
service_name é uma faceta da
descoberta, então a
descoberta lista os valores dele e não os dos outros. Esses sete são
recusados com uma mensagem que diz isso, porque
service_name=checkout-api não é um erro de digitação, e dizer
a alguém que é um parâmetro desconhecido manda essa pessoa caçar um erro de
escrita que não está lá.
A regra não é só da lista de execuções. GET /v1/api/spans,
/v1/api/logs, /v1/api/scores,
/v1/api/metrics e /v1/api/feedback impõem cada
uma o próprio vocabulário do mesmo jeito. Duas valem ser conhecidas antes
de você topar com elas: a lista de notas não aceita
order, porque ela vai da mais nova para a mais antiga e o
cursor dela compara em um sentido só, e a lista de feedback exige um
trace_id, porque o mcpd ainda não consegue listar o feedback
de um projeto inteiro e uma lista vazia daria a entender que não há nenhum.
Preencher uma caixa de filtro
Um filtro só serve se você souber o que digitar nele, e os valores não são
uma lista fixa: são o que os serviços que reportam a um projeto acabam
mandando. GET /v1/api/discovery responde isso. Peça uma
dimensão de cada vez com facet, e ele devolve os valores que
aquela dimensão assumiu no projeto, ordenados por valor, cada um com a
última vez em que um sinal o carregou.
| Faceta | O que ela lista |
|---|---|
entity_kind | Agente, workflow, ferramenta, modelo, avaliador, processador, pipeline ou serviço. |
entity_name | Os nomes que essas coisas têm, como as ferramentas que foram chamadas. |
service_name | Os serviços que reportaram. |
environment | Os ambientes vistos, como production. |
tag | Toda tag que alguma execução carrega. |
metric_name | Os nomes de métrica gravados, inclusive os que o mcpd deriva. |
metric_label_key | Toda chave de rótulo vista no projeto. |
metric_label_value | Os valores de um rótulo. Precisa de key. |
prefix estreita a lista para o autocompletar, e ele casa
literalmente e diferenciando maiúsculas de minúsculas, porque estes valores
são aquilo com que um filtro vai ser comparado e normalizar aqui ofereceria
um valor que depois não casa com nada. limit segura a resposta
em 1000. Os valores são ordenados por valor e não por recência, para que
uma lista que alguém está lendo não se reembaralhe entre uma consulta e a
seguinte.
Um limite vale ser conhecido antes de você construir em cima dele.
metric_label_key lista toda chave de rótulo vista em qualquer
ponto do projeto, e não as chaves de uma métrica nomeada: a lista de
valores distintos é indexada pelo rótulo, e não pela métrica que o
carregou.
Atualizar uma lista sem rodá-la de novo
GET /v1/api/deltas devolve o que chegou desde um cursor, do
mais antigo para o mais novo, para traces, spans
ou logs. Guarde o cursor da última consulta, mande-o de volta,
e aplique as linhas à lista que você já tem.
Uma sondagem não é uma página, e os dois cursores não são intercambiáveis, de propósito. A paginação percorre para trás um conjunto de resultados fixo, na ordenação que você escolheu; a sondagem percorre para a frente na ordem de chegada e nunca termina. A marca d’água é a hora de chegada de uma linha junto com o id dela, porque um lote grava todas as linhas dele com um mesmo carimbo de tempo, e uma marca que só pudesse dizer “depois deste nanossegundo” ou repetiria o lote inteiro ou pularia o resto dele. Uma sondagem vazia devolve o cursor sem mudança: movê-lo para a frente descartaria uma linha que chega um instante depois com uma hora de chegada anterior.
Notas e feedback não podem ser sondados. Eles são carimbados com o
horário que o chamador mandou e não carregam hora de chegada definida pelo
servidor, então uma marca d’água sobre eles ou pularia uma linha atrasada
ou repetiria toda linha para sempre. signal=scores é recusado
em vez de respondido com uma página vazia.
O que esta implantação consegue fazer
O mcpd tem um trait de armazenamento e três backends por trás dele, e eles
não são iguais. No ClickHouse, uma exclusão é aceita e aplicada depois que
a chamada retorna, então as contagens da resposta dizem o que casou, e não
o que já saiu do disco. No SQLite e no Postgres, as linhas já não estão lá
quando a chamada retorna.
GET /v1/api/capabilities é onde isso
está escrito: qual backend está atrás do daemon, o que o q
casa, se a caixa é ignorada fora do ASCII, se uma exclusão está em disco
quando a chamada retorna, e se a descoberta e a sondagem de deltas chegam a
responder.
Leia isso uma vez na hora de conectar e ofereça só o que a implantação
responde. Um endpoint cuja capacidade é falsa recusa com 501 e
com um corpo cujo campo capability nomeia a mesma palavra que
o conjunto de capacidades publica, então os dois nunca podem discordar e um
cliente que topou com a recusa pode consultá-la em vez de repetir algo que
nunca vai mudar.
Os três backends que existem hoje respondem tanto à descoberta quanto à sondagem de deltas, então o 501 abaixo é o formato com que recusaria um backend acrescentado mais tarde, e não uma resposta que você vá ver do SQLite, do Postgres ou do ClickHouse. O conjunto de capacidades é o que diz qual deles é, e vale a leitura mesmo agora: os três diferem em se uma exclusão está em disco quando a chamada retorna, e em como a retenção é aplicada. Os três ignoram a caixa das letras também fora do ASCII.
por onde posso filtrar?
# Every environment this project has reported, for a filter box.
curl -s --get http://localhost:4317/v1/api/discovery \
--data-urlencode 'project=default' \
--data-urlencode 'facet=environment'
# Typeahead: the service names starting with "check".
curl -s --get http://localhost:4317/v1/api/discovery \
--data-urlencode 'facet=service_name' \
--data-urlencode 'prefix=check'
# The values one metric label takes.
curl -s --get http://localhost:4317/v1/api/discovery \
--data-urlencode 'facet=metric_label_value' \
--data-urlencode 'key=model' {
"facet": "environment",
"values": [
{ "value": "production", "last_seen": "2026-09-03T08:04:18Z" },
{ "value": "staging", "last_seen": "2026-09-02T17:41:02Z" }
]
} {
"error": "not_implemented",
"capability": "discovery",
"message": "this deployment stores signals in <backend>, which does not support discovery: ... GET /v1/api/capabilities lists what it does support."
} Janelas
from e to aceitam um carimbo de tempo RFC 3339,
ou uma forma relativa curta: um sinal de menos, um número e uma letra entre
s, m, h e d.
-15m, -1h e -7d funcionam todos. A
forma curta existe porque digitar um carimbo ISO à mão para olhar a última
hora é um atrito que ninguém deveria pagar.
from é inclusivo e to é exclusivo, e os dois
comparam contra a hora em que a execução começou. Uma execução
longa que começou antes da sua janela e ainda estava rodando dentro dela
não é devolvida. Se você está caçando algo que estava rodando às 09:00,
alargue o from para trás em vez de apertá-lo.
Um valor que não é nem carimbo de tempo nem duração é recusado pelo nome em vez de ignorado. Todo parâmetro de todo endpoint de lista falha assim, porque um filtro descartado em silêncio devolve uma resposta errada que parece certa, e uma resposta errada que parece certa é pior do que um erro.
from=yesterday
{
"error": "bad_parameter",
"field": "from",
"message": "\"yesterday\" is not an RFC 3339 timestamp or a relative duration like -1h"
} A consulta que encontra respostas ruins
has_score=answer_relevance&score_below=0.5 devolve toda
execução que aquele avaliador pôs abaixo da metade. É esta a consulta a usar
quando a reclamação é sobre qualidade e não sobre uma falha, porque uma
resposta ruim costuma ser uma execução que deu certo.
has_score sozinho devolve toda execução que aquele avaliador
olhou. score_below sozinho não faz nada: um limiar sem avaliador
atrelado não tem sentido, e o mcpd teria de adivinhar qual avaliador você quis
dizer. Ele não adivinha.
Notas e feedback
conta de onde vêm esses valores.
Buscar etapas em vez de execuções
GET /v1/api/spans busca etapas isoladas em todas as execuções
de uma vez. Use-o para perguntas sobre uma etapa e não sobre uma sessão:
qual chamada de ferramenta está lenta, quais chamadas a um modelo deram
erro, quanto tempo leva uma recuperação.
Ele aceita q, kind, status,
name, model, trace_id,
from, to, min_duration_ms,
sort, order, limit e
cursor, com os mesmos sentidos e os mesmos padrões. Aqui não
há filtro tags, session_id nem
user_id, porque esses pertencem à execução, e os três são
recusados pelo nome em vez de aceitos e descartados.
sort aceita menos valores aqui do que na lista de execuções:
start_time, duration ou cost, e mais
nada. Um span não carrega contagem de spans nem consolidação de notas,
então span_count e score não têm por onde ordenar
e são recusados pelo nome em vez de ignorados.
Em uma execução, model pergunta se algum span usou aquele
modelo. Em um span, model é o modelo daquele span. A mesma
palavra, duas perguntas.
encontre
# Failed runs from the last day that mention both words, anywhere in a
# span name, a prompt or a completion.
curl -s --get http://localhost:4317/v1/api/traces \
--data-urlencode 'q=Leeds depot' \
--data-urlencode 'status=error' \
--data-urlencode 'from=-24h'
# Every step that called one model and failed, across every run at once.
curl -s --get http://localhost:4317/v1/api/spans \
--data-urlencode 'model=gpt-4o-mini' \
--data-urlencode 'status=error' \
--data-urlencode 'min_duration_ms=2000' import { Mcpd } from '@mcpd/sdk'
const mcpd = new Mcpd()
const bad = await mcpd.listTraces({
q: 'Leeds depot',
status: 'error',
from: '-24h',
limit: 20,
})
for (const trace of bad.traces) {
console.log(trace.traceId, trace.name, trace.status)
} from mcpd import Mcpd
mcpd = Mcpd()
bad = mcpd.list_traces(q="Leeds depot", status="error", from_="-24h", limit=20)
for trace in bad.traces:
print(trace.trace_id, trace.name, trace.status.value) Paginação, e por que o cursor é opaco
Uma lista devolve no máximo limit linhas e um objeto
page. page.next_cursor aparece quando há mais, e
page.has_more diz a mesma coisa como booleano.
A paginação é por chave, não por deslocamento. O cursor nomeia a última linha que você recebeu, e a página seguinte começa estritamente depois dela. A paginação por deslocamento derivaria: os traces chegam sem parar, então, quando você pedisse as linhas de 50 a 99, a janela já teria escorregado por baixo e você leria uma execução duas vezes enquanto outra nunca apareceria. Numa investigação, a execução que você perde é justamente aquela que você procurava.
O cursor é um par, codificado em hexadecimal, do valor de ordenação e do id
da linha, e você deve tratá-lo como algo sem sentido. Ele é opaco para que
a codificação possa mudar sem quebrar todos os chamadores, e porque o valor
dentro dele é uma grandeza diferente para cada ordenação: nanossegundos
para start_time e duration, um inteiro escalado
para cost, uma contagem simples para span_count,
a menor nota da execução para score.
Isso traz uma regra junto. Mande o cursor de volta com os mesmos
filtros, o mesmo sort e a mesma order que o
produziram. O mcpd não confere se você fez isso, e um cursor
repetido contra outra ordenação compara contra a coluna errada e pula
linhas. Um cursor editado ou truncado é recusado com
invalid_query e a mensagem malformed cursor.
Não há total. page.total está reservado para um backend que
consiga contar as correspondências sem custo alto, e o armazenamento SQLite
embutido não o preenche, então faça o laço em has_more em vez
de contar em direção a um número.
percorrer as páginas
# The first page hands back a cursor in page.next_cursor.
curl -s --get http://localhost:4317/v1/api/traces \
--data-urlencode 'q=refund' \
--data-urlencode 'sort=cost' \
--data-urlencode 'limit=100'
# The next page: the same filters, the same sort, plus the cursor.
curl -s --get http://localhost:4317/v1/api/traces \
--data-urlencode 'q=refund' \
--data-urlencode 'sort=cost' \
--data-urlencode 'limit=100' \
--data-urlencode 'cursor=MzEzNzQyfDBhZjM...' let cursor: string | undefined
do {
const page = await mcpd.listTraces({
q: 'refund',
sort: 'cost',
order: 'desc',
limit: 100,
cursor,
})
for (const trace of page.traces) {
console.log(trace.traceId, trace.cost?.totalCost ?? 'not priced')
}
// Loop on hasMore. There is no total to count towards.
cursor = page.page.hasMore ? page.page.nextCursor : undefined
} while (cursor) cursor = None
while True:
page = mcpd.list_traces(
q="refund", sort="cost", order="desc", limit=100, cursor=cursor
)
for trace in page.traces:
cost = trace.cost.total_cost if trace.cost else "not priced"
print(trace.trace_id, cost)
# Loop on has_more. There is no total to count towards.
if not page.page.has_more:
break
cursor = page.page.next_cursor Depois que você tem a execução
Um id de trace vindo de qualquer uma destas consultas vai para
GET /v1/api/traces/{trace_id}, que devolve a execução e
todo span dentro dela, já ordenados para que uma cascata seja desenhada sem
uma segunda requisição. Acrescente
include=logs,scores,feedback para receber na mesma resposta
tudo o que está preso a ela.
Daí em diante, Apuração de custos explica os números de cada etapa, e a referência da API HTTP lista todos os endpoints e campos como a especificação os declara.