TypeScript
Un seul paquet, sans dépendances. Il enregistre les spans, les logs, les métriques, les scores et le feedback, et il ne lève jamais d’exception depuis un appel d’instrumentation.
Installation
Node 20 ou plus récent, Deno, Bun, les navigateurs et les runtimes edge. ESM
d’abord, avec une version CommonJS pour require. Les seules
parties propres à Node, async_hooks et zlib, sont
chargées à l’exécution et ignorées là où elles n’existent pas.
npm install @mcpd/sdk Construire le client
Un client par processus. Chaque option lit aussi une variable
d’environnement, si bien que le même code tourne en développement et en
production sans la moindre modification. Sans aucun argument, il s’adresse à
un daemon sur http://localhost:4317.
se connecter
import { Mcpd } from '@mcpd/sdk'
// Every field here also reads an environment variable, so the same code runs
// unchanged in development and in production. With no arguments at all this
// talks to a daemon on http://localhost:4317.
const mcpd = new Mcpd({
project: 'support',
environment: 'development',
serviceName: 'support-agent',
serviceVersion: '1.4.0',
}) Le code l’emporte sur l’environnement, et l’environnement l’emporte sur la valeur par défaut.
| Option | Environnement | Par défaut |
|---|---|---|
endpoint | MCPD_ENDPOINT | http://localhost:4317 |
apiKey | MCPD_API_KEY | aucune |
project | MCPD_PROJECT | default |
environment | MCPD_ENVIRONMENT | aucun |
serviceName | MCPD_SERVICE_NAME | aucun |
serviceVersion | MCPD_SERVICE_VERSION | aucune |
sample | MCPD_SAMPLE | 1.0 |
enabled | MCPD_ENABLED | true |
capturePayloads | MCPD_CAPTURE_PAYLOADS | true |
logLevel | MCPD_LOG_LEVEL | debug |
batchSize | MCPD_BATCH_SIZE | 128 |
flushIntervalMs | MCPD_FLUSH_INTERVAL_MS | 2000 |
maxQueue | MCPD_MAX_QUEUE | 8192 |
timeoutMs | MCPD_TIMEOUT_MS | 10000 |
sink | le transport HTTP |
enabled: false n’enregistre rien et n’envoie rien, alors que
chaque appel renvoie toujours un objet valide et que les lectures de liste
renvoient une page vide. Une suite de tests peut couper mcpd sans toucher
une seule ligne d’instrumentation.
capturePayloads: false garde les prompts et les complétions
dans le processus. Le span est toujours enregistré avec ses temps, son
statut, sa consommation, son modèle, sa corrélation et ses erreurs ;
input et output sont écartés avant d’atteindre la
file d’attente, si bien que rien de sensible ne peut sortir lors d’un flush.
Un span change ce réglage pour lui-même avec capturePayloads
dans ses options de départ, dans un sens comme dans l’autre.
Enregistrer une exécution
withSpan est la forme à portée délimitée. Elle ouvre le span, le
garde actif pour tout ce que le callback démarre, et le termine quand le
callback se résout. Si le callback lève une exception, le span est marqué
error avec le message levé et l’erreur est relancée telle
quelle : votre propre gestion des erreurs n’est pas altérée par le fait
d’être observée.
enregistrer une exécution
/**
* One ticket, one trace.
*
* `withSpan` opens the span, keeps it active for everything the callback
* starts, and ends it when the callback settles. Nothing inside has to be
* passed a handle to be nested under it.
*/
async function handleTicket(ticket: Ticket): Promise<{ answer: string; traceId: string }> {
let traceId = ''
const answer = await mcpd.withSpan('support_agent', async (run) => {
// Keep the id: a score or a piece of feedback that arrives later attaches
// to this run by it.
traceId = run.traceId
// Tags label the whole run, so the trace list can filter on them.
run.setTags(['support', 'tier-1'])
run.setCorrelation({ sessionId: ticket.id, userId: ticket.customerId })
run.setInput({ question: ticket.question })
const order = await lookUpOrder(ticket)
const answer = await draftAnswer(ticket, order)
run.setOutput({ answer })
return answer
})
return { answer, traceId }
} Contexte
Sur Node et Bun, le span actif vit dans un AsyncLocalStorage.
Tout ce qui démarre à l’intérieur d’un callback withSpan
s’imbrique dessous sans recevoir de référence, et deux exécutions
entrelacées sur une même boucle d’événements ne voient jamais les spans
l’une de l’autre.
imbrication
// Tout ce que le callback démarre s’imbrique dessous. Rien n’est passé.
await mcpd.withSpan('support_agent', async (run) => {
const order = await mcpd.withSpan(
'lookup_order',
async (tool) => {
tool.setInput({ orderId: 'A-1042' })
mcpd.log('info', 'this line belongs to lookup_order')
return { status: 'in transit' }
},
{ kind: 'tool_call' },
)
run.setOutput(order)
})
Les navigateurs et la plupart des runtimes edge n’ont pas
d’AsyncLocalStorage. Le SDK y conserve à la place une seule
valeur mémorisée. Elle s’imbrique correctement et survit à un
await au sein d’une exécution, mais elle ne sait pas distinguer
deux exécutions concurrentes, et le résultat serait l’appel d’outil d’une
exécution apparaissant dans l’arbre d’une autre. Quand vous faites tourner
des agents en parallèle sur ces runtimes, passez le parent explicitement.
parent explicite
// Correct sur tous les runtimes, même ceux sans AsyncLocalStorage.
const run = mcpd.trace('support_agent')
const step = run.child('call_model', { kind: 'model_generation' })
step.end()
await run.withChild('call_tool', async (tool) => {
tool.setOutput({ ok: true })
})
run.end()
// Quel mécanisme est en usage, à l’exécution.
mcpd.stats().context // 'async-local-storage' | 'fallback'
Entre services, le contexte voyage dans un en-tête W3C
traceparent. C’est tout ce qui fait qu’une trace survit à un
saut réseau. Un en-tête mal formé est ignoré et une nouvelle trace racine
démarre, car un en-tête que personne ne peut lire n’est pas une raison de
faire échouer la requête qui le transportait.
d’un service à l’autre
// En sortie, posez le span actif sur la requête.
await fetch(url, {
headers: { traceparent: span.traceparent() },
})
// En entrée, continuez cette trace au lieu d’en démarrer une nouvelle.
const run = mcpd.trace('handle_request', {
traceparent: request.headers.get('traceparent'),
}) Modèle, consommation et erreurs
mcpd tarife les décomptes de tokens à l’ingestion : le coût porté par la
trace est donc celui qui vous a été facturé. Reportez ce que le fournisseur
vous a indiqué plutôt que de compter les tokens vous-même. Une erreur porte
toujours un message, et en définir une place du même geste le statut du span
sur error, si bien que les deux ne peuvent jamais se
contredire.
ce que porte un span
generation.setModel('claude-sonnet-4', 'anthropic')
generation.setUsage({
inputTokens: 812,
outputTokens: 37,
inputDetails: { cacheRead: 640 },
})
// Un échec que vous avez intercepté et traité vous-même.
generation.setError('the provider timed out', {
kind: 'TimeoutError',
retryable: true,
})
// Un instant à l’intérieur du span, sans durée propre.
generation.event('first_token', { kind: 'model_chunk' }) Exécutions durables
Le même client dessert les exécutions durables du daemon.
workflow() construit la définition et retient le corps des
étapes ; un Worker l’enregistre, attend des leases en long
polling et sert chacun d’eux, et mcpd.runs,
mcpd.events et mcpd.definitions reprennent un pour
un la surface des exécutions. Un corps d’étape lève
RetryableError pour être réessayé dans le budget de l’étape, et
enregistre des spans sous son étape via le StepContext. Un span
enregistré ainsi, et chaque span imbriqué dessous, ajoute sa consommation à
celle de l’étape quand il se termine : le daemon tarife donc ce que montre
la trace et le corps ne totalise jamais les tokens à la main ;
ctx.setUsage est une substitution explicite. Quatre hooks
accompagnent onError : onLease,
onStepStart, onStepEnd et
onConflict, et worker.stats() compte les leases
prises, les achèvements, les échecs par classe et les conflits par motif.
un workflow
const support = workflow({ id: 'support_ticket' })
.step('classify', async (ctx, input: { ticket: string }) => ({
kind: input.ticket.startsWith('Q') ? 'question' : 'bug',
}))
.agent(
'draft',
async (ctx, input: { kind: string }) => {
// Your own model call, recorded under this step in the run's trace. Its
// usage adds up into the step's, which the daemon prices.
return await ctx.withSpan('model_call', async (span) => {
span.setModel('claude-sonnet-4', 'anthropic')
span.setUsage({ inputTokens: 812, outputTokens: 37 })
return `an answer to a ${input.kind}`
})
},
{ input: { kind: '${steps.classify.output.kind}' } },
)
.waitFor('approval', {
event: 'approved',
key: '${input.ticket}',
timeoutMs: 7 * 24 * 3_600_000,
when: when('steps.classify.output.kind', 'eq', 'bug'),
})
.step(
'send',
async (ctx, input: { text: string }) => {
if (!(await deliver(input.text, ctx.idempotencyKey))) {
throw new RetryableError('mail relay busy', { retryAfterMs: 2_000 })
}
return { sent: true }
},
{
input: { text: '${steps.draft.output}' },
effect: 'at_most_once',
retry: { maxAttempts: 3, backoff: 'exponential', initialMs: 500 },
},
)
.output({ sent: '${steps.send.output.sent}' }) Ce que fait chaque construction, le protocole de lease et l’API des exécutions sont décrits dans Workflows, Workers et Exécutions.
Scores et feedback
min et max sont exigés par le type. Un score de
0.7 sur une échelle non déclarée ne peut être comparé à rien, et quand
quelqu’un lit le graphique il est trop tard pour demander ce qu’il voulait
dire. Le feedback est le jugement d’une personne et reste distinct d’un
score, car les deux répondent à des questions différentes et ne doivent
jamais être moyennés ensemble.
score et feedback
// A machine evaluation of the run. `min` and `max` are required: 0.86 on an
// undeclared scale cannot be compared to anything.
mcpd.score({
traceId,
scorerId: 'answer_relevance',
value: 0.86,
min: 0,
max: 1,
reason: 'Answered the question and gave the delivery date.',
})
// A human evaluation. Kept separate from a score, and never averaged with
// one, because the two answer different questions.
mcpd.feedback({
traceId,
kind: 'thumbs',
value: 'up',
comment: 'Exactly what the customer asked for.',
authorKind: 'end_user',
})
// Anything you already count can travel with the trace.
mcpd.metric('support.tickets_handled', 1, 'counter', { labels: { queue: 'tier-1' } })
Les deux se rattachent à la trace du span actif quand vous n’en nommez
aucune, si bien qu’un bouton de vote dans votre propre produit peut appeler
feedback avec pour seule information l’identifiant de trace que
vous avez conservé.
Les cinq signaux, un seul jeu d’identifiants
Les spans, les logs, les métriques, les scores et le feedback passent par la même file d’attente et portent les mêmes identifiants de trace et de span. C’est ce qui permet à un pic sur un graphique d’ouvrir l’exécution qui l’a causé.
-
Spans
What ran, nested, with timings
-
Logs
Lines the run emitted
-
Metrics
Numbers, with exemplars back to a run
-
Scores
Machine evaluation
-
Feedback
What a person thought
-
Aspect notes
A sentence a model wrote about the run
Flush et arrêt
Enregistrer, c’est ajouter à une file d’attente. Rien n’a quitté le
processus tant que le worker ne l’a pas envoyé, ce qu’il fait toutes les
deux secondes ou quand un lot est plein. flush() comme
shutdown() s’attendent avec await et sont
idempotents, et shutdown() arrête aussi le worker.
Dans une fonction serverless, cela n’est pas facultatif. Le runtime gèle le processus dès que vous renvoyez une réponse, et le worker de fond ne tourne plus jamais : le dernier lot est donc perdu. Attendez un flush avant que la réponse ne parte.
un handler serverless
export default async function handler(request: Request): Promise<Response> {
const answer = await mcpd.withSpan('answer', async (run) => {
run.setInput(await request.text())
return 'ok'
})
// Le lot est encore en mémoire ici. Envoyez-le avant que la réponse ne parte.
await mcpd.flush()
return new Response(answer)
}
Sur Node, le client vide aussi la file sur beforeExit, si bien
qu’un script qui oublie shutdown() obtient quand même son
dernier lot. Ce hook ne se déclenche ni sur un process.exit()
explicite ni sur un signal fatal : c’est un filet de sécurité, pas un
remplacement.
Vérifier ce que vous avez enregistré
Un RecordingSink remplace le réseau par la mémoire, si bien
qu’un test relit les spans, les logs, les métriques, les scores et le
feedback produits par le code testé, sans serveur et sans port. Un
ConsoleSink imprime à la place une ligne JSON par signal, et
tout objet doté d’une méthode export(batch, ingestKey) est un
sink. Un sink qui lève une exception donne un lot en échec et un
diagnostic, jamais une exception dans votre code.
mcpd.stats() répond sur queued,
dropped, capacity, exported,
failed et rejected.
un test sans serveur
import { Mcpd, RecordingSink } from '@mcpd/sdk'
const sink = new RecordingSink()
const mcpd = new Mcpd({ sink })
await answerQuestion(mcpd)
await mcpd.flush()
expect(sink.spans.map((s) => s.name)).toEqual(['answer_question', 'model_call'])
expect(sink.scores[0]?.scorerId).toBe('answer_relevance') Enregistrer un span déjà terminé
mcpd.record(span) met en file un span dont vous détenez déjà
les identifiants, le parent et les temps : ce dont dispose un adaptateur
pour un autre système de tracing, un import par lots ou un rejeu. Il est mis
en file tel quel, jamais re-parenté, re-daté ni ré-échantillonné, la
corrélation que vous avez laissée vide étant remplie depuis le client. Un
enregistrement dont les identifiants n’ont pas la forme OpenTelemetry, dont
le nom est vide ou dont l’heure de début manque est écarté et compté dans
stats().rejected. Sur n’importe quel span,
externalParentSpanId porte un parent venu d’un système de
tracing autre que mcpd, pour la seule corrélation.
un span venu d’ailleurs
// Identifiants, parent et temps déjà détenus. Mis en file tels quels.
mcpd.record({
traceId: otel.traceId,
spanId: otel.spanId,
parentSpanId: otel.parentSpanId,
name: 'GET /orders',
kind: 'http_request',
status: 'ok',
startTime: otel.startTime,
endTime: otel.endTime,
isRoot: false,
isEvent: false,
correlation: { project: 'support' },
receivedAt: otel.endTime,
})
// Un parent venu d’un autre système de tracing, pour la seule corrélation.
const run = mcpd.trace('handle_request', { externalParentSpanId: otel.spanId }) Ce sur quoi vous pouvez compter
-
L’instrumentation ne lève jamais d’exception. Un export en échec, un daemon
injoignable ou un payload qui refuse de se sérialiser sont signalés au
plus une fois par intervalle via
onError, puis abandonnés. L’observabilité ne doit jamais faire tomber ce qu’elle observe. - L’enregistrement ne bloque pas. Il ajoute à une file et rend la main ; le réseau se passe sur un worker de fond, si bien que la latence de votre agent ne dépend jamais de la vitesse de mcpd, ni de sa disponibilité.
-
La mémoire est bornée. La file contient
maxQueuesignaux. Quand elle est pleine, le plus ancien est écarté et compté dansmcpd.stats(), car un tampon sans limite transforme une panne réseau en arrêt pour mémoire épuisée. - La décision d’échantillonnage est prise une seule fois, à la racine, puis héritée. Une trace enregistrée à moitié se lit comme un bug dans votre système.
- Un span ouvert est envoyé quand même. Un span est mis en file à son début et de nouveau à sa fin, si bien qu’une exécution qui plante en plein vol reste visible. Si l’enregistrement de début n’est pas encore parti, celui de fin le remplace : un span rapide coûte un enregistrement et non deux.
-
Votre payload n’est jamais réécrit. Les clés sont converties entre
camelCaseet lesnake_casedu daemon, et la conversion s’arrête àinput,output,attributes,metadata,fieldsetlabels. Votre prompt arrive exactement comme vous l’avez écrit.
Relire ce qui a été enregistré
Le même client lit. Chaque méthode reflète un endpoint HTTP et renvoie les
types générés. Les lectures lèvent une McpdError quand elles
échouent : une requête que vous avez attendue et à laquelle il est
impossible de répondre est une nouvelle qui compte, contrairement à un span
qui n’a pas pu être exporté.
rechercher et analyser
// The same client reads. This is the query that finds bad answers: every run
// that a scorer put below the bar.
const suspect = await mcpd.listTraces({
hasScore: 'answer_relevance',
scoreBelow: 0.5,
limit: 10,
})
console.log(`runs scoring under 0.5: ${suspect.traces.length}`)
const recent = await mcpd.listTraces({ limit: 5, sort: 'start_time', order: 'desc' })
for (const trace of recent.traces) {
const seconds = ((trace.durationNs ?? 0) / 1e9).toFixed(2)
console.log(
`${trace.traceId.slice(0, 8)} ${trace.name.padEnd(16)} ${trace.status.padEnd(6)} ` +
`${seconds}s ${trace.spanCount} spans $${(trace.cost?.totalCost ?? 0).toFixed(6)}`,
)
}
// One run, with every span in it, ordered so a waterfall renders directly.
const detail = await mcpd.getTrace(traceId, { include: ['scores', 'feedback'] })
for (const span of detail.spans) {
console.log(` ${span.kind.padEnd(18)} ${span.name}`)
} Retrouver une exécution couvre les filtres en détail. Le modèle de données liste chaque champ que ces types portent.