TypeScript
Un pacchetto senza dipendenze. Registra span, log, metriche, punteggi e feedback, e da una chiamata di strumentazione non solleva mai un’eccezione.
Installazione
Node 20 o successivo, Deno, Bun, i browser e i runtime edge. Prima di tutto
ESM, con una build CommonJS per require. Le uniche parti legate
a Node, async_hooks e zlib, vengono caricate a
runtime e saltate dove non esistono.
npm install @mcpd/sdk Costruire il client
Un client per processo. Ogni opzione legge anche una variabile d’ambiente,
così lo stesso codice gira in sviluppo e in produzione senza una modifica.
Senza nessun argomento parla con un daemon su
http://localhost:4317.
connettersi
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',
}) Il codice vince sull’ambiente, e l’ambiente vince sull’impostazione predefinita.
| Opzione | Ambiente | Predefinito |
|---|---|---|
endpoint | MCPD_ENDPOINT | http://localhost:4317 |
apiKey | MCPD_API_KEY | nessuna |
project | MCPD_PROJECT | default |
environment | MCPD_ENVIRONMENT | nessuno |
serviceName | MCPD_SERVICE_NAME | nessuno |
serviceVersion | MCPD_SERVICE_VERSION | nessuna |
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 | il trasporto HTTP |
enabled: false non registra niente e non manda niente, mentre
ogni chiamata restituisce comunque un oggetto valido e le letture di elenco
restituiscono una pagina vuota. Una suite di test può spegnere mcpd senza
toccare una riga di strumentazione.
capturePayloads: false tiene i prompt e le completion dentro il
processo. Lo span viene registrato lo stesso, con i suoi tempi, lo stato, il
consumo, il modello, la correlazione e gli errori; input e
output vengono scartati prima di arrivare alla coda, così
niente di sensibile può uscire con un flush. Uno span lo può sovrascrivere
per sé con capturePayloads fra le sue opzioni di avvio, in tutte e
due le direzioni.
Registrare un’esecuzione
withSpan è la forma con ambito. Apre lo span, lo tiene attivo
per tutto quello che la callback avvia, e lo chiude quando la callback si
risolve. Se la callback solleva un’eccezione, lo span viene segnato
error con il messaggio sollevato e l’eccezione viene rilanciata
identica, così la tua gestione degli errori non cambia per il fatto di
essere osservata.
registrare un’esecuzione
/**
* 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 }
} Contesto
Su Node e Bun lo span attivo vive in un AsyncLocalStorage.
Tutto quello che parte dentro una callback di withSpan si
annida sotto di esso senza che gli venga passato un riferimento, e due
esecuzioni intrecciate su un solo event loop non vedono mai gli span
dell’altra.
annidamento
// Tutto quello che la callback avvia si annida sotto. Nessun riferimento passato.
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)
})
I browser e quasi tutti i runtime edge non hanno
AsyncLocalStorage. Lì l’SDK tiene invece un solo valore
salvato. Quello si annida correttamente e sopravvive a un
await dentro una singola esecuzione, ma non sa distinguere due
esecuzioni concorrenti, e il risultato sarebbe la chiamata a uno strumento
di un’esecuzione che compare nell’albero di un’altra. Quando fai girare più
agenti insieme su quei runtime, passa il padre in modo esplicito.
padre esplicito
// Corretto su ogni runtime, compresi quelli senza 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()
// Quale meccanismo è in uso, a runtime.
mcpd.stats().context // 'async-local-storage' | 'fallback'
Fra due servizi il contesto viaggia in un header traceparent
W3C. È tutto quello che serve perché una traccia sopravviva a un salto di
rete. Un header malformato viene ignorato e parte una traccia radice nuova,
perché un header che nessuno sa leggere non è un motivo per far fallire la
richiesta che lo portava.
attraverso il confine fra due servizi
// In uscita: metti lo span attivo sulla richiesta.
await fetch(url, {
headers: { traceparent: span.traceparent() },
})
// In entrata: continua quella traccia invece di aprirne una nuova.
const run = mcpd.trace('handle_request', {
traceparent: request.headers.get('traceparent'),
}) Modello, consumo ed errori
I conteggi dei token sono quelli che mcpd valorizza all’ingest, quindi
il costo che vedi sulla traccia è il costo che ti è stato fatturato.
Riferisci quello che ti ha detto il provider, anziché contare i
token per conto tuo. Un errore porta sempre un messaggio, e impostarne uno
porta nello stesso gesto lo stato dello span a error, così i
due non possono mai contraddirsi.
che cosa porta uno span
generation.setModel('claude-sonnet-4', 'anthropic')
generation.setUsage({
inputTokens: 812,
outputTokens: 37,
inputDetails: { cacheRead: 640 },
})
// Un errore che hai intercettato e gestito tu.
generation.setError('the provider timed out', {
kind: 'TimeoutError',
retryable: true,
})
// Un istante dentro lo span, senza durata propria.
generation.event('first_token', { kind: 'model_chunk' }) Esecuzioni durevoli
Lo stesso client serve le esecuzioni durevoli del daemon.
workflow() costruisce la definizione e conserva i corpi dei
passi; un Worker la registra, fa long polling per ottenere i
lease e serve ognuno di essi, e mcpd.runs,
mcpd.events e mcpd.definitions rispecchiano uno a
uno la superficie delle esecuzioni. Un corpo solleva
RetryableError per essere ritentato entro il budget del suo
passo, e registra span sotto il proprio passo attraverso lo
StepContext. Uno span registrato così, e ogni span annidato
sotto di esso, somma il proprio consumo a quello del passo quando finisce,
così il daemon valorizza quello che la traccia mostra e il corpo
non somma mai i token a mano; ctx.setUsage è un override
esplicito. Accanto a onError stanno quattro hook:
onLease, onStepStart, onStepEnd e
onConflict, e worker.stats() conta le prese in
carico, i completamenti, i fallimenti per classe e i conflitti per motivo.
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}' }) Che cosa fa ogni costrutto, il protocollo dei lease e l’API delle esecuzioni stanno su Workflow, Worker ed Esecuzioni.
Punteggi e feedback
min e max sono richiesti dal tipo. Un punteggio di
0.7 su una scala non dichiarata non si può confrontare con niente, e quando
qualcuno sta leggendo il grafico è troppo tardi per chiedere che cosa
volesse dire. Il feedback è il giudizio di una persona ed è tenuto separato
da un punteggio, perché i due rispondono a domande diverse e non vanno mai
mediati insieme.
punteggio e 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' } })
Tutti e due si attaccano alla traccia dello span attivo quando non ne nomini
una, così un controllo con il pollice su o giù dentro il tuo prodotto può
chiamare feedback con nient’altro che l’id di traccia che ti
sei tenuto.
Tutti e cinque i segnali, un solo insieme di id
Span, log, metriche, punteggi e feedback passano dalla stessa coda e portano gli stessi id di traccia e di span. È questo che permette a un picco su un grafico di aprire l’esecuzione che lo ha causato.
-
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 e arresto
Registrare è un’aggiunta a una coda. Niente ha lasciato il processo finché
non lo manda il worker, cosa che fa ogni due secondi o quando un batch si
riempie. Sia flush() sia shutdown() si possono
attendere e sono idempotenti, e shutdown() ferma anche il
worker.
In una funzione serverless questo non è facoltativo. Il runtime congela il processo nel momento in cui restituisci una risposta, e il worker in secondo piano non gira mai più, quindi l’ultimo batch è perso. Attendi un flush prima che la risposta esca.
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'
})
// Qui il batch è ancora in memoria. Mandalo prima che la risposta esca.
await mcpd.flush()
return new Response(answer)
}
Su Node il client fa un flush anche su beforeExit, così uno
script che si dimentica shutdown() manda comunque il suo ultimo
batch. Questo hook non scatta su un process.exit() esplicito né
su un segnale fatale, quindi è una rete di sicurezza e non un sostituto.
Verificare quello che hai registrato
Un RecordingSink sostituisce la rete con la memoria, così un
test rilegge gli span, i log, le metriche, i punteggi e il feedback che ha
prodotto il codice sotto test, senza un server e senza una porta. Un
ConsoleSink stampa invece una riga JSON per segnale, e
qualunque oggetto con un metodo export(batch, ingestKey) è un
sink. Uno che solleva un’eccezione è un batch fallito e una diagnostica, mai
un’eccezione nel tuo codice. mcpd.stats() risponde
queued, dropped, capacity,
exported, failed e rejected.
un test senza server
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') Registrare uno span già concluso
mcpd.record(span) mette in coda uno span di cui hai già gli
identificatori, il padre e i tempi: quello che ha sottomano un adattatore
per un altro sistema di tracing, un’importazione in blocco o un replay.
Viene messo in coda com’è, senza che gli si cambi il padre, i tempi o il
campionamento, e la correlazione che hai lasciato vuota viene riempita a
partire dal client. Un record i cui identificatori non hanno la forma di
OpenTelemetry, il cui nome è vuoto o a cui manca l’ora di inizio viene
scartato e contato in stats().rejected. Su qualunque span,
externalParentSpanId porta un padre che viene da un sistema di
tracing che non è mcpd, solo per la correlazione.
uno span che viene da altrove
// Id, padre e tempi che il chiamante ha già. Vanno in coda così come sono.
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 padre da un sistema di tracing che non è mcpd, solo per la correlazione.
const run = mcpd.trace('handle_request', { externalParentSpanId: otel.spanId }) Su che cosa puoi contare
-
La strumentazione non solleva mai eccezioni. Un export fallito, un daemon
irraggiungibile o un payload che non si lascia serializzare vengono
riferiti al massimo una volta per intervallo attraverso
onErrore poi lasciati andare. L’osservabilità non deve mai far cadere la cosa che osserva. - Registrare non blocca. Aggiunge a una coda e ritorna; la rete avviene su un worker in secondo piano, quindi la latenza del tuo agente non dipende mai da quanto mcpd sia veloce, né dal fatto che sia attivo.
-
La memoria è limitata. La coda tiene
maxQueuesegnali. Quando è piena il più vecchio viene scartato e contato inmcpd.stats(), perché un buffer senza limite trasforma un’interruzione di rete in un processo ucciso per memoria esaurita. - La decisione di campionamento si prende una volta sola, alla radice, e si eredita. Una traccia registrata a metà si legge come un bug nel tuo sistema.
- Uno span aperto viene mandato lo stesso. Uno span va in coda quando parte e di nuovo quando finisce, così un’esecuzione che va in crash per strada resta visibile. Se il record di inizio non è ancora uscito, quello di fine lo sostituisce, così uno span veloce costa un record e non due.
-
Il tuo payload non viene mai riscritto. Le chiavi vengono convertite fra
camelCasee losnake_casedel daemon, e la conversione si ferma ainput,output,attributes,metadata,fieldselabels. Il tuo prompt arriva esattamente come l’hai scritto.
Rileggerla
Lo stesso client legge. Ogni metodo rispecchia un endpoint HTTP e
restituisce i tipi generati. Le letture sollevano un McpdError
quando falliscono: una query che hai atteso e a cui non si sa rispondere è
una notizia, a differenza di uno span che non si è riusciti a esportare.
cercare e analizzare
// 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}`)
} Trovare un’esecuzione copre i filtri per intero. Il modello dei dati elenca ogni campo che questi tipi portano.