Trovare un’esecuzione
Un’indagine comincia da una lamentela, non da una dashboard. Questa pagina spiega come trasformare una lamentela nell’unica esecuzione che l’ha provocata.
Cerca nel payload, non nell’etichetta
Quasi tutti gli strumenti di tracing indicizzano nomi e attributi. mcpd
indicizza anche il prompt e il completion, perché quello di cui una persona
si lamenta è una cosa che ha detto il modello, e le parole che ti ripete
sono l’unica presa che hai. Il parametro q è quell’indice.
- 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
Che cosa copre q, per la precisione
L’indice tiene tre campi per span: il suo nome, il suo input e il suo output. Input e output sono JSON, quindi prima di indicizzare mcpd percorre il payload e ne tiene soltanto le stringhe e i numeri, scartando le parentesi graffe, le virgolette e le chiavi. Indicizzare il JSON grezzo farebbe di ogni segno di punteggiatura un token e seppellirebbe le parole che stai davvero cercando.
Sei conseguenze da conoscere prima di farci affidamento:
- Un’esecuzione corrisponde quando corrisponde un suo span qualsiasi. Ti tornano indietro esecuzioni intere; aprine una per vedere di quale passo si trattava.
-
Le parole sono messe insieme in AND.
q=refund delayedtrova gli span che le contengono tutte e due, ovunque nel testo indicizzato, non una accanto all’altra. -
La corrispondenza è a parola intera e non distingue maiuscole da
minuscole, e i segni diacritici sono appiattiti, quindi
resumetrovarésumé. Non c’è nessuno stemming:refundnon trovarefunds. - Nell’indice finiscono i primi 64 KiB dell’input e i primi 64 KiB dell’output. I payload più lunghi sono archiviati e mostrati per intero; soltanto la coda di un prompt molto grande resta fuori dalla ricerca.
- L’oscuramento gira prima dello storage, quindi quello che ha tolto non è mai stato nemmeno indicizzato. Vedi Oscuramento e conservazione.
- Uno span scritto più di una volta, com’è uno span di lunga durata quando finisce, sostituisce la propria riga nell’indice, così la ricerca mostra sempre la versione di adesso.
Una virgoletta di troppo è una ricerca, non un errore
L’indice che sta sotto ha un linguaggio di query suo: una virgoletta doppia
apre una frase, e NEAR, OR e * sono
operatori. Consegnargli il tuo testo così com’è vorrebbe dire che il
messaggio di un cliente che contiene una virgoletta torna indietro come
errore di sintassi invece che come risultato, e una casella di ricerca che
può sollevare un errore è una casella di ricerca che la gente smette di
usare.
Perciò il tuo testo non arriva mai a quel parser. mcpd divide quello che hai
scritto sugli spazi, toglie da ogni parola qualunque virgoletta doppia,
avvolge ogni parola come termine letterale e le unisce in AND.
q=say "no" cerca le parole say e no.
q=NEAR cerca la parola NEAR. Niente di quello che puoi
scrivere è un errore di sintassi, e nessun operatore è a tua disposizione. È
questo il compromesso, fatto in questa direzione di proposito.
Un q fatto di soli spazi viene scartato invece di non
corrispondere a niente, e un q= vuoto è trattato come assente.
Ogni parametro si comporta così, perché un modulo che manda anche le sue
caselle di filtro vuote non finisca per filtrare sulla stringa vuota.
Restringere un elenco di esecuzioni
GET /v1/api/traces prende quello che segue. Quello che lasci
fuori non viene applicato.
| Parametro | Che cosa seleziona |
|---|---|
q | Full text sul nome, sull’input e sull’output di ogni span. |
status | unset, ok, error o
cancelled. Un’esecuzione è error quando un
suo span qualsiasi è fallito, anche uno il cui errore è stato
intercettato.
|
kind |
Il tipo dello span radice dell’esecuzione, per esempio
agent_run o workflow_run.
|
name | Il nome dello span radice, esatto. Non un prefisso e non uno schema. |
tags | Separati da virgola. Un’esecuzione deve portare tutti i tag elencati, non uno qualsiasi. |
session_id | Ogni esecuzione di una stessa conversazione. |
thread_id | Ogni esecuzione di uno stesso thread. |
user_id | Ogni esecuzione per uno stesso utente finale. |
environment | Un solo ambiente, per esempio production. |
model | Le esecuzioni con almeno uno span su questo id di modello. |
from, to | La finestra, misurata sull’ora di inizio dell’esecuzione. |
min_duration_ms | Le esecuzioni lente almeno così, in millisecondi. |
min_cost | Le esecuzioni che costano almeno questo. Un’esecuzione senza span valorizzati conta come zero, quindi qualunque valore positivo la esclude. |
has_score | Le esecuzioni che portano un punteggio di questo id di scorer. |
score_below | Insieme a has_score: le esecuzioni che quello scorer ha messo sotto questo valore. |
sort | start_time, duration, cost,
span_count o score. Se non dici altro,
start_time. score ordina le esecuzioni per
il punteggio più basso che uno scorer qualsiasi ha dato loro, quindi
in ordine crescente la peggiore viene per prima; un’esecuzione senza
punteggio vale zero.
|
order | asc o desc. Se non dici altro, desc. |
limit | La dimensione della pagina, tenuta fra 1 e 500. Se non dici altro, 50. |
cursor | Il cursore opaco della pagina precedente. |
project | Quale progetto leggere. Se non dici altro, il progetto predefinito del daemon, così un’installazione con un solo progetto non deve mai passarlo. |
name, gli id di correlazione e environment sono
test di uguaglianza, ed è questo che li rende leggeri. Se vuoi una
corrispondenza parziale su un nome, usa q: anche i nomi degli
span stanno nell’indice testuale.
Quanto costa una riga di elenco
Non c’è un secondo elenco di esecuzioni più leggero, perché il primo è già leggero. La proiezione archiviata di una traccia non porta nessun input, nessun output e nessun attributo: nomi, conteggi, tempi, costo, tag e correlazione, e niente di quello che ha detto un modello. È l’apertura di un’esecuzione a portarsi dietro il payload.
Misurato su un’esecuzione realistica, un agente di assistenza in otto passi che recupera tre documenti di policy, chiama due strumenti e fa due chiamate al modello con prompt e completion veri: la riga di elenco è 577 byte e l’esecuzione intera è 10.717 byte, cioè una riduzione del 94,6%. È questo daemon, su quell’esecuzione, sulla vera superficie HTTP. Le tue esecuzioni saranno diverse, e quello che conta è la direzione: il risparmio cresce con la dimensione dei prompt, perché la parte che cresce è la parte che l’elenco non porta.
La cifra è prodotta da un test invece di essere scritta qui a mano, così non
può invecchiare in un posto senza far fallire l’altro. Esegui
cargo test -p mcpd-server --test payload_size -- --nocapture
e ti stampa tutti e due i conteggi di byte. Un campo aggiunto alla proiezione
che si porti dietro del payload fa fallire quel test.
Tutto il resto è rifiutato, per nome
Quella tabella è tutto il vocabolario. Un parametro che non ci sta dentro è
rifiutato con un 400 che lo nomina, invece di essere accettato e scartato.
Questo è cambiato: mcpd ignorava un parametro che non
conosceva, quindi ?statuss=error tornava 200 con ogni
esecuzione del progetto e niente nella risposta diceva che il filtro non era
stato applicato. Una risposta sbagliata ma credibile è peggio di un errore,
perché non c’è niente da notare.
Vale la pena dire quale compromesso comporta. Un client che manda un filtro che un daemon più recente capisce ora prende un 400 da uno più vecchio, invece di un insieme di risultati più largo. È il migliore dei due guasti: il 400 nomina il parametro e si ferma.
Certi rifiuti dicono più di «sconosciuto». mcpd registra
service_name, service_version,
resource_id, run_id, request_id,
experiment_id e release su ogni traccia, ma
l’elenco delle esecuzioni non permette ancora di filtrarli. Dei sette, solo
service_name è una faccetta della
discovery, quindi la
discovery elenca i suoi valori e nessuno degli altri. Quei sette sono
rifiutati con un messaggio che lo dice, perché
service_name=checkout-api non è un refuso, e dire a qualcuno
che è un parametro sconosciuto lo manda a caccia di un errore di ortografia
che non esiste.
La regola non è solo dell’elenco delle esecuzioni.
GET /v1/api/spans, /v1/api/logs,
/v1/api/scores, /v1/api/metrics e
/v1/api/feedback fanno valere ciascuno il proprio vocabolario
allo stesso modo. Vale la pena conoscerne due prima di incontrarli:
l’elenco dei punteggi non prende order, perché va dal più
recente e il suo cursore confronta in una sola direzione, e l’elenco dei
feedback vuole un trace_id, perché mcpd non sa ancora elencare
i feedback di un intero progetto e un elenco vuoto si leggerebbe come se non
ce ne fosse nessuno.
Riempire una casella di filtro
Un filtro serve a qualcosa solo se sai che cosa scriverci dentro, e i valori
non sono un elenco fisso: sono quello che i servizi che riferiscono a un
progetto mandano di fatto. GET /v1/api/discovery risponde a
questo. Chiedi una dimensione alla volta con facet, e ti
restituisce i valori che quella dimensione ha preso nel progetto, ordinati
per valore, ognuno con l’ultima volta che un segnale lo ha portato.
| Facet | Che cosa elenca |
|---|---|
entity_kind | Agent, workflow, tool, model, scorer, processor, pipeline o service. |
entity_name | I nomi che quelle cose hanno, per esempio gli strumenti che sono stati chiamati. |
service_name | I servizi che hanno riferito qualcosa. |
environment | Gli ambienti visti, per esempio production. |
tag | Ogni tag che porti una qualunque esecuzione. |
metric_name | I nomi di metrica registrati, compresi quelli che mcpd ricava. |
metric_label_key | Ogni chiave di etichetta vista nel progetto. |
metric_label_value | I valori di una sola etichetta. Vuole key. |
prefix restringe l’elenco per la digitazione assistita, e la
corrispondenza è alla lettera e distingue maiuscole da minuscole, perché
questi valori sono quelli con cui un filtro verrà confrontato e appiattirli
qui vorrebbe dire offrirne uno che poi non corrisponde a niente.
limit tiene la risposta entro 1000. I valori sono ordinati per
valore e non per data, così un elenco che qualcuno sta leggendo non si
rimescola sotto i suoi occhi fra un poll e l’altro.
C’è un limite che vale la pena conoscere prima di costruirci sopra.
metric_label_key elenca ogni chiave di etichetta vista in
qualunque punto del progetto, non le chiavi di una metrica con un nome
preciso: l’elenco dei valori distinti è indicizzato per etichetta, non per
la metrica che la portava.
Aggiornare un elenco senza rilanciarlo
GET /v1/api/deltas restituisce quello che è arrivato dopo un
cursore, dal più vecchio, per traces, spans o
logs. Tieni il cursore dell’ultimo poll, rimandalo indietro, e
applica le righe all’elenco che hai già.
Un poll non è una pagina, e i due cursori non sono intercambiabili, di proposito. Sfogliare le pagine percorre all’indietro un insieme di risultati fisso, nell’ordinamento che hai scelto; fare polling va in avanti nell’ordine di arrivo e non finisce mai. Il watermark è l’ora di arrivo di una riga insieme al suo id, perché un batch scrive ogni sua riga con un solo timestamp, e un watermark capace di dire soltanto «dopo questo nanosecondo» o rigiocherebbe tutto il batch o ne salterebbe il resto. Un poll vuoto restituisce il cursore invariato: spostarlo in avanti farebbe cadere una riga che atterra un istante dopo con un’ora di arrivo precedente.
Su punteggi e feedback non si può fare polling. Portano il timestamp che ha
mandato il chiamante e non hanno nessuna ora di arrivo messa dal server,
quindi un watermark su di loro o salterebbe una riga in ritardo o
ripeterebbe ogni riga per sempre. signal=scores è rifiutato,
non risposto con una pagina vuota.
Che cosa sa fare questo deploy
mcpd ha un solo trait di storage e tre backend dietro di esso, e non sono
uguali. Su ClickHouse una cancellazione viene accettata e applicata dopo
che la chiamata ritorna, quindi i conteggi della sua risposta dicono che
cosa corrispondeva e non che cosa ha già lasciato il disco. Su SQLite e
Postgres le righe non ci sono più quando la chiamata ritorna.
GET /v1/api/capabilities è il posto dove
tutto questo è scritto: quale backend sta dietro il daemon, che cosa fa
corrispondere q, se le maiuscole sono appiattite anche fuori da
ASCII, se una cancellazione è su disco quando la chiamata ritorna, e se la
discovery e il polling dei delta rispondono o no.
Leggilo una volta al momento della connessione e offri soltanto quello a cui
il deploy risponde. Un endpoint la cui capacità è falsa rifiuta con
501 e con un corpo il cui campo capability nomina
la stessa parola che pubblica l’insieme delle capacità, così i due non
possono mai essere in disaccordo e un client che ha incontrato il rifiuto lo
può andare a cercare invece di riprovare una cosa che non cambierà mai.
Tutti e tre i backend disponibili oggi rispondono sia alla discovery sia al polling dei delta, quindi il 501 qui sotto è la forma con cui rifiuterebbe un backend aggiunto più avanti, e non una risposta che vedrai da SQLite, da Postgres o da ClickHouse. L’insieme delle capacità è quello che ti dice di quale si tratta, e vale la pena leggerlo anche adesso: i tre differiscono sul fatto che una cancellazione sia su disco quando la chiamata ritorna, e su come viene fatta valere la conservazione. Tutti e tre appiattiscono le maiuscole anche fuori da ASCII.
su che cosa posso filtrare?
# Ogni ambiente che questo progetto ha riferito, per una casella di filtro.
curl -s --get http://localhost:4317/v1/api/discovery \
--data-urlencode 'project=default' \
--data-urlencode 'facet=environment'
# Digitazione assistita: i nomi di servizio che cominciano per "check".
curl -s --get http://localhost:4317/v1/api/discovery \
--data-urlencode 'facet=service_name' \
--data-urlencode 'prefix=check'
# I valori che prende un'etichetta di metrica.
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."
} Le finestre
from e to accettano un timestamp RFC 3339, oppure
una forma breve relativa: un segno meno, un numero e una fra s,
m, h e d. -15m,
-1h e -7d funzionano tutte. La forma breve esiste
perché scrivere a mano un timestamp ISO per guardare l’ultima ora è un
attrito che nessuno dovrebbe pagare.
from è inclusivo e to è esclusivo, e tutti e due si
confrontano con l’ora in cui l’esecuzione è partita. Un’esecuzione
lunga che è cominciata prima della tua finestra ed era ancora in corso
dentro di essa non viene restituita. Se stai dando la caccia a qualcosa che
girava alle 09:00, allarga from all’indietro invece di
stringerlo.
Un valore che non è né un timestamp né una durata è rifiutato per nome, invece che ignorato. Ogni parametro di ogni endpoint di elenco fallisce così, perché un filtro scartato in silenzio restituisce una risposta sbagliata ma credibile, e una risposta sbagliata ma credibile è peggio di un errore.
from=yesterday
{
"error": "bad_parameter",
"field": "from",
"message": "\"yesterday\" is not an RFC 3339 timestamp or a relative duration like -1h"
} La query che trova le risposte sbagliate
has_score=answer_relevance&score_below=0.5 restituisce ogni
esecuzione che quello scorer ha messo sotto la metà. È questa la query da
prendere quando la lamentela riguarda la qualità e non un guasto, perché una risposta
sbagliata di solito sta dentro un’esecuzione riuscita.
has_score da solo restituisce ogni esecuzione che quello scorer
ha guardato. score_below da solo non fa proprio niente: una
soglia senza uno scorer attaccato non vuol dire niente, e mcpd dovrebbe
indovinare quale scorer intendevi. Non indovina.
Punteggi e feedback
racconta da dove vengono quei valori.
Cercare passi invece di esecuzioni
GET /v1/api/spans cerca i singoli passi su tutte le esecuzioni
in una volta sola. Usalo per le domande su un passo e non su una
sessione: quale chiamata a uno strumento è lenta, quali chiamate a un
modello hanno dato errore, quanto dura un recupero.
Prende q, kind, status,
name, model, trace_id,
from, to, min_duration_ms,
sort, order, limit e
cursor, con gli stessi significati e gli stessi valori
predefiniti. Qui non c’è nessun filtro tags,
session_id o user_id, perché quelli appartengono
all’esecuzione, e tutti e tre sono rifiutati per nome invece che accettati
e scartati.
sort prende qui meno valori che nell’elenco delle esecuzioni:
start_time, duration o cost, e
nient’altro. Uno span non porta nessun conteggio di span e nessun riepilogo
dei punteggi, quindi span_count e score non hanno
niente su cui ordinare e sono rifiutati per nome invece che ignorati.
Su un’esecuzione, model chiede se uno span qualsiasi lo ha
usato. Su uno span, model è il modello di quello span. La
stessa parola, due domande.
trovarla
# Le esecuzioni fallite dell'ultimo giorno che citano tutte e due le parole,
# ovunque in un nome di span, in un prompt o in un completion.
curl -s --get http://localhost:4317/v1/api/traces \
--data-urlencode 'q=Leeds depot' \
--data-urlencode 'status=error' \
--data-urlencode 'from=-24h'
# Ogni passo che ha chiamato un modello ed è fallito, su tutte le
# esecuzioni in una volta sola.
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) Le pagine, e perché il cursore è opaco
Un elenco restituisce al massimo limit righe e un oggetto
page. page.next_cursor c’è quando c’è dell’altro,
e page.has_more dice la stessa cosa come booleano.
Le pagine vanno a keyset, non a offset. Il cursore nomina l’ultima riga che ti è stata data, e la pagina dopo comincia subito dopo di essa. Le pagine a offset andrebbero alla deriva: le tracce arrivano di continuo, quindi quando chiedi le righe dalla 50 alla 99 la finestra si è già spostata sotto di te, e leggi due volte un’esecuzione mentre un’altra non compare mai. In un’indagine, l’esecuzione che ti sfugge è quella che stavi cercando.
Il cursore è una coppia, codificata in esadecimale, fatta del valore di
ordinamento e dell’id della riga, e devi trattarlo come privo di
significato. È opaco perché la codifica possa cambiare senza rompere ogni
chiamante, e perché il valore che sta dentro è una quantità diversa per ogni
ordinamento: nanosecondi per start_time e
duration, un intero scalato per cost, un conteggio
semplice per span_count, il punteggio più basso
dell’esecuzione per score.
A questo è attaccata una regola. Rimanda indietro un cursore con gli
stessi filtri, lo stesso sort e lo stesso order
che lo hanno prodotto. mcpd non controlla che tu l’abbia fatto, e
un cursore rigiocato su un ordinamento diverso si confronta con la
colonna sbagliata e salta delle righe. Un cursore modificato o troncato è
rifiutato con invalid_query e il messaggio
malformed cursor.
Non c’è nessun totale. page.total è tenuto da parte per un
backend che sappia contare le corrispondenze a poco prezzo, e lo storage
SQLite integrato non lo riempie, quindi cicla su has_more
invece di contare verso un numero.
scorrere le pagine
# La prima pagina restituisce un cursore 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'
# La pagina dopo: gli stessi filtri, lo stesso ordinamento, più il cursore.
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')
}
// Cicla su hasMore. Non esiste un totale verso cui contare.
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)
# Cicla su has_more. Non esiste un totale verso cui contare.
if not page.page.has_more:
break
cursor = page.page.next_cursor Una volta che hai l’esecuzione
Un id di traccia preso da una qualunque di queste query va a
GET /v1/api/traces/{trace_id}, che restituisce l’esecuzione
e ogni span al suo interno, già ordinati perché un waterfall si disegni
senza una seconda richiesta. Aggiungi
include=logs,scores,feedback per avere nella stessa risposta
tutto quello che le sta attaccato.
Da lì, Contabilità dei costi spiega le cifre su ogni passo, e il riferimento dell’API HTTP elenca ogni endpoint e ogni campo come li dichiara la specifica.