TypeScript
Un paquete sin dependencias. Registra spans, logs, métricas, puntuaciones y feedback, y nunca lanza una excepción desde una llamada de instrumentación.
Instalación
Node 20 o posterior, Deno, Bun, navegadores y runtimes edge. ESM primero,
con una compilación CommonJS para require. Las únicas piezas
propias de Node, async_hooks y zlib, se cargan en
tiempo de ejecución y se omiten donde no existen.
npm install @mcpd/sdk Construir el cliente
Un cliente por proceso. Cada opción lee también una variable de entorno, así
que el mismo código corre en desarrollo y en producción sin ninguna edición.
Sin ningún argumento habla con un daemon en
http://localhost:4317.
conectar
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',
}) El código gana al entorno, y el entorno gana al valor por defecto.
| Opción | Entorno | Por defecto |
|---|---|---|
endpoint | MCPD_ENDPOINT | http://localhost:4317 |
apiKey | MCPD_API_KEY | ninguno |
project | MCPD_PROJECT | default |
environment | MCPD_ENVIRONMENT | ninguno |
serviceName | MCPD_SERVICE_NAME | ninguno |
serviceVersion | MCPD_SERVICE_VERSION | ninguno |
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 | el transporte HTTP |
enabled: false no registra nada y no envía nada, mientras que
cada llamada sigue devolviendo un objeto válido y las lecturas de listas
devuelven una página vacía. Una suite de pruebas puede apagar mcpd sin tocar
una línea de instrumentación.
capturePayloads: false mantiene los prompts y las completions
dentro del proceso. El span se sigue registrando con sus tiempos, su estado,
su uso, su modelo, su correlación y sus errores; input y
output se descartan antes de llegar a la cola, así que nada
sensible puede salir en un flush. Un span lo anula para sí mismo con
capturePayloads en sus opciones de inicio, en cualquiera de los
dos sentidos.
Registrar una ejecución
withSpan es la forma con ámbito. Abre el span, lo mantiene
activo para todo lo que arranque el callback y lo cierra cuando el callback
termina. Si el callback lanza, el span queda marcado como error
con el mensaje lanzado y el error se relanza sin cambios, de modo que tu
propio manejo de errores no cambia por estar observado.
registrar una ejecución
/**
* 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 }
} Contexto
En Node y en Bun el span activo vive en un AsyncLocalStorage.
Todo lo que arranque dentro de un callback de withSpan se anida
bajo él sin recibir ninguna referencia, y dos ejecuciones entrelazadas en un
mismo bucle de eventos nunca ven los spans de la otra.
anidamiento
// Anything the callback starts nests under it. No handle is passed down.
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)
})
Los navegadores y casi todos los runtimes edge no tienen
AsyncLocalStorage. Ahí el SDK guarda en su lugar un único valor.
Eso anida bien y sobrevive a un await dentro de una ejecución,
pero no distingue dos ejecuciones concurrentes, y el resultado sería la
llamada a herramienta de una ejecución apareciendo dentro del árbol de otra.
Cuando ejecutes agentes de forma concurrente en esos runtimes, pasa el padre
de forma explícita.
padre explícito
// Correct on every runtime, including ones with no 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()
// Which mechanism is in use, at runtime.
mcpd.stats().context // 'async-local-storage' | 'fallback'
Entre servicios, el contexto viaja en una cabecera traceparent
de W3C. Eso es todo lo que hace que una traza sobreviva a un salto de red.
Una cabecera mal formada se ignora y se inicia una traza raíz nueva, porque
una cabecera que nadie puede leer no es motivo para fallar la solicitud que
la traía.
a través de un límite de servicio
// Outbound: put the active span on the request.
await fetch(url, {
headers: { traceparent: span.traceparent() },
})
// Inbound: continue that trace instead of starting a new one.
const run = mcpd.trace('handle_request', {
traceparent: request.headers.get('traceparent'),
}) Modelo, uso y errores
mcpd calcula el precio a partir de los recuentos de tokens en la ingesta,
así que el costo que ves en la traza es el costo que te facturaron. Informa
lo que te dijo el proveedor en vez de contar los tokens por tu cuenta. Un
error siempre lleva un mensaje, y asignarlo pone el estado del span en
error en el mismo movimiento, así que los dos nunca pueden
discrepar.
qué lleva un span
generation.setModel('claude-sonnet-4', 'anthropic')
generation.setUsage({
inputTokens: 812,
outputTokens: 37,
inputDetails: { cacheRead: 640 },
})
// A failure you caught and handled yourself.
generation.setError('the provider timed out', {
kind: 'TimeoutError',
retryable: true,
})
// A point in time inside the span, with no duration of its own.
generation.event('first_token', { kind: 'model_chunk' }) Ejecuciones duraderas
El mismo cliente sirve las ejecuciones duraderas del daemon.
workflow() construye la definición y guarda los cuerpos de los
pasos; un Worker la registra, hace long polling para obtener
leases y atiende cada uno, y mcpd.runs,
mcpd.events y mcpd.definitions reflejan la
superficie de ejecuciones una a una. Un cuerpo lanza
RetryableError para que se reintente dentro del presupuesto del
paso, y registra spans bajo su paso a través del StepContext.
Un span registrado así, y todos los spans anidados bajo él, suman su uso al
del paso cuando este termina, de modo que el daemon cobra lo que muestra la
traza y el cuerpo nunca suma tokens a mano; ctx.setUsage es una
anulación explícita. Junto a onError hay cuatro hooks:
onLease, onStepStart, onStepEnd y
onConflict, y worker.stats() cuenta tomas,
finalizaciones, fallos por clase y conflictos por 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}' }) Qué hace cada construcción, el protocolo de leases y la API de ejecuciones están en Workflows, Workers y Ejecuciones.
Puntuaciones y feedback
El tipo exige min y max. Una puntuación de 0.7 en
una escala sin declarar no se puede comparar con nada, y para cuando alguien
esté leyendo el gráfico ya es tarde para preguntar qué significaba. El
feedback es el juicio de una persona y se guarda aparte de una puntuación,
porque los dos responden preguntas distintas y nunca se deben promediar
juntos.
puntuación y 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' } })
Ambos se adjuntan a la traza del span activo cuando no nombras ninguna, así
que un control de pulgar arriba o abajo en tu propio producto puede llamar a
feedback sin más que el id de traza que guardaste.
Las cinco señales, un solo juego de ids
Los spans, los logs, las métricas, las puntuaciones y el feedback pasan por la misma cola y llevan los mismos ids de traza y de span. Eso es lo que permite que un pico en un gráfico abra la ejecución que lo 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 y apagado
Registrar es añadir a una cola. Nada ha salido del proceso hasta que el
worker lo envía, cosa que hace cada dos segundos o cuando se llena un lote.
Tanto flush() como shutdown() se pueden esperar y
son idempotentes, y shutdown() además detiene el worker.
En una función serverless esto no es opcional. El runtime congela el proceso en cuanto devuelves una respuesta, y el worker en segundo plano no vuelve a correr, así que el último lote se pierde. Espera un flush antes de que salga la respuesta.
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'
})
// The batch is still in memory here. Send it before the response goes out.
await mcpd.flush()
return new Response(answer)
}
En Node el cliente también hace flush en beforeExit, así que un
script que se olvida de shutdown() aún recibe su último lote.
Ese hook no se dispara con un process.exit() explícito ni con
una señal fatal, así que es una red de seguridad y no un sustituto.
Comprobar lo que registraste
Un RecordingSink sustituye la red por memoria, así que una
prueba vuelve a leer los spans, los logs, las métricas, las puntuaciones y
el feedback que produjo el código bajo prueba, sin servidor y sin puerto. Un
ConsoleSink imprime en su lugar una línea JSON por señal, y
cualquier objeto con un método export(batch, ingestKey) es un
sink. Uno que lanza es un lote fallido y un diagnóstico, nunca una excepción
en tu código. mcpd.stats() responde queued,
dropped, capacity, exported,
failed y rejected.
una prueba sin servidor
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') Registrar un span ya terminado
mcpd.record(span) encola un span cuyos ids, padre y tiempos ya
tienes: lo que trae en la mano un adaptador de otro sistema de trazas, una
importación por lotes o una repetición. Se encola tal cual, nunca se le
cambia el padre, ni los tiempos, ni el muestreo, y la correlación que dejó
vacía se rellena desde el cliente. Un registro cuyos ids no tienen la forma
de OpenTelemetry, cuyo nombre está vacío o al que le falta la hora de inicio
se descarta y se cuenta en stats().rejected. En cualquier span,
externalParentSpanId lleva un padre de un sistema de trazas que
no es mcpd, solo para correlacionar.
un span de otro sitio
// Ids, parent and times the caller already holds. Queued as given.
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,
})
// A parent from a tracing system that is not mcpd, for correlation only.
const run = mcpd.trace('handle_request', { externalParentSpanId: otel.spanId }) Con qué puedes contar
-
La instrumentación nunca lanza. Una exportación fallida, un daemon
inalcanzable o un payload que no se serializa se informan como mucho una
vez por intervalo a través de
onErrory después se dejan pasar. La observabilidad nunca debe tumbar aquello que observa. - Registrar no bloquea. Añade a una cola y vuelve; la red ocurre en un worker en segundo plano, así que la latencia de tu agente nunca depende de que mcpd sea rápido, ni de que esté en pie.
-
La memoria está acotada. La cola guarda
maxQueueseñales. Cuando está llena se descarta la más antigua y se cuenta enmcpd.stats(), porque un búfer sin límite convierte una caída de red en una muerte por falta de memoria. - La decisión de muestreo se toma una vez, en la raíz, y se hereda. Una traza registrada a medias se lee como un fallo de tu sistema.
- Un span abierto también se envía. Un span se encola cuando empieza y otra vez cuando termina, así que una ejecución que se cae a mitad de vuelo es visible. Si el registro de inicio todavía no ha salido, el registro de fin lo reemplaza, así que un span rápido cuesta un registro y no dos.
-
Tu payload nunca se reescribe. Las claves se convierten entre
camelCasey elsnake_casedel daemon, y la conversión se detiene eninput,output,attributes,metadata,fieldsylabels. Tu prompt llega exactamente como lo escribiste.
Volver a leerlo
El mismo cliente lee. Cada método refleja un endpoint HTTP y devuelve los
tipos generados. Las lecturas lanzan un McpdError cuando
fallan: una consulta que esperaste y no se puede responder es una noticia, a
diferencia de un span que no se pudo exportar.
buscar y analizar
// 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}`)
} Encontrar una ejecución cubre los filtros por completo. El modelo de datos lista todos los campos que llevan estos tipos.