Encontrar una ejecución
Una investigación empieza con una queja, no con un panel. Esta página es cómo conviertes una queja en la única ejecución que la causó.
Busca en el payload, no en la etiqueta
La mayoría de las herramientas de trazado indexan nombres y atributos. mcpd
indexa además el prompt y la completion, porque de lo que se
queja una persona es de algo que dijo el modelo, y las palabras que te cita
son el único asidero que tienes. El parámetro q es ese í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
Qué cubre q, exactamente
El índice guarda tres campos por span: su nombre, su entrada y su salida. La entrada y la salida son JSON, así que antes de indexar mcpd recorre el payload y se queda solo con sus cadenas y sus números, y descarta las llaves, las comillas y las claves. Indexar el JSON en crudo convertiría cada signo de puntuación en un token y enterraría las palabras que de verdad buscas.
Seis consecuencias que conviene conocer antes de confiar en él:
- Una ejecución coincide cuando coincide cualquiera de sus spans. Lo que recibes son ejecuciones enteras; abre una para ver de qué paso se trataba.
-
Las palabras se combinan con AND.
q=refund delayedencuentra los spans que contienen las dos, en cualquier punto del texto indexado, no una junto a la otra. -
La coincidencia es por palabra completa, no distingue mayúsculas de
minúsculas, y los diacríticos se pliegan, así que
resumeencuentrarésumé. No hay lematización:refundno encuentrarefunds. - Al índice van los primeros 64 KiB de la entrada y los primeros 64 KiB de la salida. Los payloads más largos se almacenan y se muestran enteros; lo único que no se puede buscar es la cola de un prompt muy grande.
- La ocultación se aplica antes del almacenamiento, así que lo que se quitó ahí tampoco llegó a indexarse. Consulta Ocultación y retención.
- Un span escrito más de una vez, como le ocurre a un span de larga duración cuando termina, reemplaza su fila del índice, así que la búsqueda refleja siempre la versión actual.
Una comilla suelta es una búsqueda, no un error
El índice que hay debajo tiene su propio lenguaje de consulta: una comilla
doble abre una frase, y NEAR, OR y *
son operadores. Pasarle tu texto sin tocar significaría que el mensaje de
un cliente que lleva una comilla vuelve como error de sintaxis en vez de
como resultado, y una caja de búsqueda que puede reventar es una caja de
búsqueda que la gente deja de usar.
Por eso tu texto nunca llega a ese analizador. mcpd parte lo que escribiste
por los espacios, quita toda comilla doble de cada palabra, envuelve cada
palabra como término literal y las une con AND. q=say "no"
busca las palabras say y no. q=NEAR busca la
palabra NEAR. Nada de lo que puedas escribir es un error de sintaxis, y no
tienes ningún operador a mano. Ese es el canje, hecho en esa dirección a
propósito.
Un q que solo tiene espacios se descarta en vez de no coincidir
con nada, y un q= vacío se trata como ausente. Todos los
parámetros se comportan así, para que un formulario que envía sus cajas de
filtro vacías no acabe filtrando por la cadena vacía sin querer.
Acotar una lista de ejecuciones
GET /v1/api/traces acepta lo siguiente. Lo que omitas no se
aplica.
| Parámetro | Qué selecciona |
|---|---|
q | Texto completo sobre el nombre, la entrada y la salida de cada span. |
status | unset, ok, error o
cancelled. Una ejecución es error cuando
falló cualquiera de sus spans, incluso uno cuyo error se capturó.
|
kind |
El tipo del span raíz de la ejecución, por ejemplo
agent_run o workflow_run.
|
name | El nombre del span raíz, exacto. Ni un prefijo ni un patrón. |
tags | Separadas por comas. Una ejecución debe llevar todas las etiquetas de la lista, no una cualquiera. |
session_id | Todas las ejecuciones de una conversación. |
thread_id | Todas las ejecuciones de un hilo. |
user_id | Todas las ejecuciones de un usuario final. |
environment | Un entorno, por ejemplo production. |
model | Ejecuciones con al menos un span contra este id de modelo. |
from, to | La ventana, medida contra la hora de inicio de la ejecución. |
min_duration_ms | Ejecuciones al menos así de lentas, en milisegundos. |
min_cost | Ejecuciones que costaron al menos esto. Una ejecución sin spans tarifados cuenta como cero, así que cualquier valor positivo la deja fuera. |
has_score | Ejecuciones que llevan una puntuación de este id de evaluador. |
score_below | Con has_score: las ejecuciones que ese evaluador dejó por debajo de este valor. |
sort | start_time, duration, cost,
span_count o score. Por defecto,
start_time. score ordena las ejecuciones por
la puntuación más baja que les puso cualquier evaluador, así que en orden
ascendente la peor va primero; una ejecución sin puntuación cuenta
como cero.
|
order | asc o desc. Por defecto, desc. |
limit | Tamaño de página, sujeto al rango de 1 a 500. Por defecto, 50. |
cursor | El cursor opaco de la página anterior. |
project | Qué proyecto leer. Por defecto, el proyecto por defecto del propio daemon, así que una instalación de un solo proyecto no tiene que pasarlo nunca. |
name, los ids de correlación y environment son
pruebas de igualdad, y eso es lo que los hace baratos. Si quieres una
coincidencia parcial sobre un nombre, usa q: los nombres de
los spans también están en el índice de texto.
Lo que cuesta una fila de la lista
No hay una segunda lista de ejecuciones más ligera, porque la primera ya lo es. La proyección de traza almacenada no lleva entrada, ni salida, ni atributo alguno: nombres, recuentos, tiempos, costo, etiquetas y correlación, y nada de lo que dijo un modelo. Lo que trae el payload es abrir una ejecución.
Medido sobre una ejecución realista, un agente de soporte de ocho pasos que recupera tres documentos de política, llama a dos herramientas y hace dos llamadas al modelo con prompts y completions reales: la fila de la lista ocupa 577 bytes y la ejecución entera 10,717 bytes, una reducción del 94.6%. Eso es este daemon, sobre esa ejecución, por la superficie HTTP real. Tus ejecuciones serán distintas, y lo que importa es la dirección: el ahorro crece con el tamaño de los prompts, porque la parte que crece es la parte que la lista no lleva.
La cifra la produce una prueba en lugar de estar escrita aquí a mano, así
que no puede quedarse obsoleta en un sitio sin fallar en el otro. Ejecuta
cargo test -p mcpd-server --test payload_size -- --nocapture
y te imprime los dos recuentos de bytes. Un campo añadido a la proyección
que lleve payload hace fallar esa prueba.
Cualquier otra cosa se rechaza, por su nombre
Esa tabla es todo el vocabulario. Un parámetro que no esté en ella se
rechaza con un 400 que lo nombra, en vez de aceptarse y descartarse. Esto
cambió: mcpd ignoraba antes un parámetro que no conocía, así que
?statuss=error volvía con un 200 y con todas las ejecuciones
del proyecto, y nada en la respuesta decía que el filtro no se había
aplicado. Una respuesta equivocada pero verosímil es peor que un error,
porque no hay nada de lo que darse cuenta.
Conviene nombrar el canje que esto supone. Un cliente que envía un filtro que un daemon más nuevo entiende recibe ahora un 400 de uno más antiguo, en lugar de un conjunto de resultados más amplio. Ese es el mejor de los dos fallos: el 400 nombra el parámetro y para.
Algunos rechazos dicen más que «desconocido». mcpd registra
service_name, service_version,
resource_id, run_id, request_id,
experiment_id y release en cada traza, pero la
lista de ejecuciones todavía no puede filtrar por ellos. De los siete, solo
service_name es una faceta de
discovery, así que
discovery lista sus valores y no los de los demás. Esos siete se rechazan
con un mensaje que lo dice, porque service_name=checkout-api no
es una errata, y decirle a alguien que es un parámetro desconocido lo manda
a buscar una falta de ortografía que no está ahí.
La regla no es solo de la lista de ejecuciones.
GET /v1/api/spans, /v1/api/logs,
/v1/api/scores, /v1/api/metrics y
/v1/api/feedback imponen cada uno su propio vocabulario de la
misma manera. Dos conviene conocerlos antes de topártelos: la lista de
puntuaciones no acepta order, porque va de más nueva a más
antigua y su cursor solo compara en un sentido, y la lista de feedback
exige un trace_id, porque mcpd todavía no puede listar el
feedback de un proyecto entero y una lista vacía se leería como si no
hubiera ninguno.
Rellenar una caja de filtro
Un filtro solo sirve si sabes qué escribir en él, y los valores no son una
lista fija: son lo que envíen los servicios que reportan a un proyecto.
GET /v1/api/discovery responde a eso. Pide una dimensión cada
vez con facet, y devuelve los valores que esa dimensión ha
tomado en el proyecto, ordenados por valor, cada uno con la última vez que
una señal lo llevó.
| Faceta | Qué lista |
|---|---|
entity_kind | Agente, workflow, herramienta, modelo, evaluador, procesador, pipeline o servicio. |
entity_name | Los nombres que tienen esas cosas, por ejemplo las herramientas que se llamaron. |
service_name | Los servicios que han reportado. |
environment | Los entornos vistos, por ejemplo production. |
tag | Todas las etiquetas que lleva cualquier ejecución. |
metric_name | Los nombres de métrica registrados, incluidos los que deriva mcpd. |
metric_label_key | Todas las claves de etiqueta vistas en el proyecto. |
metric_label_value | Los valores de una etiqueta. Necesita key. |
prefix acota la lista para el autocompletado, y coincide de
forma literal y distinguiendo mayúsculas de minúsculas, porque estos
valores son contra lo que se comparará un filtro y plegarlos aquí ofrecería
uno que luego no coincide con nada. limit limita la respuesta
a 1000. Los valores se ordenan por valor y no por novedad, para que una
lista que alguien está leyendo no se le reordene entre dos sondeos.
Hay un límite que conviene conocer antes de construir sobre esto.
metric_label_key lista todas las claves de etiqueta vistas en
cualquier punto del proyecto, no las claves de una métrica concreta: la
lista de valores distintos se indexa por la etiqueta, no por la métrica que
la llevaba.
Refrescar una lista sin volver a ejecutarla
GET /v1/api/deltas devuelve lo que ha llegado desde un cursor,
de lo más antiguo a lo más nuevo, para traces,
spans o logs. Guarda el cursor del último sondeo,
devuélvelo, y aplica las filas a la lista que ya tienes.
Un sondeo no es una página, y los dos cursores no son intercambiables a propósito. La paginación recorre hacia atrás un conjunto de resultados fijo en el orden que elegiste; el sondeo avanza en orden de llegada y no termina nunca. La marca de agua es la hora de llegada de una fila junto con su id, porque un lote escribe todas sus filas con la misma marca de tiempo y una marca de agua que solo pudiera decir «después de este nanosegundo» o repetiría el lote entero o se saltaría el resto. Un sondeo vacío devuelve el cursor sin cambios: adelantarlo dejaría fuera una fila que aterriza un momento después con una hora de llegada anterior.
Las puntuaciones y el feedback no se pueden sondear. Llevan la marca de
tiempo que envió quien llamó y ninguna hora de llegada puesta por el
servidor, así que una marca de agua sobre ellos o se saltaría una fila
tardía o repetiría todas las filas para siempre.
signal=scores se rechaza en vez de responderse con una página
vacía.
Lo que puede hacer este despliegue
mcpd tiene un solo trait de almacenamiento y tres backends detrás de él, y
no son iguales. En ClickHouse un borrado se acepta y se aplica después de
que la llamada vuelve, así que los recuentos de su respuesta dicen lo que
coincidió y no lo que ya ha salido del disco. En SQLite y Postgres las
filas ya no están cuando la llamada vuelve.
GET /v1/api/capabilities es donde eso está escrito: qué
backend hay detrás del daemon, con qué coincide q, si el
plegado de mayúsculas llega más allá de ASCII, si un borrado está en disco
cuando la llamada vuelve, y si discovery y el sondeo de deltas responden
siquiera.
Léelo una vez al conectar y ofrece solo lo que el despliegue responde. Un
endpoint cuya capacidad es falsa rechaza con un 501 y un
cuerpo cuyo campo capability nombra la misma palabra que
publica el conjunto de capacidades, así que los dos no pueden discrepar
nunca y un cliente que se topó con el rechazo puede consultarlo en vez de
reintentar algo que no va a cambiar.
Los tres backends que se publican hoy responden tanto a discovery como al sondeo de deltas, así que el 501 de abajo es la forma con la que rechazaría un backend añadido más adelante, y no una respuesta que vayas a ver de SQLite, Postgres o ClickHouse. El conjunto de capacidades es lo que te dice cuál de ellos es, y vale la pena leerlo ya: los tres difieren en si un borrado está en disco cuando la llamada vuelve, y en cómo se aplica la retención. Los tres pliegan mayúsculas fuera de ASCII.
¿por qué puedo 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."
} Ventanas
from y to aceptan una marca de tiempo RFC 3339, o
una abreviatura relativa: un signo menos, un número y una de s,
m, h o d. -15m,
-1h y -7d valen todas. La abreviatura existe
porque escribir a mano una marca de tiempo ISO para mirar la última hora es
una fricción que nadie debería pagar.
from es inclusivo y to es exclusivo, y los dos
comparan contra la hora en que la ejecución empezó. Una ejecución
larga que arrancó antes de tu ventana y seguía en marcha dentro de ella no
se devuelve. Si persigues algo que estaba corriendo a las 09:00, ensancha
from hacia atrás en vez de estrecharlo.
Un valor que no es ni una marca de tiempo ni una duración se rechaza por su nombre en vez de ignorarse. Todos los parámetros de todos los endpoints de listado fallan así, porque un filtro descartado en silencio devuelve una respuesta equivocada pero verosímil, y una respuesta equivocada pero verosímil es peor que un error.
from=yesterday
{
"error": "bad_parameter",
"field": "from",
"message": "\"yesterday\" is not an RFC 3339 timestamp or a relative duration like -1h"
} La consulta que encuentra las malas respuestas
has_score=answer_relevance&score_below=0.5 devuelve todas
las ejecuciones que ese evaluador dejó por debajo de la mitad. Es la consulta a
la que recurrir cuando la queja es de calidad y no de un fallo, porque una
mala respuesta suele venir de una ejecución que terminó bien.
has_score por sí solo devuelve todas las ejecuciones que ese
evaluador ha mirado. score_below por sí solo no hace nada: un
umbral sin un evaluador al lado no significa nada, y mcpd tendría que adivinar
a qué evaluador te referías. No adivina.
Puntuaciones y feedback
cuenta de dónde salen esos valores.
Buscar pasos en vez de ejecuciones
GET /v1/api/spans busca pasos individuales en todas las
ejecuciones a la vez. Úsalo para preguntas sobre un paso y no sobre una
sesión: qué llamada a una herramienta es lenta, qué llamadas a un modelo
dieron error, cuánto tarda una recuperación.
Acepta q, kind, status,
name, model, trace_id,
from, to, min_duration_ms,
sort, order, limit y
cursor, con los mismos significados y los mismos valores por
defecto. Aquí no hay filtro tags, session_id ni
user_id, porque esos pertenecen a la ejecución, y los tres se
rechazan por su nombre en vez de aceptarse y descartarse.
sort acepta aquí menos valores que en la lista de ejecuciones:
start_time, duration o cost, y nada
más. Un span no lleva un recuento de spans ni un agregado de puntuaciones,
así que span_count y score no tienen nada por lo
que ordenar y se rechazan por su nombre en vez de ignorarse.
En una ejecución, model pregunta si algún span lo usó. En un
span, model es el modelo de ese span. La misma palabra, dos
preguntas.
encontrarlo
# 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) Paginación, y por qué el cursor es opaco
Una lista devuelve como mucho limit filas y un objeto
page. page.next_cursor está presente cuando hay
más, y page.has_more dice lo mismo como booleano.
La paginación es por keyset, no por offset. El cursor nombra la última fila que te dieron, y la página siguiente empieza estrictamente después de ella. Una paginación por offset se desviaría: las trazas llegan sin parar, así que para cuando pediste las filas de la 50 a la 99 la ventana ya se ha movido bajo tus pies y lees una ejecución dos veces mientras otra no aparece nunca. En una investigación, la ejecución que se te escapa es la que estabas buscando.
El cursor es un par codificado en hexadecimal con el valor de orden y el id
de la fila, y deberías tratarlo como si no significara nada. Es opaco para
que la codificación pueda cambiar sin romper a todo el que llama, y porque
el valor que lleva dentro es una magnitud distinta para cada orden:
nanosegundos para start_time y duration, un entero
escalado para cost, un recuento simple para
span_count, y la puntuación más baja de la ejecución para
score.
Eso lleva una regla asociada. Devuelve un cursor con los mismos
filtros, el mismo sort y el mismo order que lo
produjeron. mcpd no comprueba que lo hayas hecho, y un cursor
reproducido contra un orden distinto compara contra la columna equivocada y
se salta filas. Un cursor editado o truncado se rechaza con
invalid_query y el mensaje malformed cursor.
No hay total. page.total está reservado para un backend que
pueda contar coincidencias de forma barata, y el almacén SQLite que viene
de serie no lo rellena, así que itera sobre has_more en vez de
contar hacia un número.
paginar por ella
# 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 Cuando ya tienes la ejecución
Un id de traza salido de cualquiera de estas consultas va a
GET /v1/api/traces/{trace_id}, que devuelve la ejecución y
todos sus spans, ya ordenados para que una cascada se dibuje sin una
segunda solicitud. Añade include=logs,scores,feedback para
recibir en la misma respuesta todo lo que va adjunto a ella.
A partir de ahí, Contabilidad de costos explica las cifras de cada paso, y la referencia de la API HTTP lista cada endpoint y cada campo tal como los declara la especificación.