Vai al contenuto
mcpd
nessun daemon
Codice

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.

OpzioneAmbientePredefinito
endpointMCPD_ENDPOINThttp://localhost:4317
apiKeyMCPD_API_KEYnessuna
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENTnessuno
serviceNameMCPD_SERVICE_NAMEnessuno
serviceVersionMCPD_SERVICE_VERSIONnessuna
sampleMCPD_SAMPLE1.0
enabledMCPD_ENABLEDtrue
capturePayloadsMCPD_CAPTURE_PAYLOADStrue
logLevelMCPD_LOG_LEVELdebug
batchSizeMCPD_BATCH_SIZE128
flushIntervalMsMCPD_FLUSH_INTERVAL_MS2000
maxQueueMCPD_MAX_QUEUE8192
timeoutMsMCPD_TIMEOUT_MS10000
sinkil 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

trace_id Store Search Export

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 onError e 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 maxQueue segnali. Quando è piena il più vecchio viene scartato e contato in mcpd.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 camelCase e lo snake_case del daemon, e la conversione si ferma a input, output, attributes, metadata, fields e labels. 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.