Ir para o conteúdo
mcpd
sem daemon
Código

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.

Search
shipped on Tuesdayscore_below=0.5min_duration_ms=5000model=gpt-4o AND status=error
  • 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 delayed acha 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 resume acha résumé. Não há stemming: refund não acha refunds.
  • 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âmetroO 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_idToda execução de uma mesma conversa.
thread_idToda execução de uma mesma thread.
user_idToda execução de um mesmo usuário final.
environmentUm 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.

FacetaO que ela lista
entity_kindAgente, workflow, ferramenta, modelo, avaliador, processador, pipeline ou serviço.
entity_nameOs nomes que essas coisas têm, como as ferramentas que foram chamadas.
service_nameOs serviços que reportaram.
environmentOs ambientes vistos, como production.
tagToda tag que alguma execução carrega.
metric_nameOs nomes de métrica gravados, inclusive os que o mcpd deriva.
metric_label_keyToda chave de rótulo vista no projeto.
metric_label_valueOs 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'

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'

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...'

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.