Ir al contenido
mcpd
sin daemon
Código

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ónEntornoPor defecto
endpointMCPD_ENDPOINThttp://localhost:4317
apiKeyMCPD_API_KEYninguno
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENTninguno
serviceNameMCPD_SERVICE_NAMEninguno
serviceVersionMCPD_SERVICE_VERSIONninguno
sampleMCPD_SAMPLE1.0
enabledMCPD_ENABLEDtrue
capturePayloadsMCPD_CAPTURE_PAYLOADStrue
logLevelMCPD_LOG_LEVELdebug
batchSizeMCPD_BATCH_SIZE128
flushIntervalMsMCPD_FLUSH_INTERVAL_MS2000
maxQueueMCPD_MAX_QUEUE8192
timeoutMsMCPD_TIMEOUT_MS10000
sinkel 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

trace_id Store Search Export

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 onError y 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 maxQueue señales. Cuando está llena se descarta la más antigua y se cuenta en mcpd.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 camelCase y el snake_case del daemon, y la conversión se detiene en input, output, attributes, metadata, fields y labels. 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.