Retrouver une exécution
Une enquête commence par une plainte, pas par un tableau de bord. Cette page montre comment transformer une plainte en l’exécution précise qui l’a causée.
Cherchez le payload, pas l’étiquette
La plupart des outils de traçage indexent les noms et les attributs. mcpd
indexe aussi le prompt et la complétion, parce que ce dont une personne se
plaint est quelque chose que le modèle a dit, et que les mots qu’elle vous
cite sont la seule prise dont vous disposez. Le paramètre q est
cet index.
- 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
Ce que q couvre, exactement
L’index tient trois champs par span : son nom, son entrée et sa sortie. L’entrée et la sortie sont du JSON, donc avant l’indexation mcpd parcourt le payload et n’en garde que les chaînes et les nombres, en écartant les accolades, les guillemets et les clés. Indexer le JSON brut ferait de chaque signe de ponctuation un token et enfouirait les mots que vous cherchez vraiment.
Six conséquences valent d’être connues avant de vous y fier :
- Une exécution correspond dès que l’un quelconque de ses spans correspond. Vous récupérez des exécutions entières ; ouvrez-en une pour voir de quelle étape il s’agissait.
-
Les mots sont combinés par ET.
q=refund delayedtrouve les spans qui contiennent les deux, n’importe où dans le texte indexé, sans qu’ils soient voisins. -
La correspondance porte sur le mot entier, sans distinction de casse, et
les diacritiques sont repliés :
resumetrouve doncrésumé. Il n’y a pas de racinisation :refundne trouve pasrefunds. - Les 64 premiers KiB de l’entrée et de la sortie entrent dans l’index. Les payloads plus longs sont stockés et affichés en entier ; seule la fin d’un très gros prompt échappe à la recherche.
- Le masquage a lieu avant le stockage : ce qu’une règle a retiré n’a jamais été indexé non plus. Voir Masquage et conservation.
- Un span écrit plus d’une fois, comme l’est un span de longue durée quand il se termine, remplace sa ligne d’index : la recherche reflète donc toujours la version courante.
Un guillemet égaré est une recherche, pas une erreur
L’index sous-jacent a son propre langage de requête : un guillemet double
ouvre une phrase, et NEAR, OR et *
sont des opérateurs. Lui remettre votre texte tel quel voudrait dire qu’un
message client contenant un guillemet revient en erreur de syntaxe au lieu
d’un résultat, et une boîte de recherche qui peut échouer est une boîte de
recherche que les gens cessent d’utiliser.
Votre texte n’atteint donc jamais cet analyseur. mcpd découpe ce que vous
avez tapé sur les espaces, retire tout guillemet double de chaque mot,
enveloppe chaque mot comme un terme littéral et les joint par ET.
q=say "no" cherche les mots say et no.
q=NEAR cherche le mot NEAR. Rien de ce que vous pouvez taper
n’est une erreur de syntaxe, et aucun opérateur ne vous est offert. C’est le
marché, conclu dans ce sens à dessein.
Un q qui ne contient que des espaces est abandonné plutôt que
de ne rien faire correspondre, et un q= vide est traité comme
absent. Tous les paramètres se comportent ainsi, de sorte qu’un formulaire
qui soumet ses boîtes de filtre vides ne filtre pas par accident sur la
chaîne vide.
Restreindre une liste d’exécutions
GET /v1/api/traces accepte ce qui suit. Tout ce que vous omettez
n’est pas appliqué.
| Paramètre | Ce qu’il sélectionne |
|---|---|
q | Plein texte sur le nom, l’entrée et la sortie de chaque span. |
status | unset, ok, error ou
cancelled. Une exécution est en error dès
qu’un span en elle a échoué, même si son erreur a été rattrapée.
|
kind |
Le genre du span racine de l’exécution, par exemple
agent_run ou workflow_run.
|
name | Le nom du span racine, exactement. Ni un préfixe, ni un motif. |
tags | Séparés par des virgules. Une exécution doit porter toutes les étiquettes listées, et non l’une d’elles. |
session_id | Toutes les exécutions d’une conversation. |
thread_id | Toutes les exécutions d’un fil. |
user_id | Toutes les exécutions d’un utilisateur final. |
environment | Un environnement, par exemple production. |
model | Les exécutions ayant au moins un span contre cet identifiant de modèle. |
from, to | La fenêtre, mesurée sur l’heure de début de l’exécution. |
min_duration_ms | Les exécutions au moins aussi lentes, en millisecondes. |
min_cost | Les exécutions qui coûtent au moins cela. Une exécution sans span tarifé compte pour zéro : toute valeur positive l’exclut donc. |
has_score | Les exécutions portant un score de ce scorer. |
score_below | Avec has_score : les exécutions que ce scorer a mises sous cette valeur. |
sort | start_time, duration, cost,
span_count ou score. Vaut
start_time par défaut. score ordonne les
exécutions par le plus bas score que tout scorer leur a donné : en
ordre croissant, la pire vient donc en tête, et une exécution sans
score compte pour zéro.
|
order | asc ou desc. Vaut desc par défaut. |
limit | La taille de page, ramenée entre 1 et 500. Vaut 50 par défaut. |
cursor | Le curseur opaque de la page précédente. |
project | Le projet à lire. Vaut le projet par défaut du daemon, de sorte qu’une installation à projet unique n’a jamais à le passer. |
name, les identifiants de corrélation et
environment sont des tests d’égalité, et c’est ce qui les rend
peu coûteux. Si vous voulez une correspondance partielle sur un nom, passez
par q : les noms de span sont dans l’index de texte eux aussi.
Ce que coûte une ligne de liste
Il n’existe pas de seconde liste d’exécutions plus légère, parce que la première l’est déjà. La projection de trace stockée ne porte aucune entrée, aucune sortie et aucun attribut : des noms, des comptes, des temps, un coût, des étiquettes et la corrélation, et rien de ce qu’un modèle a dit. C’est l’ouverture d’une exécution qui va chercher le payload.
Mesuré sur une exécution réaliste, un agent de support en huit étapes qui récupère trois documents de procédure, appelle deux outils et fait deux appels de modèle avec de vrais prompts et de vraies complétions : la ligne de liste fait 577 octets et l’exécution entière 10 717 octets, soit une réduction de 94.6 %. Il s’agit de ce daemon, sur cette exécution, à travers la vraie surface HTTP. Vos exécutions différeront, et c’est le sens qui compte : l’économie croît avec la taille des prompts, parce que ce qui grossit est justement ce que la liste ne porte pas.
Le chiffre est produit par un test plutôt que tapé ici, si bien qu’il ne
peut pas devenir périmé d’un côté sans faire échouer l’autre. Lancez
cargo test -p mcpd-server --test payload_size -- --nocapture et
il affiche les deux volumes en octets. Un champ ajouté à la projection et
qui porte du payload fait échouer ce test.
Tout le reste est refusé, nommément
Ce tableau est tout le vocabulaire. Un paramètre qui n’y figure pas est
refusé par un 400 qui le nomme, plutôt qu’accepté puis abandonné. C’est un
changement : mcpd ignorait autrefois un paramètre qu’il ne connaissait pas,
si bien que ?statuss=error revenait en 200 avec toutes les
exécutions du projet et que rien dans la réponse ne disait que le filtre
n’avait pas été appliqué. Une réponse fausse mais plausible est pire qu’une
erreur, parce qu’il n’y a rien à remarquer.
Le marché ainsi conclu mérite d’être nommé. Un client qui envoie un filtre qu’un daemon plus récent comprend reçoit désormais un 400 d’un daemon plus ancien, au lieu d’un ensemble de résultats plus large. C’est le meilleur des deux échecs : le 400 nomme le paramètre et s’arrête.
Certains refus en disent plus que « inconnu ». mcpd enregistre
service_name, service_version,
resource_id, run_id, request_id,
experiment_id et release sur chaque trace, mais
la liste d’exécutions ne sait pas encore filtrer dessus. Des sept, seul
service_name est une facette de la
découverte, qui liste
donc ses valeurs et aucune de celles des autres. Ces sept-là sont refusés
avec un message qui le dit, car service_name=checkout-api n’est
pas une faute de frappe, et répondre à quelqu’un qu’il s’agit d’un paramètre
inconnu l’envoie chasser une faute d’orthographe qui n’existe pas.
La règle n’appartient pas qu’à la liste d’exécutions.
GET /v1/api/spans, /v1/api/logs,
/v1/api/scores, /v1/api/metrics et
/v1/api/feedback imposent chacun leur propre vocabulaire de la
même façon. Deux méritent d’être connus avant que vous ne les rencontriez :
la liste des scores ne prend pas d’order, parce qu’elle va du
plus récent au plus ancien et que son curseur ne compare que dans un sens ;
et la liste des retours exige un trace_id, parce que mcpd ne
sait pas encore lister les retours d’un projet entier et qu’une liste vide
se lirait comme s’il n’y en avait aucun.
Remplir une boîte de filtre
Un filtre n’est utilisable que si vous savez quoi y taper, et les valeurs ne
forment pas une liste figée : ce sont celles que les services rapportant à
un projet envoient. GET /v1/api/discovery y répond. Demandez une
dimension à la fois avec facet, et il rend les valeurs que cette
dimension a prises dans le projet, ordonnées par valeur, chacune avec la date
à laquelle un signal l’a portée pour la dernière fois.
| Facette | Ce qu’elle liste |
|---|---|
entity_kind | Agent, workflow, outil, modèle, scorer, processeur, pipeline ou service. |
entity_name | Les noms que portent ces choses, par exemple les outils qui ont été appelés. |
service_name | Les services qui ont rapporté. |
environment | Les environnements vus, par exemple production. |
tag | Toutes les étiquettes que porte une exécution quelconque. |
metric_name | Les noms de métrique enregistrés, y compris ceux que mcpd dérive. |
metric_label_key | Toutes les clés d’étiquette vues dans le projet. |
metric_label_value | Les valeurs d’une étiquette. Demande key. |
prefix restreint la liste pour la saisie assistée, et il
compare littéralement et en distinguant la casse, parce que ces valeurs sont
ce à quoi un filtre sera comparé et que replier ici en proposerait une qui ne
correspondrait ensuite à rien. limit plafonne la réponse à 1000.
Les valeurs sont ordonnées par valeur et non par fraîcheur, de sorte qu’une
liste que quelqu’un est en train de lire ne se réorganise pas sous ses yeux
entre deux interrogations.
Une limite mérite d’être connue avant que vous ne construisiez dessus.
metric_label_key liste toutes les clés d’étiquette vues où que
ce soit dans le projet, et non les clés d’une métrique nommée : la liste des
valeurs distinctes est indexée par l’étiquette, pas par la métrique qui la
portait.
Rafraîchir une liste sans la relancer
GET /v1/api/deltas rend ce qui est arrivé depuis un curseur, du
plus ancien au plus récent, pour traces, spans ou
logs. Gardez le curseur de la dernière interrogation, renvoyez-le,
et appliquez les lignes à la liste que vous détenez déjà.
Une interrogation n’est pas une page, et les deux curseurs ne sont délibérément pas interchangeables. La pagination parcourt un ensemble de résultats figé, à rebours et dans le tri que vous avez choisi ; l’interrogation avance dans l’ordre d’arrivée et ne finit jamais. Le repère est l’heure d’arrivée d’une ligne et son identifiant pris ensemble, car un lot écrit toutes ses lignes avec un même horodatage, et un repère qui ne pourrait dire que « après cette nanoseconde » rejouerait le lot entier ou en sauterait le reste. Une interrogation vide rend le curseur inchangé : l’avancer ferait perdre une ligne qui atterrit un instant plus tard avec une heure d’arrivée antérieure.
Les scores et les retours ne peuvent pas être interrogés ainsi. Ils
portent l’horodatage que l’appelant a envoyé et aucune heure d’arrivée posée
par le serveur, donc un repère sur eux sauterait une ligne tardive ou
répéterait chaque ligne à l’infini. signal=scores est refusé
plutôt que répondu par une page vide.
Ce que ce déploiement sait faire
mcpd a un seul trait de stockage et trois backends derrière lui, et ils ne
sont pas équivalents. Sur ClickHouse, une suppression est acceptée puis
appliquée après le retour de l’appel, donc les décomptes de sa réponse
disent ce qui correspondait et non ce qui a quitté le disque. Sur SQLite et
Postgres, les lignes ont disparu au retour de l’appel.
GET /v1/api/capabilities est l’endroit où
cela est écrit : quel backend se trouve derrière le daemon, ce à quoi
q correspond, si la casse est repliée hors ASCII, si une
suppression est sur disque au retour de l’appel, et si la découverte et
l’interrogation par deltas répondent tout court.
Lisez-les une fois à la connexion et n’offrez que ce à quoi le déploiement
répond. Un endpoint dont la capacité est fausse refuse par un
501 et un corps dont le champ capability nomme le
mot même que publie le jeu de capacités : les deux ne peuvent donc jamais se
contredire, et un client qui a rencontré le refus peut aller le consulter
plutôt que réessayer une chose qui ne changera jamais.
Les trois backends livrés aujourd’hui répondent à la découverte comme à l’interrogation par deltas ; le 501 ci-dessous est donc la forme du refus d’un backend ajouté plus tard, et non une réponse que vous verrez de SQLite, de Postgres ou de ClickHouse. C’est le jeu de capacités qui vous dit lequel est là, et il vaut la peine d’être lu dès maintenant : les trois diffèrent sur le fait qu’une suppression soit sur disque au retour de l’appel, et sur la façon dont la conservation est appliquée. Les trois replient la casse hors ASCII.
sur quoi puis-je filtrer ?
# Tous les environnements que ce projet a déclarés, pour une boîte de filtre.
curl -s --get http://localhost:4317/v1/api/discovery \
--data-urlencode 'project=default' \
--data-urlencode 'facet=environment'
# Saisie assistée : les noms de service qui commencent par "check".
curl -s --get http://localhost:4317/v1/api/discovery \
--data-urlencode 'facet=service_name' \
--data-urlencode 'prefix=check'
# Les valeurs que prend une étiquette de métrique.
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."
} Les fenêtres
from et to acceptent un horodatage RFC 3339, ou une
forme relative abrégée : un signe moins, un nombre et l’une des lettres
s, m, h ou d.
-15m, -1h et -7d marchent tous. Cette
abréviation existe parce que taper un horodatage ISO à la main pour regarder
la dernière heure est une friction que personne ne devrait payer.
from est inclusif et to exclusif, et tous deux se
comparent à l’heure à laquelle l’exécution a démarré. Une longue
exécution commencée avant votre fenêtre et toujours en cours pendant
celle-ci n’est pas rendue. Si vous cherchez quelque chose qui tournait à
09:00, élargissez from vers l’arrière plutôt que de le
resserrer.
Une valeur qui n’est ni un horodatage ni une durée est refusée nommément plutôt qu’ignorée. Chaque paramètre de chaque endpoint de liste échoue de cette façon, car un filtre silencieusement abandonné rend une réponse fausse mais plausible, et une réponse fausse mais plausible est pire qu’une erreur.
from=yesterday
{
"error": "bad_parameter",
"field": "from",
"message": "\"yesterday\" is not an RFC 3339 timestamp or a relative duration like -1h"
} La requête qui trouve les mauvaises réponses
has_score=answer_relevance&score_below=0.5 rend toutes les
exécutions que ce scorer a mises sous la moitié. C’est celle qu’il faut
saisir quand la plainte porte sur la qualité plutôt que sur une panne, parce
qu’une mauvaise réponse vient le plus souvent d’une exécution qui a réussi.
has_score seul rend toutes les exécutions que ce scorer a
examinées. score_below seul ne fait strictement rien : un seuil
sans scorer attaché n’a pas de sens, et mcpd devrait deviner de quel scorer
vous parlez. Il ne devine pas.
Scores et retours
explique d’où viennent ces valeurs.
Chercher des étapes plutôt que des exécutions
GET /v1/api/spans cherche des étapes individuelles sur toutes
les exécutions à la fois. Servez-vous-en pour les questions qui portent sur
une étape plutôt que sur une session : quel appel d’outil est lent, quels
appels à un modèle ont échoué, combien de temps prend une récupération.
Il accepte q, kind, status,
name, model, trace_id,
from, to, min_duration_ms,
sort, order, limit et
cursor, avec les mêmes sens et les mêmes valeurs par défaut. Il
n’y a ici aucun filtre tags, session_id ou
user_id, parce que ceux-là appartiennent à l’exécution, et tous
les trois sont refusés nommément plutôt qu’acceptés puis abandonnés.
sort prend ici moins de valeurs que sur la liste d’exécutions :
start_time, duration ou cost, et rien
d’autre. Un span ne porte ni nombre de spans ni cumul de scores, donc
span_count et score n’ont rien sur quoi trier et
sont refusés nommément plutôt qu’ignorés.
Sur une exécution, model demande si un span quelconque l’a
utilisé. Sur un span, model est le modèle de ce span. Le même
mot, deux questions.
la trouver
# Les exécutions en échec du dernier jour qui mentionnent les deux mots,
# où que ce soit dans un nom de span, un prompt ou une complétion.
curl -s --get http://localhost:4317/v1/api/traces \
--data-urlencode 'q=Leeds depot' \
--data-urlencode 'status=error' \
--data-urlencode 'from=-24h'
# Toutes les étapes qui ont appelé un modèle et échoué, sur toutes les
# exécutions à la fois.
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) La pagination, et pourquoi le curseur est opaque
Une liste rend au plus limit lignes et un objet
page. page.next_cursor est présent quand il y a
davantage à lire, et page.has_more dit la même chose sous forme
de booléen.
La pagination se fait par clé, non par décalage. Le curseur nomme la dernière ligne qui vous a été donnée, et la page suivante commence strictement après elle. Une pagination par décalage dériverait : les traces arrivent en continu, donc le temps que vous demandiez les lignes 50 à 99, la fenêtre a glissé sous vous et vous lisez une exécution deux fois pendant qu’une autre n’apparaît jamais. Dans une enquête, l’exécution que vous ratez est celle que vous cherchiez.
Le curseur est une paire encodée en hexadécimal formée de la valeur de tri
et de l’identifiant de ligne, et vous devriez le tenir pour dépourvu de
sens. Il est opaque pour que l’encodage puisse changer sans casser tous les
appelants, et parce que la valeur qu’il contient est une grandeur
différente selon le tri : des nanosecondes pour start_time et
duration, un entier mis à l’échelle pour cost, un
simple compte pour span_count, le plus bas score de l’exécution
pour score.
Une règle s’y attache. Renvoyez un curseur avec les mêmes filtres,
le même sort et le même order que ceux qui l’ont
produit. mcpd ne vérifie pas que vous l’avez fait, et un curseur
rejoué contre un autre tri se compare à la mauvaise colonne et saute des
lignes. Un curseur modifié ou tronqué est refusé avec
invalid_query et le message malformed cursor.
Il n’y a pas de total. page.total est réservé à un backend
capable de compter les correspondances à bas coût, et le stockage SQLite
intégré ne le remplit pas : bouclez donc sur has_more plutôt
que de compter vers un nombre.
parcourir les pages
# La première page rend un curseur dans 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 page suivante : mêmes filtres, même tri, plus le curseur.
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')
}
// Boucler sur hasMore. Il n'y a pas de total vers lequel compter.
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)
# Boucler sur has_more. Il n'y a pas de total vers lequel compter.
if not page.page.has_more:
break
cursor = page.page.next_cursor Une fois l’exécution trouvée
Un identifiant de trace issu de n’importe laquelle de ces requêtes va à
GET /v1/api/traces/{trace_id}, qui rend l’exécution et tous
ses spans, déjà ordonnés pour qu’une cascade se dessine sans seconde
requête. Ajoutez include=logs,scores,feedback pour obtenir dans
la même réponse tout ce qui y est rattaché.
À partir de là, Comptabilité des coûts explique les chiffres portés par chaque étape, et la référence de l’API HTTP liste tous les endpoints et tous les champs tels que la spécification les déclare.